From bdc96aecd2f0a0a43482a36842a51cfa088c2845 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 08:23:17 -0400 Subject: [PATCH 01/31] refactor(cargo): patch crates in place; drop the [patch]-redirect backend + build guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify the cargo apply path to patch crates in place (vendored or registry cache) like npm/pypi/gem, removing the project-local `[patch]`-redirect backend and the build-time `socket-patch-guard` setup wiring. Cargo now rolls back in place from before-blobs rather than dropping a redirect. Removed: - core: `cargo_setup` (discover/update), `patch/cargo_config`, `patch/cargo_redirect`, and `go_setup` (the build-time Go guard package + templates). - cli: cargo/go redirect dispatch in `apply`/`rollback`, cargo + Go guard wiring in `setup`, and the now-dead setup/redirect tests. Kept: - Go `replace`-redirect (`go_redirect`/`go_mod_edit`/`copy_tree`): the module cache is checksum-verified, so in-place patching fails `go.sum` at build time — Go still needs the project-local copy. - The `socket-patch-guard` crate and its build-integration test. `copy_tree` is now gated on `golang` only. Docs (README, CLI_CONTRACT, module headers) updated to drop the removed backends. Builds clean across feature combos (default / none / all / cargo-only) and the full suite passes (--no-fail-fast). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 5 +- crates/socket-patch-cli/CLI_CONTRACT.md | 103 +- crates/socket-patch-cli/src/commands/apply.rs | 286 +--- .../socket-patch-cli/src/commands/rollback.rs | 169 +-- crates/socket-patch-cli/src/commands/setup.rs | 622 +------- .../tests/e2e_cargo_coexist.rs | 763 ---------- .../tests/e2e_golang_build.rs | 155 +- .../tests/guard_build_integration.rs | 2 +- .../tests/setup_cargo_invariants.rs | 275 ---- .../tests/setup_cargo_roundtrip.rs | 345 ----- .../tests/setup_contract_gaps.rs | 42 - .../tests/setup_go_roundtrip.rs | 135 -- .../tests/setup_matrix_cargo.rs | 292 ---- .../tests/setup_matrix_golang.rs | 307 ---- .../tests/setup_monorepo_invariants.rs | 241 --- .../src/cargo_setup/discover.rs | 629 -------- .../socket-patch-core/src/cargo_setup/mod.rs | 15 - .../src/cargo_setup/update.rs | 404 ----- crates/socket-patch-core/src/gem_setup/mod.rs | 10 +- .../socket-patch-core/src/gem_setup/update.rs | 9 +- crates/socket-patch-core/src/go_setup/mod.rs | 809 ---------- .../src/go_setup/templates/guard.go.tmpl | 173 --- .../src/go_setup/templates/guard_test.go.tmpl | 21 - crates/socket-patch-core/src/lib.rs | 4 - .../src/patch/cargo_config.rs | 788 ---------- .../src/patch/cargo_redirect.rs | 1348 ----------------- .../socket-patch-core/src/patch/copy_tree.rs | 14 +- .../src/patch/go_mod_edit.rs | 9 +- .../src/patch/go_redirect.rs | 13 +- crates/socket-patch-core/src/patch/mod.rs | 6 +- 30 files changed, 286 insertions(+), 7708 deletions(-) delete mode 100644 crates/socket-patch-cli/tests/e2e_cargo_coexist.rs delete mode 100644 crates/socket-patch-cli/tests/setup_cargo_invariants.rs delete mode 100644 crates/socket-patch-cli/tests/setup_cargo_roundtrip.rs delete mode 100644 crates/socket-patch-cli/tests/setup_go_roundtrip.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_cargo.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_golang.rs delete mode 100644 crates/socket-patch-cli/tests/setup_monorepo_invariants.rs delete mode 100644 crates/socket-patch-core/src/cargo_setup/discover.rs delete mode 100644 crates/socket-patch-core/src/cargo_setup/mod.rs delete mode 100644 crates/socket-patch-core/src/cargo_setup/update.rs delete mode 100644 crates/socket-patch-core/src/go_setup/mod.rs delete mode 100644 crates/socket-patch-core/src/go_setup/templates/guard.go.tmpl delete mode 100644 crates/socket-patch-core/src/go_setup/templates/guard_test.go.tmpl delete mode 100644 crates/socket-patch-core/src/patch/cargo_config.rs delete mode 100644 crates/socket-patch-core/src/patch/cargo_redirect.rs diff --git a/README.md b/README.md index 3190eb6..96a6e62 100644 --- a/README.md +++ b/README.md @@ -408,10 +408,9 @@ Configure your project so patches are **re-applied automatically after install** - **npm / yarn / pnpm / bun** — writes a `postinstall` script into `package.json` so any install re-applies patches (pnpm: root package only). - **Python (pip / uv / poetry / pdm / hatch)** — Python has no universal post-install hook, so `setup` instead commits a **`socket-patch[hook]`** dependency (for classic Poetry, the equivalent `socket-patch = { extras = ["hook"] }`). Installing it lays down a startup `.pth` (shipped by the small `socket-patch-hook` wheel) that re-applies your committed `.socket/` patches the next time the interpreter runs. It is package-manager-agnostic (it rides the interpreter, not any one installer) and **fail-open** — a hook error can never break interpreter startup. -- **Cargo** — adds a `socket-patch-guard` build dependency to each workspace member's `Cargo.toml` plus an `[env] SOCKET_PATCH_ROOT` in `.cargo/config.toml`. The guard's build script re-applies patches on every `cargo build` and is **fail-closed** — a build using stale/unpatched sources fails loudly. (Requires the `socket-patch` CLI on `PATH` at build time.) -- **Go** — generates a committed `internal/socketpatchguard/` guard package plus a blank import in each `main` package. The guard re-applies patches and gates both `go test ./...` (CI) and every `go run` / binary launch via `init()` — **fail-closed**. Fully self-contained committed source. (Requires the `socket-patch` CLI on `PATH`.) - **Ruby gems (Bundler)** — adds a managed `plugin "socket-patch"` block to the `Gemfile` and commits an in-tree Bundler plugin under `.socket/bundler-plugin/`. It re-applies patches on every `bundle install` (cached *and* fresh). (Requires the `socket-patch` CLI on `PATH`.) - **Composer (PHP)** *(opt-in `composer` feature)* — appends `socket-patch apply` to `composer.json`'s `post-install-cmd` / `post-update-cmd` script events, so patches re-apply on every `composer install` / `composer update`. Only available in a build compiled with `--features composer`. (Requires the `socket-patch` CLI on `PATH`.) +- **Cargo & Go** — *apply-only, no `setup` hook.* A one-click auto-repatch-on-build isn't possible for these, so `setup` skips them. Patch with `socket-patch apply` directly: **cargo** patches the crate in place (in `vendor/` or the registry cache, rewriting `.cargo-checksum.json` so `cargo build` accepts it); **go** writes a project-local patched copy under `.socket/go-patches/` plus a `go.mod` `replace` directive (the module cache is `go.sum`-verified, so in-place patching can't build). Commit `go.mod` + `.socket/go-patches/` so a clone builds the patched bytes. Declare them in `setup.manual` for VEX attestation. - **Apply-only ecosystems** (nuget · maven · deno) — no native install hook to wire, so `setup` reports `no_files`; patch them on demand with `socket-patch apply`. **Usage:** @@ -425,7 +424,7 @@ socket-patch setup --remove # revert what setup added | Flag | Description | |------|-------------| | `--check` | Read-only verification that every manifest is configured; exits non-zero if any still needs setup. Never writes (safe in CI). Conflicts with `--remove`. | -| `--remove` | Revert the install hooks `setup` added (npm `package.json` scripts, the Python `socket-patch[hook]` dependency, the cargo `socket-patch-guard` dependency + `[env]`, the Go guard package + imports, and the gem Bundler plugin wiring). | +| `--remove` | Revert the install hooks `setup` added (npm `package.json` scripts, the Python `socket-patch[hook]` dependency, and the gem Bundler plugin wiring). | #### Disabling / opting out (Python hook) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index fa56608..4b39e17 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -14,7 +14,7 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth | `scan` | — | Crawl installed packages for available patches | | `list` | — | Print patches in the local manifest | | `remove` | — | Remove patch from manifest (rolls back first); requires positional `identifier` | -| `setup` | — | Wire automatic-patching install hooks (npm/pypi/cargo/gem/golang) | +| `setup` | — | Wire automatic-patching install hooks (npm/pypi/gem) | | `repair` | `gc` | Download missing blobs + clean up unused ones | | `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest | @@ -109,15 +109,15 @@ in particular, are behavior changes that gate a version bump when implemented). 2. **Ecosystem-scoped.** `setup`, `setup --check`, and `setup --remove` honor the global `--ecosystems` filter and act on only the named ecosystems; with no filter they act on every detected ecosystem. *(Intended; **not yet implemented** — `setup` currently ignores `--ecosystems` - and always processes every detected ecosystem (npm + python + cargo + golang + gem). RED-guarded.)* + and always processes every detected ecosystem (npm + python + gem). RED-guarded.)* 3. **Consistency after install.** Once an ecosystem is set up, its locally-installed dependencies are re-patched to match the manifest after **any** of: a dependency added, updated, or removed; **or** a - new patch added to the manifest. The re-patch is carried by the ecosystem's install/build hook (npm - `postinstall`/`dependencies`, the Python `.pth` startup hook, the cargo guard build script, the gem - Bundler plugin, the golang guard package) which runs `socket-patch apply` after the ecosystem's - installer finishes, so patch state always reconverges with the manifest. *(Implemented for - npm/pypi/cargo/gem/golang via the support matrix.)* + new patch added to the manifest. The re-patch is carried by the ecosystem's install hook (npm + `postinstall`/`dependencies`, the Python `.pth` startup hook, the gem Bundler plugin) which runs + `socket-patch apply` after the ecosystem's installer finishes, so patch state always reconverges with + the manifest. *(Implemented for npm/pypi/gem via the support matrix. Cargo and Go have no `setup` + hook — see "Cargo and Go: apply-only, no setup" below.)* 4. **`check` proves a correctly-patched state.** `setup --check` reports `configured` only when the in-scope ecosystems are *actually in a correctly patched state* — install hooks present **and** @@ -126,8 +126,8 @@ in particular, are behavior changes that gate a version bump when implemented). on-disk patch consistency. RED-guarded.)* 5. **In-repo and committable.** `setup` writes only inside the working tree: `package.json`, - `pyproject.toml`/`requirements.txt`, member `Cargo.toml`s, `.cargo/config.toml`, the `Gemfile` + - generated `.socket/bundler-plugin/`. Every artifact is git-committable. It never writes outside + `pyproject.toml`/`requirements.txt`, the `Gemfile` + generated `.socket/bundler-plugin/`. Every + artifact is git-committable. It never writes outside `--cwd` — no `$HOME`, no global `site-packages` (the Python `.pth` wheel is installed later by the user's package manager, not by `setup`; the gem patch stamp is written under `Bundler.bundle_path` by the plugin at `bundle install` time, not by `setup`). *(Implemented.)* @@ -144,63 +144,77 @@ in particular, are behavior changes that gate a version bump when implemented). filter and on-disk verification. Applies in both verify and `--no-verify` modes.)* - **Manual declaration.** Users who run `socket-patch apply` by hand (e.g. in a CI step) declare an ecosystem as `manual` so VEX still attests its patches even though the auto-install hook is - intentionally not wired. Home: the `setup.manual` array (a list of ecosystem `cli_name`s — `pypi`, - `cargo`, …) in `.socket/manifest.json`. *(Implemented for the read/attest path; a `setup` flag to + intentionally not wired. This is the normal path for **cargo** and **golang** (apply-only, no + `setup` hook). Home: the `setup.manual` array (a list of ecosystem `cli_name`s — `pypi`, `cargo`, + `golang`, …) in `.socket/manifest.json`. *(Implemented for the read/attest path; a `setup` flag to populate it is a future nicety — today it's hand-authored in the manifest.)* 8. **Graceful, exact remove.** `setup --remove` (optionally per-ecosystem via `--ecosystems`) restores the repo to its exact pre-setup state: manifests byte-for-byte, sibling scripts/dependencies preserved, keys that became empty dropped. Afterward `setup --check` reports needs-configuration - again. *(Implemented for the manifest edits — npm `package.json`, Python deps, and member - `Cargo.toml`s all round-trip byte-for-byte. **Known residue:** a `.cargo/config.toml` (and its - `.cargo/` dir) that `setup` created is left behind empty rather than deleted on `--remove`; - RED-guarded.)* + again. *(Implemented for the manifest edits — npm `package.json` and Python deps round-trip + byte-for-byte.)* 9. **Nested workspaces, with exclude.** Setup applies to every subproject below the repo root: npm / - yarn / pnpm / bun workspace members and cargo workspace members are all discovered and configured - (pnpm is root-package-only by design, because workspace-member `postinstall` scripts fail under - pnpm's strict module isolation). Selected paths may be **excluded**, and the exclusion is **persisted - in `.socket/manifest.json`** so `check`, `apply`, and any clone all honor it. *(Implemented — - nested-workspace discovery plus the `--exclude` flag, persisted as the `setup.exclude` array in - `.socket/manifest.json` and honored by discovery + `check` (a fresh clone inherits it without - re-passing the flag). Excludes apply to npm + cargo workspace members; the repo root is never - excludable.)* - - **Nested workspaces (implemented).** A workspace member that is itself a workspace root — or, for - cargo, members matched by a recursive `members = ["crates/**"]` glob — is recursed into and has its - own members configured. `find_workspace_packages` re-reads each discovered member's own - `workspaces` field (bounded depth), and `discover_cargo_project`'s `expand_member` expands the - recursive `crates/**` glob (`glob_dir_recursive`, skipping `target/` + hidden dirs). Guarded by - `setup_recurses_into_nested_npm_workspace` / `setup_expands_recursive_cargo_member_glob` in - `tests/setup_monorepo_invariants.rs`. + yarn / pnpm / bun workspace members are all discovered and configured (pnpm is root-package-only by + design, because workspace-member `postinstall` scripts fail under pnpm's strict module isolation). + Selected paths may be **excluded**, and the exclusion is **persisted in `.socket/manifest.json`** so + `check`, `apply`, and any clone all honor it. *(Implemented — nested-workspace discovery plus the + `--exclude` flag, persisted as the `setup.exclude` array in `.socket/manifest.json` and honored by + discovery + `check` (a fresh clone inherits it without re-passing the flag). Excludes apply to npm + workspace members; the repo root is never excludable.)* + - **Nested workspaces (implemented).** A workspace member that is itself a workspace root is recursed + into and has its own members configured. `find_workspace_packages` re-reads each discovered + member's own `workspaces` field (bounded depth). Guarded by the nested-workspace pins in + `tests/setup_invariants.rs`. ### Per-ecosystem setup support -`setup` installs an automatic-repatch hook for the five ecosystems with a usable post-install / build / -startup hook (npm, pypi, cargo, gem, golang) — plus **composer** when the binary is built with the -opt-in `composer` feature. The remaining ecosystems are **apply-only**: `socket-patch apply` patches -them on demand, but there is no hook for `setup` to install, so `setup` is a `no_files` no-op for them. -These are exactly the ecosystems for which property 7's **manual** declaration is intended (so their -hand-applied patches still show up in VEX). +`setup` installs an automatic-repatch hook for the three ecosystems with a usable post-install / +startup hook (npm, pypi, gem) — plus **composer** when the binary is built with the opt-in `composer` +feature. The remaining ecosystems are **apply-only**: `socket-patch apply` patches them on demand, but +there is no hook for `setup` to install, so `setup` is a `no_files` no-op for them. These are exactly +the ecosystems for which property 7's **manual** declaration is intended (so their hand-applied patches +still show up in VEX). | Ecosystem | Hook `setup` installs | Repatch trigger | Notes | |---|---|---|---| | npm / yarn / pnpm / bun | `scripts.postinstall` + `scripts.dependencies` | `npm/pnpm install` (+ `install `) | pnpm: root package only | | pypi | `socket-patch[hook]` dependency → `.pth` startup hook | Python interpreter startup after installed-set change | manifest = `pyproject.toml` (uv/poetry/pdm/hatch) or `requirements.txt` (pip) | -| cargo | `socket-patch-guard` dependency + `[env] SOCKET_PATCH_ROOT` in `.cargo/config.toml` | every `cargo build` (fail-closed guard) | per-member dep + one workspace-root `[env]`; the guard crate is published to crates.io each release | | gem | managed `plugin "socket-patch"` block in the `Gemfile` → committed in-tree Bundler plugin under `.socket/bundler-plugin/` | every `bundle install` (cached + fresh: load-time digest gate + `after-install-all` hook) | Bundler loads only committed git plugins, so the generated dir must be committed; CLI must be on `PATH`. Phase 1 references the in-tree plugin via `git:`; Phase 2 (follow-up) switches to a published `socket-patch-bundler` gem | -| golang | generated `internal/socketpatchguard/` guard package (`guard.go` + `guard_test.go`) + a blank import in each `main` package | every `go test ./...` (CI gate) **and** every `go run` / binary launch (`init()` guard) — fail-closed | self-contained: committed Go source, no published artifact; CLI must be on `PATH` | | composer *(opt-in `composer` feature)* | `socket-patch apply` appended to `composer.json`'s `post-install-cmd` + `post-update-cmd` script events | every `composer install` / `composer update` | CLI must be on `PATH`; only compiled in with `--features composer` (apply support is likewise feature-gated). Without the feature, composer is a `no_files` no-op | +| cargo · golang | **none** (apply-only) | — | see "Cargo and Go: apply-only, no setup" below; candidates for the **manual** declaration | | nuget · maven · deno | **none** (apply-only) | — | `setup` reports `no_files`; candidates for the **manual** declaration | +#### Cargo and Go: apply-only, no setup + +Cargo and Go have **no `setup` hook** — a one-click, auto-repatch-on-build setup isn't possible for +them, so `setup` skips both (it never writes a `socket-patch-guard` dependency, `[env]`, +`internal/socketpatchguard/`, a `[patch]` entry, or a `go.mod` `replace` as a *setup* action). Patch +them with `socket-patch apply` directly (manually or from a per-project install script), and declare +them in `setup.manual` for VEX attestation. + +- **cargo** — `apply` patches the crate **in place** wherever the crawler finds it: the project + `vendor/` directory or the shared registry cache (`$CARGO_HOME/registry/src/...`). The + `.cargo-checksum.json` sidecar is rewritten so `cargo build` accepts the modified files. Rollback + restores the original bytes from the `beforeHash` blobs. *(Note: a non-vendored crate patches the + **shared** registry cache, which affects other projects on the machine and is reset by `cargo clean` + / a cache prune. Vendor the dependency for a project-local, committable patch.)* +- **golang** — `apply` writes a project-local **patched copy** under `.socket/go-patches/@/` + and a `go.mod` `replace` directive pointing at it; `go build` links the copy (the module cache is + `go.sum`-verified, so in-place patching can't build). Commit `go.mod` + `.socket/go-patches/` + your + `.socket/` patches so a clone builds the patched bytes with no further setup. `socket-patch apply + --check` is a read-only audit of the committed redirect. + ### Monorepo / multi-project discovery model How `setup` (and the underlying `scan`/`apply` crawlers) find subprojects differs by ecosystem, and the model is **not uniform** today: -- **Workspace-aware (walk members):** npm / yarn / pnpm / bun (`workspaces` / `pnpm-workspace.yaml`) - and cargo (`[workspace] members`). One repo-root invocation discovers and configures every member. - *Single level only* — see property 9's nested-workspace gap. -- **cwd-only (single project):** gem, pypi, golang, composer. The crawler inspects only the project +- **Workspace-aware (walk members):** npm / yarn / pnpm / bun (`workspaces` / `pnpm-workspace.yaml`). + One repo-root invocation discovers and configures every member. *Single level only* — see property + 9's nested-workspace gap. +- **cwd-only (single project):** gem, pypi, composer. The crawler inspects only the project rooted at `--cwd` (e.g. gem looks at `/vendor/bundle/...`; pypi at `/.venv`); it does **not** descend into sibling subprojects. A monorepo with several independent lockfiles in subdirectories (`backend/Gemfile.lock` + `frontend/Gemfile.lock`, multiple `.venv`, multiple `go.mod` / @@ -208,7 +222,7 @@ the model is **not uniform** today: per-directory install hook would. **Intended (gap):** the cwd-only ecosystems *should* also auto-discover per-subproject lockfiles when -run from the repo root, matching the npm/cargo workspace model. The npm-vs-others asymmetry is a known +run from the repo root, matching the npm workspace model. The npm-vs-others asymmetry is a known defect, guarded by the `#[ignore]`d gap pin `gem_crawl_from_repo_root_discovers_all_subproject_lockfiles` in `crates/socket-patch-core/tests/crawler_monorepo_gaps.rs` (gem is the representative; python/go/composer @@ -225,8 +239,7 @@ is patched identically to a direct one. Pinned by `setup` predates the v3.0 unified envelope and emits its own three shapes. They are stable as of v3.0; consumers may rely on these keys. All three share a `files[*]` entry shape; `kind` is one of -`package_json`, `pth`, `cargo`, `cargo_env`, `go_guard`, `go_import`, `gemfile`, `gem_plugin`, -`composer`. +`package_json`, `pth`, `gemfile`, `gem_plugin`, `composer`. **`setup`:** diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 1836f67..92157c4 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -12,12 +12,6 @@ use socket_patch_core::manifest::schema::PatchRecord; use socket_patch_core::patch::apply::{ apply_package_patch, verify_file_patch, ApplyResult, PatchSources, VerifyStatus, }; -#[cfg(feature = "cargo")] -use socket_patch_core::patch::cargo_redirect::{ - apply_cargo_redirect, reconcile_cargo_redirects, verify_cargo_redirect_state, -}; -#[cfg(feature = "cargo")] -use socket_patch_core::utils::purl::parse_cargo_purl; #[cfg(feature = "golang")] use socket_patch_core::patch::go_redirect::{ apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state, @@ -84,7 +78,7 @@ pub struct ApplyArgs { #[arg(short = 'f', long, env = "SOCKET_FORCE", default_value_t = false)] pub force: bool, - /// Read-only: verify that the committed cargo patch redirects match the + /// Read-only: verify that the committed Go `replace`-redirects match the /// manifest (for CI / GitHub-App auditing), exiting non-zero on drift. /// Lock-free and offline-safe — it does not crawl, fetch, or mutate. #[arg( @@ -102,165 +96,6 @@ pub struct ApplyArgs { pub vex: VexEmbedArgs, } -// ── local-cargo redirect helpers ───────────────────────────────────────────── -// Cargo's project-local `[patch]`-redirect backend (local mode only). In a -// build WITHOUT the `cargo` feature these are inert stubs so the dispatch sites -// stay branch-free and `Ecosystem::Cargo` (which only exists under that -// feature) is never named outside a gated block. - -/// True for a cargo PURL in local mode (no `--global` / `--global-prefix`), -/// which redirects to a project-local patched copy rather than patching in -/// place in the shared registry. -#[cfg(feature = "cargo")] -fn is_local_cargo(purl: &str, common: &GlobalArgs) -> bool { - !common.global - && common.global_prefix.is_none() - && Ecosystem::from_purl(purl) == Some(Ecosystem::Cargo) -} - -/// True if a resolved crate path lives under `/vendor/` (a vendored -/// crate, patched in place) rather than the registry cache (redirected). -#[cfg(feature = "cargo")] -fn is_under_vendor(pkg_path: &Path, cwd: &Path) -> bool { - pkg_path.starts_with(cwd.join("vendor")) -} - -/// Whether local-cargo redirects are in scope (local mode + cargo not filtered -/// out by `--ecosystems`). Gates reconcile / `--check` so we never touch cargo -/// state when the user scoped to other ecosystems. -#[cfg(feature = "cargo")] -fn cargo_in_local_scope(common: &GlobalArgs) -> bool { - if common.global || common.global_prefix.is_some() { - return false; - } - match &common.ecosystems { - None => true, - Some(list) => list.iter().any(|e| e.eq_ignore_ascii_case("cargo")), - } -} - -/// Materialise a local-cargo redirect for `purl`, or `None` if `purl` isn't a -/// local-cargo target (the caller then falls back to in-place apply). -#[cfg(feature = "cargo")] -async fn try_local_cargo_apply( - purl: &str, - pkg_path: &Path, - patch: &PatchRecord, - sources: &PatchSources<'_>, - common: &GlobalArgs, - force: bool, -) -> Option { - if !is_local_cargo(purl, common) { - return None; - } - let (name, version) = parse_cargo_purl(purl)?; - // The redirect model targets registry-cache crates - // (`$CARGO_HOME/registry/src/...`). A VENDORED crate (under `/vendor/`) - // is already project-local + committed, so the shared-registry isolation the - // redirect solves doesn't apply, and `[patch.crates-io]` doesn't compose - // with source replacement — vendored crates keep the in-place sidecar path. - // The crawler searches `vendor/` *exclusively* when it exists, so the - // reliable discriminator is whether the resolved path is under it. - if is_under_vendor(pkg_path, &common.cwd) { - return None; // vendored crate → fall through to in-place apply - } - warn_if_dirty_registry(purl, pkg_path, patch, common).await; - Some( - apply_cargo_redirect( - purl, - name, - version, - pkg_path, - &common.cwd, - &patch.files, - sources, - Some(&patch.uuid), - common.dry_run, - force, - ) - .await, - ) -} - -#[cfg(not(feature = "cargo"))] -async fn try_local_cargo_apply( - _purl: &str, - _pkg_path: &Path, - _patch: &PatchRecord, - _sources: &PatchSources<'_>, - _common: &GlobalArgs, - _force: bool, -) -> Option { - None -} - -/// Warn-only migration aid: detect a shared-registry crate that the legacy -/// in-place backend already patched (a source file hashing to its `afterHash`, -/// with `after != before`) and point the user at restoring the pristine copy. -/// The project-local backend never mutates the registry, so it stays dirty -/// until cargo re-fetches. Suppressed under `--offline` (the guard's mode) so -/// it surfaces on a human's manual apply without spamming every build. -#[cfg(feature = "cargo")] -async fn warn_if_dirty_registry( - purl: &str, - pkg_path: &Path, - patch: &PatchRecord, - common: &GlobalArgs, -) { - use socket_patch_core::patch::apply::normalize_file_path; - use socket_patch_core::patch::file_hash::compute_file_git_sha256; - - if common.offline || common.silent || common.json { - return; - } - if !pkg_path.join(".cargo-checksum.json").exists() { - return; - } - for (file_name, info) in &patch.files { - if info.before_hash == info.after_hash || info.after_hash.is_empty() { - continue; - } - let on_disk = pkg_path.join(normalize_file_path(file_name)); - if let Ok(h) = compute_file_git_sha256(&on_disk).await { - if h == info.after_hash { - eprintln!( - "warning: the shared registry crate for {purl} at {} appears to have been \ - patched in place by the legacy cargo backend. The project-local backend \ - leaves the registry untouched and uses a copy under .socket/cargo-patches/; \ - cargo restores the pristine source on re-fetch (or delete the crate dir).", - pkg_path.display() - ); - return; - } - } - } -} - -/// After the apply loop: prune local-cargo redirects whose patches were -/// dropped from the manifest. No-op unless local cargo is in scope. -#[cfg(feature = "cargo")] -async fn reconcile_local_cargo(common: &GlobalArgs, target_manifest_purls: &HashSet) { - if !cargo_in_local_scope(common) { - return; - } - let desired: HashSet = target_manifest_purls - .iter() - .filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Cargo)) - .cloned() - .collect(); - let removed = reconcile_cargo_redirects(&common.cwd, &desired, common.dry_run).await; - if !removed.is_empty() && !common.silent && !common.json { - let verb = if common.dry_run { "Would remove" } else { "Removed" }; - println!("{verb} {} stale cargo patch redirect(s):", removed.len()); - for purl in &removed { - println!(" {purl}"); - } - } -} - -#[cfg(not(feature = "cargo"))] -async fn reconcile_local_cargo(_common: &GlobalArgs, _target_manifest_purls: &HashSet) {} - // ── local-go redirect helpers ──────────────────────────────────────────────── // The Go analog of the cargo helpers above: in local mode a `pkg:golang/…` PURL // redirects to a project-local patched copy under `.socket/go-patches/` wired via @@ -361,11 +196,11 @@ async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet #[cfg(not(feature = "golang"))] async fn reconcile_local_go(_common: &GlobalArgs, _target_manifest_purls: &HashSet) {} -/// Read-only verification of committed local redirects (cargo + go) for CI / -/// GitHub-App auditing and the build-time guard probe. Lock-free, crawl-free, -/// offline-safe. Exits 0 when in sync, 1 on drift. Verifies every redirect -/// ecosystem that is both compiled in and in `--ecosystems` scope. -#[cfg(any(feature = "cargo", feature = "golang"))] +/// Read-only verification of the committed Go `replace`-redirects for CI / +/// GitHub-App auditing. Lock-free, crawl-free, offline-safe. Exits 0 when in +/// sync, 1 on drift. Cargo patches in place (no redirect to audit), so `--check` +/// covers Go only. +#[cfg(feature = "golang")] async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { let manifest = match read_manifest(manifest_path).await { Ok(Some(m)) => m, @@ -385,39 +220,10 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { } }; - // (purl_or_name, reason_code, detail) for each drift across ecosystems. + // (purl_or_name, reason_code, detail) for each drift. let mut drifts: Vec<(String, &'static str, String)> = Vec::new(); let mut checked: usize = 0; - #[cfg(feature = "cargo")] - { - use socket_patch_core::patch::cargo_redirect::Drift as CargoDrift; - if cargo_in_local_scope(&args.common) { - let desired: HashSet = manifest - .patches - .keys() - .filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Cargo)) - .cloned() - .collect(); - checked += desired.len(); - if let Err(ds) = verify_cargo_redirect_state(&args.common.cwd, &manifest, &desired).await - { - for d in &ds { - let id = match d { - CargoDrift::MissingCopy { purl } - | CargoDrift::StaleCopy { purl, .. } - | CargoDrift::MissingEntry { purl } - | CargoDrift::WrongEntryPath { purl, .. } - | CargoDrift::ResolvedVersionMismatch { purl, .. } => purl.clone(), - CargoDrift::OrphanEntry { name } => name.clone(), - }; - drifts.push((id, "cargo_redirect_drift", d.to_string())); - } - } - } - } - - #[cfg(feature = "golang")] { use socket_patch_core::patch::go_redirect::Drift as GoDrift; if go_in_local_scope(&args.common) { @@ -473,20 +279,16 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { } } -#[cfg(not(any(feature = "cargo", feature = "golang")))] +#[cfg(not(feature = "golang"))] async fn run_check(args: &ApplyArgs, _manifest_path: &Path) -> i32 { - // Fail-closed: `--check` is the redirect audit. A socket-patch built WITHOUT - // any redirect ecosystem (cargo/golang) cannot verify those redirects, so it - // must NOT report "in sync" (exit 0). The build-time guard probes whatever - // `socket-patch` is on the build machine's PATH; if a feature-off binary - // answered 0 here, the guard would silently proceed against possibly - // stale/unpatched copies — defeating its whole purpose. Exit non-zero with a - // clear reason so the guard fails the build instead. + // Fail-closed: `--check` is the Go `replace`-redirect audit. A socket-patch + // built WITHOUT the `golang` feature cannot verify those redirects, so it + // must NOT report "in sync" (exit 0). Exit non-zero with a clear reason. if !args.common.silent && !args.common.json { eprintln!( - "socket-patch: this build has no cargo/golang support, so it cannot verify \ - patch redirects (`--check`). Install a socket-patch built with the `cargo` \ - and/or `golang` feature, or point SOCKET_PATCH_BIN at one." + "socket-patch: this build has no golang support, so it cannot verify \ + Go patch redirects (`--check`). Install a socket-patch built with the \ + `golang` feature, or point SOCKET_PATCH_BIN at one." ); } 2 @@ -629,10 +431,10 @@ pub async fn run(args: ApplyArgs) -> i32 { return 0; } - // Read-only cargo-redirect verification for CI / GitHub-App auditing. + // Read-only Go `replace`-redirect verification for CI / GitHub-App auditing. // Branches BEFORE the lock (so concurrent builds don't contend) and // before any crawl/fetch; it reads only the manifest + committed copies + - // `.cargo/config.toml`, so it is always offline-safe. + // `go.mod`, so it is always offline-safe. if args.check { return run_check(&args, &manifest_path).await; } @@ -1088,12 +890,11 @@ async fn apply_patches_inner( .flat_map(|purls| purls.iter().cloned()) .collect(); - // Local cargo: prune redirects whose patches were dropped from the + // Local go: prune `replace`-redirects whose patches were dropped from the // manifest (orphans). Done here — before the crawl + the "no packages // found" early returns — so orphans are reconciled even when the manifest - // now lists zero in-scope cargo patches (the all-removed case). No-op - // unless local cargo is in scope. - reconcile_local_cargo(&args.common, &target_manifest_purls).await; + // now lists zero in-scope go patches (the all-removed case). No-op unless + // local go is in scope. reconcile_local_go(&args.common, &target_manifest_purls).await; let crawler_options = CrawlerOptions { @@ -1286,12 +1087,13 @@ async fn apply_patches_inner( packages_path: Some(&packages_path), diffs_path: Some(&diffs_path), }; - // Local cargo/go redirect to a project-local patched copy - // (`apply_cargo_redirect` / `apply_go_redirect`); everything else — - // npm/pypi, and cargo/go under --global/--global-prefix — patches in - // place via `apply_package_patch`. Without the respective feature the - // `try_local_*_apply` helpers are inert `None`s. - let result = match try_local_cargo_apply( + // Local go redirects to a project-local patched copy under + // `.socket/go-patches/` wired via a `go.mod` `replace` (the module + // cache is `go.sum`-verified, so in-place patching can't build). + // Everything else — npm/pypi/gem and cargo (vendored or registry + // cache) — patches in place via `apply_package_patch`. Without the + // `golang` feature `try_local_go_apply` is an inert `None`. + let result = match try_local_go_apply( purl, pkg_path, patch, @@ -1302,30 +1104,18 @@ async fn apply_patches_inner( .await { Some(r) => r, - None => match try_local_go_apply( - purl, - pkg_path, - patch, - &sources, - &args.common, - args.force, - ) - .await - { - Some(r) => r, - None => { - apply_package_patch( - purl, - pkg_path, - &patch.files, - &sources, - Some(&patch.uuid), - args.common.dry_run, - args.force, - ) - .await - } - }, + None => { + apply_package_patch( + purl, + pkg_path, + &patch.files, + &sources, + Some(&patch.uuid), + args.common.dry_run, + args.force, + ) + .await + } }; if !result.success { diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 862ffc3..3684834 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -37,20 +37,11 @@ struct PatchToRollback { patch: PatchRecord, } -// ── local-redirect rollback helpers (cargo + go) ───────────────────────────── -// Local cargo/go roll back by dropping the project-local redirect (cargo's -// `[patch]` entry / go's `replace` directive) + the patched copy — no in-place -// restore, no before-blob. Each helper is an inert stub in a build without its -// respective feature. - -/// True for a cargo PURL in local mode (no `--global` / `--global-prefix`). -#[cfg(feature = "cargo")] -fn is_local_cargo(purl: &str, common: &GlobalArgs) -> bool { - use socket_patch_core::crawlers::Ecosystem; - !common.global - && common.global_prefix.is_none() - && Ecosystem::from_purl(purl) == Some(Ecosystem::Cargo) -} +// ── local-redirect rollback helpers (go only) ──────────────────────────────── +// Local go rolls back by dropping the project-local redirect (go's `replace` +// directive) + the patched copy — no in-place restore, no before-blob. Cargo +// patches in place (vendored or registry cache), so it rolls back in place from +// before-blobs like npm/pypi. The helper is an inert stub without `golang`. /// True for a golang PURL in local mode (no `--global` / `--global-prefix`). #[cfg(feature = "golang")] @@ -61,16 +52,11 @@ fn is_local_go(purl: &str, common: &GlobalArgs) -> bool { && Ecosystem::from_purl(purl) == Some(Ecosystem::Golang) } -/// True when `purl` rolls back by dropping a project-local redirect (cargo or -/// go in local mode) rather than restoring bytes from a before-blob. The -/// before-blob gate uses this to skip those PURLs — they read no blobs, so a -/// missing before-blob must not block (or trigger a needless download for) an -/// offline redirect rollback. +/// True when `purl` rolls back by dropping a project-local redirect (local-mode +/// go) rather than restoring bytes from a before-blob. The before-blob gate uses +/// this to skip those PURLs — they read no blobs, so a missing before-blob must +/// not block (or trigger a needless download for) an offline redirect rollback. fn is_local_redirect(purl: &str, common: &GlobalArgs) -> bool { - #[cfg(feature = "cargo")] - if is_local_cargo(purl, common) { - return true; - } #[cfg(feature = "golang")] if is_local_go(purl, common) { return true; @@ -79,8 +65,8 @@ fn is_local_redirect(purl: &str, common: &GlobalArgs) -> bool { false } -/// Copy of `manifest` with local-redirect PURLs (cargo + go) removed — used for -/// the before-blob gate, which those PURLs never need. Avoids blocking an +/// Copy of `manifest` with local-redirect PURLs (local-mode go) removed — used +/// for the before-blob gate, which those PURLs never need. Avoids blocking an /// offline redirect rollback on absent blobs. fn exclude_local_redirects(manifest: &PatchManifest, common: &GlobalArgs) -> PatchManifest { PatchManifest { @@ -94,60 +80,12 @@ fn exclude_local_redirects(manifest: &PatchManifest, common: &GlobalArgs) -> Pat } } -/// Roll back a local-cargo redirect (drop the `[patch]` entry + copy), or -/// `None` if `purl` isn't a local-cargo target (caller falls back to the next -/// backend, ultimately in-place rollback). -#[cfg(feature = "cargo")] -async fn try_rollback_local_cargo( - purl: &str, - pkg_path: &Path, - patch: &PatchRecord, - common: &GlobalArgs, -) -> Option { - use socket_patch_core::patch::cargo_redirect::remove_cargo_redirect; - if !is_local_cargo(purl, common) { - return None; - } - // Only registry-cache crates use the redirect model; a vendored crate - // (under `/vendor/`) was patched in place and rolls back in place. - // The crawler searches `vendor/` exclusively when it exists, so a path - // under it is the reliable "vendored" discriminator (mirrors apply). - if pkg_path.starts_with(common.cwd.join("vendor")) { - return None; - } - let mut result = RollbackResult { - package_key: purl.to_string(), - package_path: pkg_path.display().to_string(), - success: true, - files_verified: Vec::new(), - files_rolled_back: patch.files.keys().cloned().collect(), - error: None, - }; - if let Err(e) = remove_cargo_redirect(purl, &common.cwd, common.dry_run).await { - result.success = false; - result.files_rolled_back.clear(); - result.error = Some(e.to_string()); - } - Some(result) -} - -#[cfg(not(feature = "cargo"))] -async fn try_rollback_local_cargo( - _purl: &str, - _pkg_path: &Path, - _patch: &PatchRecord, - _common: &GlobalArgs, -) -> Option { - None -} - /// Roll back a local-go redirect (drop the `go.mod` `replace` directive + the /// patched copy under `.socket/go-patches/`), or `None` if `purl` isn't a /// local-go target (caller falls back to in-place rollback). The module cache -/// is left pristine by the redirect, so — exactly like cargo — there is no -/// before-blob to restore; mirrors apply's `try_local_go_apply`. Go has no -/// `vendor/` fallthrough (apply always redirects local go), so there is no -/// vendored discriminator here. +/// is left pristine by the redirect, so there is no before-blob to restore; +/// mirrors apply's `try_local_go_apply`. Go has no `vendor/` fallthrough (apply +/// always redirects local go), so there is no vendored discriminator here. #[cfg(feature = "golang")] async fn try_rollback_local_go( purl: &str, @@ -566,7 +504,7 @@ async fn rollback_patches_inner( setup: None, }; - // Check for missing beforeHash blobs. Local-redirect PURLs (cargo + go) + // Check for missing beforeHash blobs. Local-redirect PURLs (local-mode go) // are excluded: their rollback just drops the project-local redirect + copy // and reads no blobs, so a missing before-blob must not block an offline // redirect rollback. @@ -597,10 +535,10 @@ async fn rollback_patches_inner( } // Re-check against `gate_manifest` (NOT `filtered_manifest`): the - // download only targeted blobs from the local-cargo-excluded gate, so - // local-cargo before-hashes must stay excluded here too. Re-checking + // download only targeted blobs from the local-go-excluded gate, so + // local-go before-hashes must stay excluded here too. Re-checking // the full filtered manifest would re-introduce those never-needed - // blobs and spuriously abort a mixed local-cargo rollback. + // blobs and spuriously abort a mixed local-go rollback. let still_missing = get_missing_before_blobs(&gate_manifest, &blobs_path).await; if !still_missing.is_empty() { if !args.common.silent && !args.common.json { @@ -694,25 +632,22 @@ async fn rollback_patches_inner( None => continue, }; - // Local cargo/go drop the project-local redirect; everything else — - // npm/pypi, and cargo/go under --global/--global-prefix — restores - // in place. Without the respective feature the `try_rollback_local_*` - // helpers are inert `None`s. - let result = match try_rollback_local_cargo(purl, pkg_path, patch, &args.common).await { + // Local go drops the project-local `replace`-redirect; everything + // else — npm/pypi/gem and cargo (vendored or registry cache) — + // restores in place from before-blobs. Without the `golang` feature + // `try_rollback_local_go` is an inert `None`. + let result = match try_rollback_local_go(purl, pkg_path, patch, &args.common).await { Some(r) => r, - None => match try_rollback_local_go(purl, pkg_path, patch, &args.common).await { - Some(r) => r, - None => { - rollback_package_patch( - purl, - pkg_path, - &patch.files, - &blobs_path, - args.common.dry_run, - ) - .await - } - }, + None => { + rollback_package_patch( + purl, + pkg_path, + &patch.files, + &blobs_path, + args.common.dry_run, + ) + .await + } }; if !result.success { @@ -1001,15 +936,18 @@ mod tests { // --- Missing-blob gate consistency ---------------------------------- // - // The before-blob gate excludes local-cargo PURLs (redirect rollback + // The before-blob gate excludes local-go PURLs (redirect rollback // reads no blobs). Both the initial missing-blob check AND the // post-download re-check (`still_missing`) must run against the SAME - // local-cargo-excluded gate manifest. Re-checking the full filtered - // manifest re-introduces local-cargo before-hashes that were never + // local-go-excluded gate manifest. Re-checking the full filtered + // manifest re-introduces local-go before-hashes that were never // downloaded, spuriously aborting a mixed rollback. + #[cfg(any(feature = "cargo", feature = "golang"))] use socket_patch_core::manifest::schema::PatchFileInfo; + // Only the cargo/golang-gated before-blob gate tests use this helper. + #[cfg(any(feature = "cargo", feature = "golang"))] fn record_with_file(uuid: &str, path: &str, before_hash: &str) -> PatchRecord { let mut rec = make_record(uuid); let mut files = HashMap::new(); @@ -1024,15 +962,14 @@ mod tests { rec } - /// Regression: a local-cargo before-hash that is absent on disk must NOT - /// count as missing once the manifest is run through `exclude_local_redirects` - /// — for the initial gate or the post-download re-check. Before the fix - /// the re-check used the full filtered manifest, so a present-npm + - /// missing-cargo manifest still reported the cargo blob missing and - /// aborted the rollback. + /// Cargo now patches in place (vendored or registry cache) and rolls back + /// by restoring from before-blobs — exactly like npm/pypi. So a cargo PURL + /// must NOT be excluded by the before-blob gate: a missing cargo before-blob + /// IS a real problem the gate should surface. This guards against cargo + /// being mistakenly reclassified as a redirect again. #[cfg(feature = "cargo")] #[tokio::test] - async fn gate_manifest_excludes_local_cargo_before_blobs_from_missing_check() { + async fn gate_manifest_keeps_cargo_before_blobs_in_missing_check() { let mut patches = HashMap::new(); patches.insert( "pkg:cargo/serde@1.0.0".to_string(), @@ -1053,20 +990,16 @@ mod tests { let blobs = tmp.path(); tokio::fs::write(blobs.join("npm_before"), b"x").await.unwrap(); - // Full manifest: the cargo before-blob shows up as missing. This is - // exactly what the buggy re-check used, spuriously aborting rollback. - let full_missing = get_missing_before_blobs(&manifest, blobs).await; - assert!(full_missing.contains("cargo_before")); - - // Gate manifest: the local-cargo PURL is excluded, so its before-blob - // is not counted as missing. With the npm blob present, the gate (and - // the re-check that now reuses it) reports nothing missing. + // The gate must STILL report the cargo before-blob as missing — cargo + // is an in-place rollback that genuinely needs it. let gate = exclude_local_redirects(&manifest, &common); let gate_missing = get_missing_before_blobs(&gate, blobs).await; assert!( - gate_missing.is_empty(), - "gate must exclude local-cargo before-blobs, got {gate_missing:?}" + gate_missing.contains("cargo_before"), + "gate must keep cargo before-blobs (in-place rollback), got {gate_missing:?}" ); + // And the cargo PURL must not be classified as a redirect. + assert!(!is_local_redirect("pkg:cargo/serde@1.0.0", &common)); } /// Regression: local-GO redirects must be excluded from the before-blob diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 2170767..af0b59c 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -1,11 +1,4 @@ use clap::Args; -#[cfg(feature = "cargo")] -use socket_patch_core::cargo_setup::{ - add_guard_dep, discover_cargo_project, is_guard_dep_present, remove_guard_dep, CargoEditResult, - CargoSetupStatus, -}; -#[cfg(feature = "golang")] -use socket_patch_core::go_setup::{self, GoSetupStatus}; use socket_patch_core::gem_setup::{self, GemSetupStatus}; #[cfg(feature = "composer")] use socket_patch_core::composer_setup::{self, ComposerSetupStatus}; @@ -43,13 +36,10 @@ fn manager_name(pm: PackageManager) -> &'static str { } /// Compose the `+`-joined telemetry manager tag across the ecosystems in scope -/// (e.g. `npm+pypi+cargo`), or `none`. -#[allow(clippy::too_many_arguments)] +/// (e.g. `npm+pypi+gem`), or `none`. fn telemetry_manager_str( npm: bool, py: bool, - cargo: bool, - go: bool, gem: bool, composer: bool, npm_pm: PackageManager, @@ -61,12 +51,6 @@ fn telemetry_manager_str( if py { parts.push("pypi"); } - if cargo { - parts.push("cargo"); - } - if go { - parts.push("golang"); - } if gem { parts.push("gem"); } @@ -93,9 +77,8 @@ pub struct SetupArgs { pub check: bool, /// Revert the install hooks that `setup` added: npm `package.json` scripts, - /// the Python `socket-patch[hook]` dependency, the cargo `socket-patch-guard` - /// dependency + `[env]`, the Go guard package + blank imports, and the gem - /// Bundler plugin wiring. + /// the Python `socket-patch[hook]` dependency, and the gem Bundler plugin + /// wiring. #[arg( long = "remove", default_value_t = false, @@ -176,7 +159,7 @@ fn report_no_files(args: &SetupArgs, status: &str, counts: &[(&str, i64)]) -> i3 serde_json::to_string_pretty(&serde_json::Value::Object(map)).unwrap() ); } else { - println!("No package.json, Python, Cargo, Go, Bundler, or Composer project found"); + println!("No package.json, Python, Bundler, or Composer project found"); } 0 } @@ -192,9 +175,9 @@ fn pathdiff(path: &str, base: &Path) -> String { /// `--ecosystems` filter (`CLI_CONTRACT.md` → "Setup command contract", /// property 2). With no filter (or an empty one) every ecosystem is in scope. /// `names` lists the accepted tokens for the ecosystem — its canonical -/// `Ecosystem::cli_name()` plus any friendly alias (e.g. `golang`/`go`, -/// `pypi`/`python`, `gem`/`ruby`) — matched case-insensitively, mirroring the -/// semantics `apply` already uses (`cargo_in_local_scope`/`go_in_local_scope`). +/// `Ecosystem::cli_name()` plus any friendly alias (e.g. `pypi`/`python`, +/// `gem`/`ruby`) — matched case-insensitively, mirroring the scoping semantics +/// `apply` uses for the in-place ecosystems. fn eco_in_scope(common: &GlobalArgs, names: &[&str]) -> bool { match &common.ecosystems { None => true, @@ -350,25 +333,6 @@ pub(crate) async fn configured_ecosystems( } } - #[cfg(feature = "cargo")] - if let Some(project) = discover_cargo_project(&common.cwd).await { - for member in &project.members { - if let Ok(content) = tokio::fs::read_to_string(member).await { - if is_guard_dep_present(&content) { - set.insert(Ecosystem::Cargo); - break; - } - } - } - } - - #[cfg(feature = "golang")] - if let Some(module) = go_setup::discover_go_module(&common.cwd).await { - if go_setup::guard_files_present(&module.root).await { - set.insert(Ecosystem::Golang); - } - } - #[cfg(feature = "composer")] if let Some(project) = composer_setup::discover_composer_project(&common.cwd).await { if let Ok(content) = tokio::fs::read_to_string(&project.composer_json).await { @@ -384,8 +348,6 @@ pub(crate) async fn configured_ecosystems( // Canonical `--ecosystems` token sets per setup branch (see `eco_in_scope`). const ECO_NPM: &[&str] = &["npm"]; const ECO_PYPI: &[&str] = &["pypi", "python"]; -const ECO_CARGO: &[&str] = &["cargo"]; -const ECO_GOLANG: &[&str] = &["golang", "go"]; const ECO_GEM: &[&str] = &["gem", "ruby"]; #[cfg(feature = "composer")] const ECO_COMPOSER: &[&str] = &["composer", "php"]; @@ -548,244 +510,26 @@ fn update_status_str(s: &UpdateStatus) -> &'static str { } // ───────────────────────────────────────────────────────────────────────── -// Cargo (project-local [patch]-redirect guard) helpers +// Shared per-ecosystem setup outcome // ───────────────────────────────────────────────────────────────────────── -/// Feature-agnostic summary of the cargo branch's contribution to a -/// setup/remove run. Built by [`build_cargo_outcome`] (a no-op `Default` when -/// the `cargo` feature is off), so the shared reporting code never has to name -/// the cargo-only types. +/// Feature-agnostic summary of one ecosystem branch's contribution to a +/// setup/remove run. Each `build_*_outcome` returns one of these and the shared +/// reporting code merges + renders them without naming ecosystem-specific types. #[derive(Default)] struct SetupOutcome { - /// A cargo project was discovered (gates the `no_files` decision). + /// A project for this ecosystem was discovered (gates the `no_files` decision). present: bool, - /// Items changed (guard dep added/removed + `[env]` written/removed). + /// Items changed (hook added/removed). changed: usize, already: usize, errors: usize, - /// Envelope `files[]` entries (kind = `cargo` / `cargo_env`). + /// Envelope `files[]` entries (kind = `package_json` / `pth` / `gemfile` / …). json_files: Vec, /// Human-readable preview lines (already formatted). preview: Vec, } -/// Build the cargo outcome for a setup (`remove=false`) or remove -/// (`remove=true`) run at the given `dry_run` setting. -#[cfg(feature = "cargo")] -async fn build_cargo_outcome( - common: &GlobalArgs, - remove: bool, - dry_run: bool, - excludes: &[String], -) -> SetupOutcome { - use socket_patch_core::patch::cargo_config; - - if !eco_in_scope(common, ECO_CARGO) { - return SetupOutcome::default(); - } - let project = match discover_cargo_project(&common.cwd).await { - Some(p) => p, - None => return SetupOutcome::default(), - }; - - let mut out = SetupOutcome { - present: true, - ..Default::default() - }; - - // Per-member guard dependency edits, skipping excluded members (property 9). - let version = guard_version(); - let mut results: Vec<(String, CargoEditResult)> = Vec::new(); - for member in project - .members - .iter() - .filter(|m| !is_member_excluded(m, &common.cwd, excludes)) - { - let res = if remove { - remove_guard_dep(member, dry_run).await - } else { - add_guard_dep(member, &version, dry_run).await - }; - results.push(("cargo".to_string(), res)); - } - - // The shared `[env] SOCKET_PATCH_ROOT` at the workspace root. - let config_path = project.root.join(".cargo/config.toml"); - let env_change = if remove { - cargo_config::drop_env_root(&project.root, dry_run).await - } else { - cargo_config::ensure_env_root(&project.root, dry_run).await - }; - results.push(("cargo_env".to_string(), env_result(&config_path, env_change))); - - // Aggregate counts + render envelope entries / preview lines. - let mut added_paths: Vec = Vec::new(); - for (kind, r) in &results { - match r.status { - CargoSetupStatus::Updated => { - out.changed += 1; - added_paths.push(r.path.clone()); - } - CargoSetupStatus::AlreadyConfigured => out.already += 1, - CargoSetupStatus::Error => out.errors += 1, - } - out.json_files.push(serde_json::json!({ - "kind": kind, - "path": r.path, - "status": cargo_status_str(&r.status, remove), - "error": r.error, - })); - } - - if !added_paths.is_empty() { - let header = if remove { - "Cargo: remove socket-patch-guard + [env] SOCKET_PATCH_ROOT from:" - } else { - "Cargo: add socket-patch-guard + [env] SOCKET_PATCH_ROOT to:" - }; - out.preview.push(header.to_string()); - for p in &added_paths { - out.preview.push(format!(" + {}", pathdiff(p, &common.cwd))); - } - } - - out -} - -#[cfg(not(feature = "cargo"))] -async fn build_cargo_outcome( - _common: &GlobalArgs, - _remove: bool, - _dry_run: bool, - _excludes: &[String], -) -> SetupOutcome { - SetupOutcome::default() -} - -// ───────────────────────────────────────────────────────────────────────── -// Go (project-local go.mod `replace`-redirect guard) helpers -// ───────────────────────────────────────────────────────────────────────── - -/// Build the Go branch's contribution to a setup/remove run: write (or remove) -/// the `internal/socketpatchguard` package + the per-`main` blank-import files. -/// A no-op `Default` when the `golang` feature is off. -#[cfg(feature = "golang")] -async fn build_go_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> SetupOutcome { - if !eco_in_scope(common, ECO_GOLANG) { - return SetupOutcome::default(); - } - let module = match go_setup::discover_go_module(&common.cwd).await { - Some(m) => m, - None => return SetupOutcome::default(), - }; - - let mut out = SetupOutcome { - present: true, - ..Default::default() - }; - - let mut results: Vec = Vec::new(); - if remove { - results.push(go_setup::remove_guard(&module.root, dry_run).await); - results.extend(go_setup::remove_main_imports(&module.root, dry_run).await); - } else { - results.push(go_setup::add_guard(&module.root, dry_run).await); - results.extend( - go_setup::add_main_imports(&module.root, &module.module_path, dry_run).await, - ); - } - - let mut added_paths: Vec = Vec::new(); - for r in &results { - match r.status { - GoSetupStatus::Updated => { - out.changed += 1; - added_paths.push(r.path.clone()); - } - GoSetupStatus::AlreadyConfigured => out.already += 1, - GoSetupStatus::Error => out.errors += 1, - } - out.json_files.push(serde_json::json!({ - "kind": r.kind, - "path": r.path, - "status": go_status_str(&r.status, remove), - "error": r.error, - })); - } - - if !added_paths.is_empty() { - let header = if remove { - "Go: remove socket-patch guard wiring from:" - } else { - "Go: add socket-patch guard wiring to:" - }; - out.preview.push(header.to_string()); - for p in &added_paths { - out.preview.push(format!(" + {}", pathdiff(p, &common.cwd))); - } - } - - out -} - -#[cfg(not(feature = "golang"))] -async fn build_go_outcome(_common: &GlobalArgs, _remove: bool, _dry_run: bool) -> SetupOutcome { - SetupOutcome::default() -} - -#[cfg(feature = "golang")] -fn go_status_str(s: &GoSetupStatus, for_remove: bool) -> &'static str { - match (s, for_remove) { - (GoSetupStatus::Updated, false) => "updated", - (GoSetupStatus::Updated, true) => "removed", - (GoSetupStatus::AlreadyConfigured, false) => "already_configured", - (GoSetupStatus::AlreadyConfigured, true) => "not_configured", - (GoSetupStatus::Error, _) => "error", - } -} - -/// Materialise the Go `replace` redirects right after wiring the guard (the -/// "automatic" step) so the first `go test`/`go run` finds patches already in -/// sync instead of self-healing on first run. Best-effort and offline: runs the -/// same `apply` the guard would, capturing output so it never corrupts setup's -/// (possibly JSON) stdout. A non-zero exit becomes a warning — the guard heals -/// it on first run. No-op without the `golang` feature. -#[cfg(feature = "golang")] -async fn finalize_go(common: &GlobalArgs) -> Vec { - let exe = match std::env::current_exe() { - Ok(e) => e, - Err(e) => { - return vec![format!( - "could not locate socket-patch to materialize go patches ({e}); \ - run `socket-patch apply --ecosystems golang`" - )] - } - }; - let root = common.cwd.display().to_string(); - match tokio::process::Command::new(&exe) - .args(["apply", "--offline", "--ecosystems", "golang", "--cwd", &root, "--silent"]) - .output() - .await - { - Ok(o) if o.status.success() => Vec::new(), - Ok(o) => vec![format!( - "materializing go patches exited with {}; the guard will heal on first `go test`/run", - o.status - .code() - .map(|c| c.to_string()) - .unwrap_or_else(|| "signal".into()) - )], - Err(e) => vec![format!( - "could not run apply to materialize go patches ({e}); the guard will heal on first run" - )], - } -} - -#[cfg(not(feature = "golang"))] -async fn finalize_go(_common: &GlobalArgs) -> Vec { - Vec::new() -} - // ───────────────────────────────────────────────────────────────────────── // Gem (Bundler plugin) helpers // ───────────────────────────────────────────────────────────────────────── @@ -793,7 +537,7 @@ async fn finalize_go(_common: &GlobalArgs) -> Vec { /// Build the gem branch's contribution to a setup/remove run: add (or remove) /// the managed `plugin "socket-patch"` block in the Gemfile + the generated /// `.socket/bundler-plugin/` plugin files. Gem is an unconditional ecosystem, -/// so (unlike cargo/go) this is never feature-gated. +/// so this is never feature-gated. async fn build_gem_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> SetupOutcome { if !eco_in_scope(common, ECO_GEM) { return SetupOutcome::default(); @@ -864,9 +608,8 @@ fn gem_status_str(s: &GemSetupStatus, for_remove: bool) -> &'static str { /// Build the composer branch's contribution to a setup/remove run: add (or /// remove) the `socket-patch apply` command in `composer.json`'s /// `post-install-cmd` / `post-update-cmd` script events. Feature-gated behind -/// `composer` (a no-op `Default` when off), exactly like the cargo/go branches — -/// composer apply itself only exists with the feature, so wiring a hook without -/// it would be incoherent. +/// `composer` (a no-op `Default` when off) — composer apply itself only exists +/// with the feature, so wiring a hook without it would be incoherent. #[cfg(feature = "composer")] async fn build_composer_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> SetupOutcome { if !eco_in_scope(common, ECO_COMPOSER) { @@ -981,7 +724,7 @@ async fn append_composer_check_entries( /// Materialise gem patches right after wiring the plugin (the "automatic" step) /// so the first `bundle install` finds them already applied. Best-effort and /// offline; a non-zero exit becomes a warning — the plugin heals on the next -/// `bundle install`. Mirrors [`finalize_go`]. +/// `bundle install`. async fn finalize_gem(common: &GlobalArgs) -> Vec { let exe = match std::env::current_exe() { Ok(e) => e, @@ -1114,153 +857,6 @@ fn merge_outcomes(mut a: SetupOutcome, b: SetupOutcome) -> SetupOutcome { a } -/// The guard version string `setup` writes — major.minor of this CLI, so the -/// committed dep tracks the installed `socket-patch`. -#[cfg(feature = "cargo")] -fn guard_version() -> String { - let v = env!("CARGO_PKG_VERSION"); - let mut parts = v.split('.'); - match (parts.next(), parts.next()) { - (Some(major), Some(minor)) => format!("{major}.{minor}"), - _ => v.to_string(), - } -} - -#[cfg(feature = "cargo")] -fn cargo_status_str(s: &CargoSetupStatus, for_remove: bool) -> &'static str { - match (s, for_remove) { - (CargoSetupStatus::Updated, false) => "updated", - (CargoSetupStatus::Updated, true) => "removed", - (CargoSetupStatus::AlreadyConfigured, false) => "already_configured", - (CargoSetupStatus::AlreadyConfigured, true) => "not_configured", - (CargoSetupStatus::Error, _) => "error", - } -} - -#[cfg(feature = "cargo")] -fn env_result(config_path: &Path, change: Result) -> CargoEditResult { - match change { - Ok(true) => CargoEditResult { - path: config_path.display().to_string(), - status: CargoSetupStatus::Updated, - error: None, - }, - Ok(false) => CargoEditResult { - path: config_path.display().to_string(), - status: CargoSetupStatus::AlreadyConfigured, - error: None, - }, - Err(e) => CargoEditResult { - path: config_path.display().to_string(), - status: CargoSetupStatus::Error, - error: Some(e), - }, - } -} - -/// Append cargo check entries (one per member + one for `[env]`) to the shared -/// `run_check` entries list. Returns whether a cargo project was found. -#[cfg(feature = "cargo")] -async fn append_cargo_check_entries( - common: &GlobalArgs, - entries: &mut Vec<(&'static str, String, CheckState, Option)>, - excludes: &[String], -) -> bool { - use socket_patch_core::patch::cargo_config; - - if !eco_in_scope(common, ECO_CARGO) { - return false; - } - let project = match discover_cargo_project(&common.cwd).await { - Some(p) => p, - None => return false, - }; - for member in project - .members - .iter() - .filter(|m| !is_member_excluded(m, &common.cwd, excludes)) - { - let (state, err) = match tokio::fs::read_to_string(member).await { - Ok(content) => { - if is_guard_dep_present(&content) { - (CheckState::Configured, None) - } else { - (CheckState::NeedsConfiguration, None) - } - } - Err(e) => (CheckState::Error, Some(e.to_string())), - }; - entries.push(("cargo", member.display().to_string(), state, err)); - } - let env_ok = cargo_config::env_root_present(&project.root).await; - entries.push(( - "cargo_env", - project.root.join(".cargo/config.toml").display().to_string(), - if env_ok { - CheckState::Configured - } else { - CheckState::NeedsConfiguration - }, - None, - )); - true -} - -#[cfg(not(feature = "cargo"))] -async fn append_cargo_check_entries( - _common: &GlobalArgs, - _entries: &mut Vec<(&'static str, String, CheckState, Option)>, - _excludes: &[String], -) -> bool { - false -} - -/// Append Go check entries (the guard package + one per `package main` blank -/// import) to the shared `run_check` entries list. Returns whether a Go module -/// was found. Checks the SETUP wiring only — redirect sync is `apply --check`. -#[cfg(feature = "golang")] -async fn append_go_check_entries( - common: &GlobalArgs, - entries: &mut Vec<(&'static str, String, CheckState, Option)>, -) -> bool { - if !eco_in_scope(common, ECO_GOLANG) { - return false; - } - let module = match go_setup::discover_go_module(&common.cwd).await { - Some(m) => m, - None => return false, - }; - let guard_state = if go_setup::guard_files_present(&module.root).await { - CheckState::Configured - } else { - CheckState::NeedsConfiguration - }; - entries.push(( - "go_guard", - module.root.join(go_setup::GUARD_DIR).display().to_string(), - guard_state, - None, - )); - for dir in go_setup::find_main_package_dirs(&module.root).await { - let path = go_setup::import_file_path(&dir); - let state = if tokio::fs::metadata(&path).await.is_ok() { - CheckState::Configured - } else { - CheckState::NeedsConfiguration - }; - entries.push(("go_import", path.display().to_string(), state, None)); - } - true -} - -#[cfg(not(feature = "golang"))] -async fn append_go_check_entries( - _common: &GlobalArgs, - _entries: &mut Vec<(&'static str, String, CheckState, Option)>, -) -> bool { - false -} - // ───────────────────────────────────────────────────────────────────────── // check // ───────────────────────────────────────────────────────────────────────── @@ -1278,12 +874,11 @@ enum CheckState { /// configured and none failed to parse. async fn run_check(args: &SetupArgs) -> i32 { if !args.common.json { - println!("Searching for package.json / Python / Cargo / Go / Bundler / Composer manifests..."); + println!("Searching for package.json / Python / Bundler / Composer manifests..."); } // Excluded members (persisted in the manifest + any passed via `--exclude`) - // are skipped by both discovery and the cargo check. Read-only: `--check` - // never persists. + // are skipped by discovery. Read-only: `--check` never persists. let excludes = effective_excludes(&args.common, &args.exclude).await; let npm_files = discover(args, &excludes).await; let py_plan = plan_python(&args.common).await; @@ -1329,8 +924,6 @@ async fn run_check(args: &SetupArgs) -> i32 { } } - append_cargo_check_entries(&args.common, &mut entries, &excludes).await; - append_go_check_entries(&args.common, &mut entries).await; append_gem_check_entries(&args.common, &mut entries).await; append_composer_check_entries(&args.common, &mut entries).await; @@ -1429,7 +1022,7 @@ fn render_removed(new: &Option) -> String { async fn run_remove(args: &SetupArgs) -> i32 { let common = &args.common; if !common.json { - println!("Searching for package.json / Python / Cargo / Go / Bundler / Composer manifests..."); + println!("Searching for package.json / Python / Bundler / Composer manifests..."); } // Honor the persisted/`--exclude` member set so we never touch a member that @@ -1437,14 +1030,10 @@ async fn run_remove(args: &SetupArgs) -> i32 { let excludes = effective_excludes(common, &args.exclude).await; let npm_files = discover(args, &excludes).await; let py_plan = plan_python(common).await; - let cargo_preview = build_cargo_outcome(common, true, true, &excludes).await; - let go_preview = build_go_outcome(common, true, true).await; let gem_preview = build_gem_outcome(common, true, true).await; let composer_preview = build_composer_outcome(common, true, true).await; if npm_files.is_empty() && py_plan.is_none() - && !cargo_preview.present - && !go_preview.present && !gem_preview.present && !composer_preview.present { @@ -1454,13 +1043,8 @@ async fn run_remove(args: &SetupArgs) -> i32 { &[("removed", 0), ("notConfigured", 0), ("errors", 0)], ); } - let cargo_present = cargo_preview.present; - let go_present = go_preview.present; let gem_present = gem_preview.present; - let cargo_preview = merge_outcomes( - merge_outcomes(merge_outcomes(cargo_preview, go_preview), gem_preview), - composer_preview, - ); + let extra_preview = merge_outcomes(gem_preview, composer_preview); // Preview (dry_run=true never writes). let mut npm_preview = Vec::new(); @@ -1473,15 +1057,15 @@ async fn run_remove(args: &SetupArgs) -> i32 { }; if !common.json { - print_remove_preview(&npm_preview, &py_preview, &cargo_preview, common); + print_remove_preview(&npm_preview, &py_preview, &extra_preview, common); } let n_remove = npm_preview.iter().filter(|r| r.status == RemoveStatus::Removed).count() + py_preview.iter().filter(|r| r.status == PthStatus::Updated).count() - + cargo_preview.changed; + + extra_preview.changed; let preview_errs = npm_preview.iter().filter(|r| r.status == RemoveStatus::Error).count() + py_preview.iter().filter(|r| r.status == PthStatus::Error).count() - + cargo_preview.errors; + + extra_preview.errors; // Nothing to remove: clean (exit 0) or some file errored (exit 1). if n_remove == 0 { @@ -1490,7 +1074,7 @@ async fn run_remove(args: &SetupArgs) -> i32 { if preview_errs > 0 { "error" } else { "not_configured" }, &npm_preview, &py_preview, - &cargo_preview, + &extra_preview, &[], ); } else if preview_errs > 0 { @@ -1504,7 +1088,7 @@ async fn run_remove(args: &SetupArgs) -> i32 { // Dry-run: preview already shown; report and exit without writing. if common.dry_run { if common.json { - print_remove_envelope("dry_run", &npm_preview, &py_preview, &cargo_preview, &[]); + print_remove_envelope("dry_run", &npm_preview, &py_preview, &extra_preview, &[]); } else { println!("\nSummary:"); println!(" {n_remove} item(s) would have socket-patch removed"); @@ -1542,36 +1126,29 @@ async fn run_remove(args: &SetupArgs) -> i32 { py_results = edit_python_manifests(plan, true, false).await; warnings = finalize_python(plan, &py_results, &common.cwd).await; } - // Real cargo + go + gem + composer removal (guard dep/[env] root; go guard - // package + imports; gem Gemfile `plugin` block + generated plugin dir; - // composer.json script-event command). - let cargo_results = merge_outcomes( - merge_outcomes( - merge_outcomes( - build_cargo_outcome(common, true, false, &excludes).await, - build_go_outcome(common, true, false).await, - ), - build_gem_outcome(common, true, false).await, - ), + // Real gem + composer removal (gem Gemfile `plugin` block + generated plugin + // dir; composer.json script-event command). + let extra_results = merge_outcomes( + build_gem_outcome(common, true, false).await, build_composer_outcome(common, true, false).await, ); let errs = npm_results.iter().filter(|r| r.status == RemoveStatus::Error).count() + py_results.iter().filter(|r| r.status == PthStatus::Error).count() - + cargo_results.errors; + + extra_results.errors; if common.json { print_remove_envelope( if errs > 0 { "partial_failure" } else { "success" }, &npm_results, &py_results, - &cargo_results, + &extra_results, &warnings, ); } else { let removed = npm_results.iter().filter(|r| r.status == RemoveStatus::Removed).count() + py_results.iter().filter(|r| r.status == PthStatus::Updated).count() - + cargo_results.changed; + + extra_results.changed; println!("\nSummary:"); println!(" {removed} item(s) had socket-patch removed"); if errs > 0 { @@ -1583,19 +1160,6 @@ async fn run_remove(args: &SetupArgs) -> i32 { if py_plan.is_some() { println!("\nAlso run `pip uninstall socket-patch-hook` to remove the installed .pth."); } - if cargo_present { - println!( - "\nNote: existing patched-crate copies under .socket/cargo-patches/ and any \ - managed [patch.crates-io] entries are removed on `socket-patch rollback`." - ); - } - if go_present { - println!( - "\nNote: the Go guard wiring was removed; existing patched-module copies under \ - .socket/go-patches/ and managed go.mod `replace` directives are removed on \ - `socket-patch rollback`." - ); - } if gem_present { println!( "\nNote: the Bundler plugin wiring was removed; already-patched gems on disk are \ @@ -1611,10 +1175,10 @@ async fn run_remove(args: &SetupArgs) -> i32 { } } -/// Error messages from a cargo/go/gem [`SetupOutcome`]'s rendered `files[]` +/// Error messages from a gem/composer [`SetupOutcome`]'s rendered `files[]` /// entries — the only place per-edit errors for those ecosystems are retained. /// The setup/remove previews use this so their human-mode "Errors:" sections -/// actually list cargo/go/gem failures, honoring the "(see errors above)" line +/// actually list gem/composer failures, honoring the "(see errors above)" line /// both flows print when `preview_errors > 0`. fn outcome_error_messages(o: &SetupOutcome) -> Vec { o.json_files @@ -1627,7 +1191,7 @@ fn outcome_error_messages(o: &SetupOutcome) -> Vec { fn print_remove_preview( npm: &[RemoveResult], py: &[PthEditResult], - cargo: &SetupOutcome, + extra: &SetupOutcome, common: &GlobalArgs, ) { let to_remove: Vec<_> = npm.iter().filter(|r| r.status == RemoveStatus::Removed).collect(); @@ -1652,8 +1216,8 @@ fn print_remove_preview( } println!(); } - if !cargo.preview.is_empty() { - for line in &cargo.preview { + if !extra.preview.is_empty() { + for line in &extra.preview { println!("{line}"); } println!(); @@ -1671,7 +1235,7 @@ fn print_remove_preview( .filter_map(|r| r.error.clone()), ) .collect(); - errs.extend(outcome_error_messages(cargo)); + errs.extend(outcome_error_messages(extra)); if !errs.is_empty() { println!("Errors:"); for e in &errs { @@ -1685,18 +1249,18 @@ fn print_remove_envelope( status: &str, npm: &[RemoveResult], py: &[PthEditResult], - cargo: &SetupOutcome, + extra: &SetupOutcome, warnings: &[String], ) { let removed = npm.iter().filter(|r| r.status == RemoveStatus::Removed).count() + py.iter().filter(|r| r.status == PthStatus::Updated).count() - + cargo.changed; + + extra.changed; let not_cfg = npm.iter().filter(|r| r.status == RemoveStatus::NotConfigured).count() + py.iter().filter(|r| r.status == PthStatus::AlreadyConfigured).count() - + cargo.already; + + extra.already; let errors = npm.iter().filter(|r| r.status == RemoveStatus::Error).count() + py.iter().filter(|r| r.status == PthStatus::Error).count() - + cargo.errors; + + extra.errors; let mut files: Vec = npm .iter() @@ -1725,9 +1289,9 @@ fn print_remove_envelope( "error": r.error, }) })); - // cargo.json_files already use the remove vocabulary - // (removed/not_configured/error), built by `build_cargo_outcome`. - files.extend(cargo.json_files.iter().cloned()); + // extra.json_files already use the remove vocabulary + // (removed/not_configured/error), built by the gem/composer outcomes. + files.extend(extra.json_files.iter().cloned()); let mut obj = serde_json::json!({ "status": status, @@ -1759,23 +1323,19 @@ async fn run_setup(args: &SetupArgs) -> i32 { // Resolve the effective exclude set (persisted + `--exclude`) and, on a real // run, persist it so `--check` and a fresh clone honor it without the flag. // Dry-run never writes the manifest. Excluded members are then skipped by - // discovery and the cargo branch. + // discovery. let excludes = effective_excludes(common, &args.exclude).await; if !common.dry_run { persist_setup_excludes(common, &excludes).await; } let npm_files = discover(args, &excludes).await; let py_plan = plan_python(common).await; - // Cargo + Go + Gem + Composer previews (dry-run); `.present` also tells us each project exists. - let cargo_preview = build_cargo_outcome(common, false, true, &excludes).await; - let go_preview = build_go_outcome(common, false, true).await; + // Gem + Composer previews (dry-run); `.present` also tells us each project exists. let gem_preview = build_gem_outcome(common, false, true).await; let composer_preview = build_composer_outcome(common, false, true).await; if npm_files.is_empty() && py_plan.is_none() - && !cargo_preview.present - && !go_preview.present && !gem_preview.present && !composer_preview.present { @@ -1792,27 +1352,20 @@ async fn run_setup(args: &SetupArgs) -> i32 { .unwrap() ); } else { - println!("No package.json, Python, Cargo, Go, Bundler, or Composer project found"); + println!("No package.json, Python, Bundler, or Composer project found"); } return 0; } - let cargo_present = cargo_preview.present; - let go_present = go_preview.present; let gem_present = gem_preview.present; let composer_present = composer_preview.present; - let cargo_preview = merge_outcomes( - merge_outcomes(merge_outcomes(cargo_preview, go_preview), gem_preview), - composer_preview, - ); + let extra_preview = merge_outcomes(gem_preview, composer_preview); let npm_pm = detect_package_manager(&common.cwd).await; let telemetry_manager = telemetry_manager_str( !npm_files.is_empty(), py_plan.is_some(), - cargo_present, - go_present, gem_present, composer_present, npm_pm, @@ -1835,15 +1388,15 @@ async fn run_setup(args: &SetupArgs) -> i32 { }; if !common.json { - print_setup_preview(&npm_preview, &py_preview, &cargo_preview, common); + print_setup_preview(&npm_preview, &py_preview, &extra_preview, common); } let n_changes = npm_preview.iter().filter(|r| r.status == UpdateStatus::Updated).count() + py_preview.iter().filter(|r| r.status == PthStatus::Updated).count() - + cargo_preview.changed; + + extra_preview.changed; let preview_errors = npm_preview.iter().filter(|r| r.status == UpdateStatus::Error).count() + py_preview.iter().filter(|r| r.status == PthStatus::Error).count() - + cargo_preview.errors; + + extra_preview.errors; if n_changes == 0 { if common.json { @@ -1851,7 +1404,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { if preview_errors > 0 { "error" } else { "already_configured" }, &npm_preview, &py_preview, - &cargo_preview, + &extra_preview, npm_pm, py_plan.as_ref(), &[], @@ -1870,7 +1423,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { "dry_run", &npm_preview, &py_preview, - &cargo_preview, + &extra_preview, npm_pm, py_plan.as_ref(), &[], @@ -1912,25 +1465,13 @@ async fn run_setup(args: &SetupArgs) -> i32 { py_results = edit_python_manifests(plan, false, false).await; warnings = finalize_python(plan, &py_results, &common.cwd).await; } - // Real cargo + go + gem + composer edits (cargo guard dep/[env] root; go guard - // package + per-main blank imports; gem Gemfile `plugin` block + generated - // plugin dir; composer.json script-event command). - let cargo_results = merge_outcomes( - merge_outcomes( - merge_outcomes( - build_cargo_outcome(common, false, false, &excludes).await, - build_go_outcome(common, false, false).await, - ), - build_gem_outcome(common, false, false).await, - ), + // Real gem + composer edits (gem Gemfile `plugin` block + generated plugin + // dir; composer.json script-event command). + let extra_results = merge_outcomes( + build_gem_outcome(common, false, false).await, build_composer_outcome(common, false, false).await, ); - // Materialise the go.mod `replace` redirects now so the first `go test`/run - // is already in sync (the "automatic" step). Best-effort → warnings only. - if go_present { - warnings.extend(finalize_go(common).await); - } // Materialise gem patches now so the first `bundle install` finds them // applied. Best-effort → warnings only. if gem_present { @@ -1939,14 +1480,14 @@ async fn run_setup(args: &SetupArgs) -> i32 { let errors = npm_results.iter().filter(|r| r.status == UpdateStatus::Error).count() + py_results.iter().filter(|r| r.status == PthStatus::Error).count() - + cargo_results.errors; + + extra_results.errors; if common.json { print_setup_envelope( if errors > 0 { "partial_failure" } else { "success" }, &npm_results, &py_results, - &cargo_results, + &extra_results, npm_pm, py_plan.as_ref(), &warnings, @@ -1954,7 +1495,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { } else { let updated = npm_results.iter().filter(|r| r.status == UpdateStatus::Updated).count() + py_results.iter().filter(|r| r.status == PthStatus::Updated).count() - + cargo_results.changed; + + extra_results.changed; println!("\nSummary:"); println!(" {updated} item(s) updated"); if errors > 0 { @@ -1970,21 +1511,6 @@ async fn run_setup(args: &SetupArgs) -> i32 { plan.pm.as_str() ); } - if cargo_present { - println!( - "\nCommit Cargo.toml (socket-patch-guard), .cargo/config.toml, and your \ - .socket/ patches so the guard re-applies cargo patches in CI." - ); - } - if go_present { - println!( - "\nCommit go.mod (the `replace` directives), internal/socketpatchguard/, the \ - generated socket_patch_guard_import.go files, .socket/go-patches/, and your \ - .socket/ patches. Enforcement: `go test ./...` gates at CI time (the guard \ - reads the patch state in-process, so the test cache re-runs it on any drift), \ - and the init() guard gates every `go run`/binary launch." - ); - } if gem_present { println!( "\nCommit the Gemfile (the `plugin` block), .socket/bundler-plugin/, and your \ @@ -2005,7 +1531,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { fn print_setup_preview( npm: &[UpdateResult], py: &[PthEditResult], - cargo: &SetupOutcome, + extra: &SetupOutcome, common: &GlobalArgs, ) { let npm_changes: Vec<_> = npm.iter().filter(|r| r.status == UpdateStatus::Updated).collect(); @@ -2024,19 +1550,19 @@ fn print_setup_preview( println!(" + {}", pathdiff(&r.path, &common.cwd)); } } - if !cargo.preview.is_empty() { + if !extra.preview.is_empty() { println!(); - for line in &cargo.preview { + for line in &extra.preview { println!("{line}"); } } let npm_already = npm.iter().filter(|r| r.status == UpdateStatus::AlreadyConfigured).count(); let py_already = py.iter().filter(|r| r.status == PthStatus::AlreadyConfigured).count(); - if npm_already + py_already + cargo.already > 0 { + if npm_already + py_already + extra.already > 0 { println!( "\nAlready configured (will skip): {}", - npm_already + py_already + cargo.already + npm_already + py_already + extra.already ); } @@ -2050,7 +1576,7 @@ fn print_setup_preview( .filter_map(|r| r.error.clone()), ) .collect(); - errs.extend(outcome_error_messages(cargo)); + errs.extend(outcome_error_messages(extra)); if !errs.is_empty() { println!("\nErrors:"); for e in &errs { @@ -2064,20 +1590,20 @@ fn print_setup_envelope( status: &str, npm: &[UpdateResult], py: &[PthEditResult], - cargo: &SetupOutcome, + extra: &SetupOutcome, npm_pm: PackageManager, py_plan: Option<&PythonPlan>, warnings: &[String], ) { let updated = npm.iter().filter(|r| r.status == UpdateStatus::Updated).count() + py.iter().filter(|r| r.status == PthStatus::Updated).count() - + cargo.changed; + + extra.changed; let already = npm.iter().filter(|r| r.status == UpdateStatus::AlreadyConfigured).count() + py.iter().filter(|r| r.status == PthStatus::AlreadyConfigured).count() - + cargo.already; + + extra.already; let errors = npm.iter().filter(|r| r.status == UpdateStatus::Error).count() + py.iter().filter(|r| r.status == PthStatus::Error).count() - + cargo.errors; + + extra.errors; let mut files: Vec = npm .iter() @@ -2098,7 +1624,7 @@ fn print_setup_envelope( "error": r.error, }) })); - files.extend(cargo.json_files.iter().cloned()); + files.extend(extra.json_files.iter().cloned()); let mut obj = serde_json::json!({ "status": status, diff --git a/crates/socket-patch-cli/tests/e2e_cargo_coexist.rs b/crates/socket-patch-cli/tests/e2e_cargo_coexist.rs deleted file mode 100644 index 938d7c5..0000000 --- a/crates/socket-patch-cli/tests/e2e_cargo_coexist.rs +++ /dev/null @@ -1,763 +0,0 @@ -#![cfg(feature = "cargo")] -//! End-to-end coexistence test for the project-local cargo `[patch]`-redirect -//! backend. -//! -//! Proves that patching a registry crate for project A: -//! * redirects A to a project-local patched copy under -//! `A/.socket/cargo-patches/` via a managed `[patch.crates-io]` entry, and -//! * leaves the *shared* registry crate pristine — so a sibling project B -//! resolving the same crate still sees the unpatched source. -//! -//! Also covers the self-heal/idempotency hot path, rollback, reconcile of a -//! dropped patch, and the read-only `apply --check` auditor (including its -//! registry-independence). No network and no real `cargo` — a fake -//! `$CARGO_HOME/registry/src/` tree stands in for an extracted crate. - -use std::path::{Path, PathBuf}; - -#[path = "common/mod.rs"] -mod common; - -use common::{ - binary, cargo_run, git_sha256, has_command, json_string, parse_json_envelope, run_with_env, - write_blob, write_minimal_manifest, PatchEntry, -}; - -/// The exact managed `[patch.crates-io]` entry apply must write — keying the -/// crate NAME to its version-specific copy path. Asserting the full `key = { -/// path = ... }` line (not two independent `contains()` substrings) closes the -/// loophole where a broken impl writes the `[patch.crates-io]` header plus the -/// copy path under the WRONG key (or no key) and still passes — cargo keys -/// `[patch]` by name, so the key↔path binding is what actually redirects. -const EXPECTED_PATCH_LINE: &str = - "cfg-if = { path = \".socket/cargo-patches/cfg-if-1.0.0\" }"; - -/// Parse an `apply --json` envelope and assert it reports a real, successful -/// patch of `PURL` (status=success, summary.applied≥1, an `applied` event for -/// the purl). Guards against an apply that exits 0 while reporting nothing -/// applied (or a failure) yet happens to leave plausible bytes on disk. -fn assert_applied_envelope(stdout: &str) { - let env = parse_json_envelope(stdout); - assert_eq!( - json_string(&env, "status"), - Some("success"), - "apply envelope status must be success:\n{stdout}" - ); - assert!( - env["summary"]["applied"].as_u64().unwrap_or(0) >= 1, - "summary.applied must be >= 1:\n{stdout}" - ); - let events = env["events"].as_array().expect("events array"); - assert!( - events.iter().any(|e| e["action"] == "applied" && e["purl"] == PURL), - "expected an `applied` event for {PURL}:\n{stdout}" - ); -} - -const CRATE: &str = "cfg-if"; -const VERSION: &str = "1.0.0"; -const PURL: &str = "pkg:cargo/cfg-if@1.0.0"; -const UUID: &str = "20202020-2020-4202-8202-202020202020"; - -const PRISTINE: &[u8] = b"pub fn cfg() -> u8 { 1 }\n"; -const PATCHED: &[u8] = b"pub fn cfg() -> u8 { 2 } // patched\n"; -const PATCHED_V2: &[u8] = b"pub fn cfg() -> u8 { 3 } // patched again\n"; - -/// Stage a fake extracted registry crate at -/// `/registry/src/index.crates.io-test/-/` with the -/// given `lib` bytes + a valid-shaped `.cargo-checksum.json`. Returns the crate -/// dir. -fn stage_registry_crate(cargo_home: &Path, lib: &[u8]) -> PathBuf { - let crate_dir = cargo_home - .join("registry/src/index.crates.io-test") - .join(format!("{CRATE}-{VERSION}")); - std::fs::create_dir_all(crate_dir.join("src")).unwrap(); - std::fs::write( - crate_dir.join("Cargo.toml"), - format!("[package]\nname = \"{CRATE}\"\nversion = \"{VERSION}\"\n"), - ) - .unwrap(); - std::fs::write(crate_dir.join("src/lib.rs"), lib).unwrap(); - std::fs::write( - crate_dir.join(".cargo-checksum.json"), - "{\"files\":{},\"package\":\"x\"}", - ) - .unwrap(); - crate_dir -} - -/// Stage a consumer project that depends on the crate (a `Cargo.toml` makes the -/// cargo crawler fall back to `$CARGO_HOME/registry/src`; no `vendor/` so the -/// redirect model engages). -fn stage_project(root: &Path) { - std::fs::create_dir_all(root.join("src")).unwrap(); - std::fs::write( - root.join("Cargo.toml"), - format!("[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{CRATE} = \"={VERSION}\"\n"), - ) - .unwrap(); - std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); -} - -/// Write `.socket/manifest.json` + the after-hash blob for a patch turning -/// `PRISTINE` into `patched`. -fn stage_manifest(root: &Path, patched: &[u8]) -> (String, String) { - let before = git_sha256(PRISTINE); - let after = git_sha256(patched); - let socket = root.join(".socket"); - write_minimal_manifest( - &socket, - PURL, - UUID, - &[PatchEntry { - file_name: "package/src/lib.rs", - before_hash: &before, - after_hash: &after, - }], - ); - write_blob(&socket, &after, patched); - (before, after) -} - -fn apply(root: &Path, cargo_home: &Path) -> (i32, String, String) { - run_with_env( - root, - &[ - "apply", - "--offline", - "-e", - "cargo", - "--cwd", - root.to_str().unwrap(), - "--json", - ], - &[("CARGO_HOME", cargo_home.to_str().unwrap())], - ) -} - -fn copy_lib(root: &Path) -> PathBuf { - root.join(format!( - ".socket/cargo-patches/{CRATE}-{VERSION}/src/lib.rs" - )) -} - -fn config_toml(root: &Path) -> PathBuf { - root.join(".cargo/config.toml") -} - -#[test] -fn apply_redirects_and_leaves_registry_pristine() { - let tmp = tempfile::tempdir().unwrap(); - let cargo_home = tmp.path().join("cargo-home"); - let project = tmp.path().join("A"); - let crate_dir = stage_registry_crate(&cargo_home, PRISTINE); - stage_project(&project); - stage_manifest(&project, PATCHED); - - let (code, stdout, stderr) = apply(&project, &cargo_home); - assert_eq!( - code, 0, - "apply failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); - // The JSON envelope must actually report the patch as applied — not just - // exit 0 while reporting nothing (or a partial failure). - assert_applied_envelope(&stdout); - - // Project-local patched copy holds EXACTLY the patched bytes, and its - // git-sha matches the manifest afterHash (independently derived from - // PATCHED) — so the bytes aren't merely non-empty, they're the right ones. - let copy_bytes = std::fs::read(copy_lib(&project)).unwrap(); - assert_eq!(copy_bytes, PATCHED); - assert_eq!(git_sha256(©_bytes), git_sha256(PATCHED)); - // Managed [patch.crates-io] entry binds the crate NAME to the copy path. - let cfg = std::fs::read_to_string(config_toml(&project)).unwrap(); - assert!( - cfg.contains("[patch.crates-io]"), - "config.toml missing [patch.crates-io] table:\n{cfg}" - ); - assert!( - cfg.contains(EXPECTED_PATCH_LINE), - "config.toml missing the exact `{EXPECTED_PATCH_LINE}` entry \ - (key must bind to the version-specific copy path):\n{cfg}" - ); - // apply also wires the build-time guard's [env] SOCKET_PATCH_ROOT — the - // rollback test depends on this being present so it can prove rollback - // leaves it intact. Pin it here at the source. - assert!( - cfg.contains("SOCKET_PATCH_ROOT"), - "apply must wire [env] SOCKET_PATCH_ROOT for the guard:\n{cfg}" - ); - // The SHARED registry crate is untouched — a sibling project sees pristine. - assert_eq!( - std::fs::read(crate_dir.join("src/lib.rs")).unwrap(), - PRISTINE, - "registry crate must NOT be mutated by the local redirect" - ); - // The registry checksum sidecar is likewise pristine (the redirect model - // must not rewrite the shared registry's .cargo-checksum.json). - assert_eq!( - std::fs::read_to_string(crate_dir.join(".cargo-checksum.json")).unwrap(), - "{\"files\":{},\"package\":\"x\"}", - "registry .cargo-checksum.json must NOT be mutated" - ); -} - -#[test] -fn project_without_manifest_has_no_redirect() { - let tmp = tempfile::tempdir().unwrap(); - let cargo_home = tmp.path().join("cargo-home"); - let project = tmp.path().join("B"); - stage_registry_crate(&cargo_home, PRISTINE); - stage_project(&project); // no .socket/manifest.json - - let (code, stdout, _stderr) = apply(&project, &cargo_home); - assert_eq!( - code, 0, - "apply on a manifest-less project should be a clean no-op" - ); - // The envelope must say *why* it was a no-op: noManifest, nothing applied. - // Otherwise a broken apply that silently did nothing (or errored) on a real - // manifest would also look like a clean exit-0 here. - let env = parse_json_envelope(&stdout); - assert_eq!( - json_string(&env, "status"), - Some("noManifest"), - "manifest-less apply must report status=noManifest:\n{stdout}" - ); - assert_eq!( - env["summary"]["applied"].as_u64().unwrap_or(u64::MAX), - 0, - "manifest-less apply must apply nothing:\n{stdout}" - ); - assert!( - !config_toml(&project).exists(), - "no manifest => no [patch] redirect written" - ); - // And no patched copy materialised either. - assert!( - !project.join(".socket/cargo-patches").exists(), - "no manifest => no patched copy tree" - ); -} - -#[test] -fn reapply_in_sync_is_byte_identical() { - let tmp = tempfile::tempdir().unwrap(); - let cargo_home = tmp.path().join("cargo-home"); - let project = tmp.path().join("A"); - stage_registry_crate(&cargo_home, PRISTINE); - stage_project(&project); - stage_manifest(&project, PATCHED); - - let (c1, out1, err1) = apply(&project, &cargo_home); - assert_eq!(c1, 0, "first apply failed.\nstdout:\n{out1}\nstderr:\n{err1}"); - assert_applied_envelope(&out1); - let lib1 = std::fs::read(copy_lib(&project)).unwrap(); - let cfg1 = std::fs::read_to_string(config_toml(&project)).unwrap(); - // The snapshot we're about to prove "byte-identical" must itself be the - // CORRECT state — otherwise idempotently reproducing a *wrong* state (e.g. - // an apply that never patched) would pass this test. - assert_eq!(lib1, PATCHED, "first apply did not patch the copy"); - assert!( - cfg1.contains(EXPECTED_PATCH_LINE), - "first apply did not write the managed patch entry:\n{cfg1}" - ); - - // Second apply must hit the in-sync short-circuit: the envelope must report - // the package as already-patched (skipped), NOT re-applied. A regression - // that re-copies + re-patches every run would still leave byte-identical - // files, so byte-equality alone can't detect it — assert the action. - let (c2, out2, err2) = apply(&project, &cargo_home); - assert_eq!(c2, 0, "resync apply failed.\nstdout:\n{out2}\nstderr:\n{err2}"); - let env2 = parse_json_envelope(&out2); - assert_eq!( - json_string(&env2, "status"), - Some("success"), - "resync status must be success:\n{out2}" - ); - assert_eq!( - env2["summary"]["applied"].as_u64().unwrap_or(u64::MAX), - 0, - "resync must apply nothing (in-sync short-circuit):\n{out2}" - ); - let events2 = env2["events"].as_array().expect("events array"); - assert!( - events2 - .iter() - .any(|e| e["action"] == "skipped" && e["purl"] == PURL), - "resync must emit a `skipped` (already-patched) event for {PURL}:\n{out2}" - ); - - assert_eq!( - std::fs::read(copy_lib(&project)).unwrap(), - lib1, - "copy bytes changed on resync" - ); - assert_eq!( - std::fs::read_to_string(config_toml(&project)).unwrap(), - cfg1, - "config changed on resync" - ); -} - -#[test] -fn self_heal_regenerates_copy_when_manifest_changes() { - let tmp = tempfile::tempdir().unwrap(); - let cargo_home = tmp.path().join("cargo-home"); - let project = tmp.path().join("A"); - stage_registry_crate(&cargo_home, PRISTINE); - stage_project(&project); - stage_manifest(&project, PATCHED); - assert_eq!(apply(&project, &cargo_home).0, 0); - assert_eq!(std::fs::read(copy_lib(&project)).unwrap(), PATCHED); - - // Patch set changes (afterHash + content) — re-apply regenerates the copy. - stage_manifest(&project, PATCHED_V2); - let (code, stdout, stderr) = apply(&project, &cargo_home); - assert_eq!(code, 0, "re-apply failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); - // The manifest drifted from the committed copy, so this must be a real - // re-apply (applied event), not an already-patched short-circuit. - assert_applied_envelope(&stdout); - let regenerated = std::fs::read(copy_lib(&project)).unwrap(); - assert_eq!( - regenerated, PATCHED_V2, - "copy must be regenerated to the new patched content" - ); - // And distinct from the previous patched content — proves a genuine - // regeneration, not a stale leftover that happens to read back. - assert_ne!(regenerated, PATCHED, "copy is still the stale v1 content"); - assert_eq!(git_sha256(®enerated), git_sha256(PATCHED_V2)); -} - -#[test] -fn rollback_removes_redirect_offline_without_registry() { - let tmp = tempfile::tempdir().unwrap(); - let cargo_home = tmp.path().join("cargo-home"); - let project = tmp.path().join("A"); - let crate_dir = stage_registry_crate(&cargo_home, PRISTINE); - stage_project(&project); - stage_manifest(&project, PATCHED); - assert_eq!(apply(&project, &cargo_home).0, 0); - assert!(copy_lib(&project).exists()); - - let (code, stdout, stderr) = run_with_env( - &project, - &[ - "rollback", - "--offline", - "-e", - "cargo", - "--cwd", - project.to_str().unwrap(), - "--yes", - "--json", - ], - &[("CARGO_HOME", cargo_home.to_str().unwrap())], - ); - assert_eq!( - code, 0, - "rollback failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); - // The rollback envelope must report a real removal (rolledBack >= 1), not - // exit 0 having done nothing. - let rb = parse_json_envelope(&stdout); - assert_eq!( - json_string(&rb, "status"), - Some("success"), - "rollback status must be success:\n{stdout}" - ); - assert!( - rb["rolledBack"].as_u64().unwrap_or(0) >= 1, - "rollback must report >= 1 rolled-back package:\n{stdout}" - ); - assert_eq!( - rb["failed"].as_u64().unwrap_or(u64::MAX), - 0, - "rollback must report no failures:\n{stdout}" - ); - - // Redirect copy + config entry are gone; the registry stayed pristine. - assert!( - !project - .join(format!(".socket/cargo-patches/{CRATE}-{VERSION}")) - .exists(), - "copy dir should be removed on rollback" - ); - // Read WITHOUT a default fallback: a wrongly-deleted config.toml must fail - // loudly here, not collapse to "" and let the `!contains(CRATE)` check pass - // vacuously (the SOCKET_PATCH_ROOT survival assert below is the only thing - // that would otherwise catch a deletion — make the failure mode explicit). - let cfg = std::fs::read_to_string(config_toml(&project)) - .expect("config.toml must survive rollback (it holds [env] setup state)"); - assert!( - !cfg.contains(CRATE), - "managed [patch] entry should be gone:\n{cfg}" - ); - // Rollback removes patch state only — the [env] SOCKET_PATCH_ROOT setup - // state (written by apply/setup, owned by setup --remove) must survive so - // the guard stays wired. - assert!( - cfg.contains("SOCKET_PATCH_ROOT"), - "rollback must NOT remove [env] SOCKET_PATCH_ROOT (setup state):\n{cfg}" - ); - assert_eq!( - std::fs::read(crate_dir.join("src/lib.rs")).unwrap(), - PRISTINE - ); -} - -#[test] -fn reconcile_prunes_dropped_patch() { - let tmp = tempfile::tempdir().unwrap(); - let cargo_home = tmp.path().join("cargo-home"); - let project = tmp.path().join("A"); - stage_registry_crate(&cargo_home, PRISTINE); - stage_project(&project); - stage_manifest(&project, PATCHED); - assert_eq!(apply(&project, &cargo_home).0, 0); - assert!(copy_lib(&project).exists()); - - // Drop the patch from the manifest, then re-apply: reconcile prunes the - // now-orphan redirect even though the manifest lists zero cargo patches. - let empty = serde_json::json!({ "patches": {} }); - std::fs::write( - project.join(".socket/manifest.json"), - serde_json::to_string_pretty(&empty).unwrap(), - ) - .unwrap(); - // The empty manifest takes the "nothing to apply" early-return path (today: - // exit 1 / status=partialFailure; a future no-op-success fix would make it - // exit 0), but reconcile runs BEFORE that return and prunes the orphan. We - // deliberately don't pin the exact status (it's the early-return path, not - // the contract under test) — but `rc_code >= 0` was vacuous: every normal - // exit, INCLUDING a Rust panic (code 101), satisfies it, so it could not - // actually catch the binary crashing before reconcile. Instead require the - // apply pipeline to have RUN TO COMPLETION: a normal exit in {0,1} (rejects - // panic=101 and signal=-1) AND a well-formed JSON envelope that applied - // nothing. A panic/abort before reconcile yields no envelope (parse panics) - // or a signal exit; a runaway re-apply would report applied>=1 — both fail - // loudly here rather than silently passing the FS checks below. - let (rc_code, rc_out, rc_err) = apply(&project, &cargo_home); - assert!( - rc_code == 0 || rc_code == 1, - "empty-manifest apply must exit 0/1 (not crash), got {rc_code}.\nstdout:\n{rc_out}\nstderr:\n{rc_err}" - ); - let rc_env = parse_json_envelope(&rc_out); - assert!( - matches!(json_string(&rc_env, "status"), Some("partialFailure") | Some("success")), - "empty-manifest apply must reach a clean terminal status, got {:?}:\n{rc_out}", - json_string(&rc_env, "status") - ); - assert_eq!( - rc_env["summary"]["applied"].as_u64().unwrap_or(u64::MAX), - 0, - "reconcile/empty-manifest apply must apply nothing:\n{rc_out}" - ); - - assert!( - !project - .join(format!(".socket/cargo-patches/{CRATE}-{VERSION}")) - .exists(), - "orphan copy dir should be pruned by reconcile" - ); - // config.toml must still EXIST (reconcile prunes patch entries but must keep - // the [env] setup state) — read it WITHOUT a default fallback so a wrongly - // deleted file fails loudly here instead of vacuously passing the !contains - // check below. - let cfg = std::fs::read_to_string(config_toml(&project)) - .expect("config.toml must survive reconcile (it holds [env] setup state)"); - assert!( - !cfg.contains(CRATE), - "orphan [patch] entry should be pruned:\n{cfg}" - ); - // The [env] SOCKET_PATCH_ROOT setup state must NOT be dropped by reconcile — - // it is owned by `setup`/`setup --remove`, independent of whether any - // redirects remain (mirrors the production invariant). - assert!( - cfg.contains("SOCKET_PATCH_ROOT"), - "reconcile must NOT remove [env] SOCKET_PATCH_ROOT (setup state):\n{cfg}" - ); -} - -#[test] -fn check_detects_drift_and_is_registry_independent() { - let tmp = tempfile::tempdir().unwrap(); - let cargo_home = tmp.path().join("cargo-home"); - let project = tmp.path().join("A"); - let crate_dir = stage_registry_crate(&cargo_home, PRISTINE); - stage_project(&project); - stage_manifest(&project, PATCHED); - assert_eq!(apply(&project, &cargo_home).0, 0); - - // Drop the registry crate entirely — `--check` reads only manifest + copy - // + config, so it must still work (fresh-clone / airgapped CI). - std::fs::remove_dir_all(&crate_dir).unwrap(); - - let check = |root: &Path| -> i32 { - run_with_env( - root, - &[ - "apply", - "--check", - "--offline", - "-e", - "cargo", - "--cwd", - root.to_str().unwrap(), - ], - &[("CARGO_HOME", cargo_home.to_str().unwrap())], - ) - .0 - }; - - // In sync (no registry present) → exit 0. - assert_eq!( - check(&project), - 0, - "in-sync --check should pass even with no registry crate" - ); - - // Mutate the manifest afterHash without re-applying → the committed copy - // is now stale → `--check` must fail. - stage_manifest(&project, PATCHED_V2); - assert_eq!( - check(&project), - 1, - "drift should make --check exit non-zero" - ); -} - -/// Real-cargo end-to-end: prove that the committed `[patch.crates-io]` entry -/// (relative path) + `[env] SOCKET_PATCH_ROOT` resolve correctly and that a -/// bare `cargo build` actually compiles the **patched copy**, not the pristine -/// registry crate. The patch appends a top-level `compile_error!`, so the build -/// FAILS with that marker iff the redirect resolved — an unambiguous signal. -/// -/// `#[ignore]`d: needs real `cargo` + a network `cargo fetch` from crates.io. -/// Skips (rather than fails) when cargo is absent or the fetch fails offline. -#[test] -#[ignore] -fn real_cargo_resolves_to_patched_copy() { - if !has_command("cargo") { - eprintln!("SKIP: cargo not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let consumer = tmp.path().join("consumer"); - let cargo_home = tmp.path().join("cargo-home"); - std::fs::create_dir_all(consumer.join("src")).unwrap(); - std::fs::create_dir_all(&cargo_home).unwrap(); - std::fs::write( - consumer.join("Cargo.toml"), - format!("[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{CRATE} = \"={VERSION}\"\n"), - ) - .unwrap(); - std::fs::write(consumer.join("src/main.rs"), "fn main() {}\n").unwrap(); - - // Populate the registry (network). Skip on failure (offline CI etc.). - let fetch = cargo_run( - &consumer, - &["fetch"], - &[("CARGO_HOME", cargo_home.to_str().unwrap())], - ); - if !fetch.status.success() { - eprintln!( - "SKIP: cargo fetch failed (likely no network):\n{}", - String::from_utf8_lossy(&fetch.stderr) - ); - return; - } - - // Locate the extracted crate + read its pristine lib.rs. - let registry_src = cargo_home.join("registry/src"); - let mut lib_path = None; - for entry in std::fs::read_dir(®istry_src).unwrap().flatten() { - let candidate = entry - .path() - .join(format!("{CRATE}-{VERSION}")) - .join("src/lib.rs"); - if candidate.exists() { - lib_path = Some(candidate); - break; - } - } - let lib_path = lib_path.expect("cfg-if lib.rs after fetch"); - let pristine = std::fs::read(&lib_path).unwrap(); - let mut patched = pristine.clone(); - patched.extend_from_slice(b"\ncompile_error!(\"SOCKET_PATCH_APPLIED\");\n"); - - // Stage a manifest/blob for the real pristine→patched transition. - let before = git_sha256(&pristine); - let after = git_sha256(&patched); - let socket = consumer.join(".socket"); - write_minimal_manifest( - &socket, - PURL, - UUID, - &[PatchEntry { - file_name: "package/src/lib.rs", - before_hash: &before, - after_hash: &after, - }], - ); - write_blob(&socket, &after, &patched); - - // Apply the redirect. - let (code, stdout, stderr) = apply(&consumer, &cargo_home); - assert_eq!( - code, 0, - "apply failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); - // The pristine registry crate is untouched. - assert_eq!( - std::fs::read(&lib_path).unwrap(), - pristine, - "registry must stay pristine" - ); - - // A bare `cargo build` must resolve to the patched copy → the injected - // compile_error fires. If the redirect didn't resolve, the pristine crate - // builds cleanly and this assertion fails. - let build = cargo_run( - &consumer, - &["build", "--offline"], - &[("CARGO_HOME", cargo_home.to_str().unwrap())], - ); - let build_err = String::from_utf8_lossy(&build.stderr); - assert!( - !build.status.success() && build_err.contains("SOCKET_PATCH_APPLIED"), - "cargo build must compile the PATCHED copy (expected the injected \ - compile_error). success={}, stderr:\n{build_err}", - build.status.success(), - ); -} - -/// Real-cargo end-to-end **fail-closed** proof: with the guard wired (path dep + -/// `[env] SOCKET_PATCH_ROOT` + `SOCKET_PATCH_BIN` = the real cargo-enabled -/// binary), a `cargo build` whose committed patched copy is STALE relative to -/// `.socket/manifest.json` must FAIL at build-script time (the guard's -/// `apply --check` detects drift), so a stale/unpatched binary is never -/// produced — closing the 1-build-lag silent-stale hole. -/// -/// `#[ignore]`d: needs real `cargo` + network. Skips when offline. -#[test] -#[ignore] -fn real_cargo_guard_fails_build_on_stale_patch() { - if !has_command("cargo") { - eprintln!("SKIP: cargo not on PATH"); - return; - } - let guard_dir = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join("socket-patch-guard"); - - let tmp = tempfile::tempdir().unwrap(); - let consumer = tmp.path().join("consumer"); - let cargo_home = tmp.path().join("cargo-home"); - std::fs::create_dir_all(consumer.join("src")).unwrap(); - std::fs::create_dir_all(&cargo_home).unwrap(); - std::fs::write( - consumer.join("Cargo.toml"), - format!("[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{CRATE} = \"={VERSION}\"\nsocket-patch-guard = {{ path = {guard_dir:?} }}\n"), - ) - .unwrap(); - std::fs::write(consumer.join("src/main.rs"), "fn main() {}\n").unwrap(); - - let fetch = cargo_run( - &consumer, - &["fetch"], - &[("CARGO_HOME", cargo_home.to_str().unwrap())], - ); - if !fetch.status.success() { - eprintln!("SKIP: cargo fetch failed (likely no network)"); - return; - } - - let registry_src = cargo_home.join("registry/src"); - let mut lib_path = None; - for entry in std::fs::read_dir(®istry_src).unwrap().flatten() { - let c = entry - .path() - .join(format!("{CRATE}-{VERSION}")) - .join("src/lib.rs"); - if c.exists() { - lib_path = Some(c); - break; - } - } - let lib_path = lib_path.expect("lib.rs after fetch"); - let pristine = std::fs::read(&lib_path).unwrap(); - let before = git_sha256(&pristine); - let socket = consumer.join(".socket"); - - // v1: benign API-compatible patch (an appended const) — must build clean. - // (cfg-if has `#![deny(missing_docs)]`, so the item needs a doc comment.) - let mut v1 = pristine.clone(); - v1.extend_from_slice(b"\n/// socket-patch test marker v1.\npub const __SOCKET_PATCH_V1: u8 = 1;\n"); - let after_v1 = git_sha256(&v1); - write_minimal_manifest( - &socket, - PURL, - UUID, - &[PatchEntry { - file_name: "package/src/lib.rs", - before_hash: &before, - after_hash: &after_v1, - }], - ); - write_blob(&socket, &after_v1, &v1); - assert_eq!(apply(&consumer, &cargo_home).0, 0); // committed copy in sync - - let bin = binary(); - let env = [ - ("CARGO_HOME", cargo_home.to_str().unwrap()), - ("SOCKET_PATCH_ROOT", consumer.to_str().unwrap()), - ("SOCKET_PATCH_BIN", bin.to_str().unwrap()), - ]; - - // In sync → the guard's `apply --check` passes → build succeeds. - let ok = cargo_run(&consumer, &["build", "--offline"], &env); - assert!( - ok.status.success(), - "in-sync guarded build must succeed.\nstderr:\n{}", - String::from_utf8_lossy(&ok.stderr) - ); - - // Change the patch in the MANIFEST + blob (v2) but DON'T re-apply, so the - // committed copy is now stale relative to the manifest. - let mut v2 = pristine.clone(); - v2.extend_from_slice(b"\n/// socket-patch test marker v2.\npub const __SOCKET_PATCH_V2: u8 = 2;\n"); - let after_v2 = git_sha256(&v2); - write_minimal_manifest( - &socket, - PURL, - UUID, - &[PatchEntry { - file_name: "package/src/lib.rs", - before_hash: &before, - after_hash: &after_v2, - }], - ); - write_blob(&socket, &after_v2, &v2); - - // Guarded build with a stale committed patch → guard detects drift → build - // FAILS (fail-closed; no stale artifact shipped). This v2 patch is API- - // compatible, so the guard's heal reconciles it and the build fails with the - // RECOVERABLE message ("regenerated … re-run the build"); the v1→v2 mismatch - // is what makes the committed copy stale. - let drift = cargo_run(&consumer, &["build", "--offline"], &env); - let stderr = String::from_utf8_lossy(&drift.stderr); - assert!( - !drift.status.success(), - "guarded build with a stale committed patch MUST fail (fail-closed).\nstderr:\n{stderr}" - ); - // Assert the SPECIFIC recoverable-drift message, not a generic substring: - // cargo's "failed to run custom build command for `socket-patch-guard …`" - // boilerplate contains "socket-patch" on ANY build-script failure, which - // would let this pass even if the guard failed for an unrelated reason. - assert!( - stderr.contains("regenerated") && stderr.to_lowercase().contains("re-run"), - "failure must carry the guard's recoverable-drift message.\nstderr:\n{stderr}" - ); -} diff --git a/crates/socket-patch-cli/tests/e2e_golang_build.rs b/crates/socket-patch-cli/tests/e2e_golang_build.rs index 8d78f40..6ae2d9d 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_build.rs @@ -1,7 +1,13 @@ #![cfg(all(unix, feature = "golang"))] -//! Full go-toolchain capstone for the Go `replace`-redirect guard: proves the -//! patched bytes are actually LINKED by `go build`, and that the committed guard -//! enforces drift at runtime (`init()`) and self-heals. +//! Full go-toolchain capstone for the Go `replace`-redirect: proves the patched +//! bytes are actually LINKED by `go build`, and that the read-only +//! `apply --check` redirect auditor detects drift in the committed copy. +//! +//! Go is the one ecosystem that still uses the project-local `replace`-redirect +//! (the module cache is `go.sum`-verified, so in-place patching can't build). +//! There is no longer a build-time guard or `setup` step for Go — the committed +//! `go.mod` `replace` + `.socket/go-patches/` copy is the whole mechanism, and +//! `go build` links it with no extra wiring. //! //! Hermetic + offline: a tiny upstream module is served from a local file //! GOPROXY into a temp GOMODCACHE, so no network and no pre-cached module are @@ -13,7 +19,7 @@ use std::process::Command; #[path = "common/mod.rs"] mod common; -use common::{binary, git_sha256, has_command, run_with_env}; +use common::{git_sha256, has_command, run_with_env}; const UMOD: &str = "example.com/upstream"; const UVER: &str = "v1.0.0"; @@ -124,7 +130,7 @@ fn walkdir(dir: &Path) -> Vec { } #[test] -fn go_build_links_patch_and_guard_enforces_drift() { +fn go_build_links_patch_via_replace_redirect() { if !has_command("go") || !has_command("zip") { eprintln!("skipping e2e_golang_build: `go`/`zip` not installed"); return; @@ -134,15 +140,14 @@ fn go_build_links_patch_and_guard_enforces_drift() { let cs = consumer.to_str().unwrap(); let mc = modcache.to_str().unwrap(); let goenv = go_env(mc, &proxy_url); - let bin = binary(); - let bin_s = bin.to_str().unwrap(); // Baseline build links PRISTINE. let base = go(&consumer, &["run", "."], &goenv); assert!(base.status.success(), "baseline run failed: {}", String::from_utf8_lossy(&base.stderr)); assert!(String::from_utf8_lossy(&base.stdout).contains("OUT: PRISTINE")); - // Patch + apply (socket-patch reads only the cache; no `go`). + // Patch + apply (socket-patch reads only the cache; no `go`). This writes the + // project-local copy under `.socket/go-patches/` and the `go.mod` `replace`. write_patch(&consumer); let (code, so, se) = run_with_env( &consumer, @@ -151,7 +156,7 @@ fn go_build_links_patch_and_guard_enforces_drift() { ); assert_eq!(code, 0, "apply failed.\n{so}\n{se}"); - // The patched bytes are now LINKED by go build. + // The patched bytes are now LINKED by `go build` via the `replace` redirect. let patched = go(&consumer, &["run", "."], &goenv); assert!(patched.status.success(), "patched run failed: {}", String::from_utf8_lossy(&patched.stderr)); assert!( @@ -160,46 +165,16 @@ fn go_build_links_patch_and_guard_enforces_drift() { String::from_utf8_lossy(&patched.stdout) ); - // ── setup wires the guard; go test (cold) passes in sync ───────── - let (code, so, se) = run_with_env( + // `apply --check` (read-only redirect auditor) reports the committed + // redirect as in sync. + let (code, _so, _se) = run_with_env( &consumer, - &["setup", "--cwd", cs, "--yes"], - &[("GOMODCACHE", mc), ("SOCKET_PATCH_BIN", bin_s)], - ); - assert_eq!(code, 0, "setup failed.\n{so}\n{se}"); - assert!(consumer.join("internal/socketpatchguard/guard.go").exists()); - assert!(consumer.join("socket_patch_guard_import.go").exists()); - - let test_env: Vec<(&str, &str)> = goenv.iter().cloned().chain([("SOCKET_PATCH_BIN", bin_s)]).collect(); - let t = go(&consumer, &["test", "-count=1", "./..."], &test_env); - assert!( - t.status.success(), - "guard test should pass in sync:\n{}\n{}", - String::from_utf8_lossy(&t.stdout), - String::from_utf8_lossy(&t.stderr) - ); - - // ── warm-cache drift: `go test` (NO -count=1) must NOT serve a stale PASS ── - // Prime the cache with a passing run, then corrupt the copy and run again - // WITHOUT -count=1. The guard reads the patch state in-process, so the test - // cache must re-run the gate and FAIL (this is the test-cache-masking fix). - let warm = consumer.join(".socket/go-patches/example.com").join(format!("upstream@{UVER}")).join("lib.go"); - let _ = go(&consumer, &["test", "./internal/socketpatchguard/"], &test_env); // prime cache (no -count=1) - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&warm, std::fs::Permissions::from_mode(0o644)); - } - std::fs::write(&warm, "package upstream\n\nfunc Greeting() string { return \"WARM-DRIFT\" }\n").unwrap(); - let warm_test = go(&consumer, &["test", "./internal/socketpatchguard/"], &test_env); // NO -count=1 - assert!( - !warm_test.status.success(), - "WARM-CACHE go test must catch drift (not serve a cached PASS):\n{}\n{}", - String::from_utf8_lossy(&warm_test.stdout), - String::from_utf8_lossy(&warm_test.stderr) + &["apply", "--check", "--ecosystems", "golang", "--cwd", cs], + &[("GOMODCACHE", mc)], ); - // (heal happened during that run; restore is verified by the -count=1 block below) + assert_eq!(code, 0, "apply --check should be in sync after apply"); - // ── drift: corrupt the committed copy → guard test fails closed ── + // Corrupt the committed copy → `apply --check` must detect drift (exit !=0). let copy_file = consumer .join(".socket/go-patches/example.com") .join(format!("upstream@{UVER}")) @@ -209,85 +184,27 @@ fn go_build_links_patch_and_guard_enforces_drift() { let _ = std::fs::set_permissions(©_file, std::fs::Permissions::from_mode(0o644)); } std::fs::write(©_file, "package upstream\n\nfunc Greeting() string { return \"DRIFT\" }\n").unwrap(); - - let t2 = go(&consumer, &["test", "-count=1", "./internal/socketpatchguard/"], &test_env); - assert!( - !t2.status.success(), - "guard test must FAIL on drift (it self-heals + fails):\n{}\n{}", - String::from_utf8_lossy(&t2.stdout), - String::from_utf8_lossy(&t2.stderr) - ); - - // The heal restored the patched bytes; a re-run passes. - let t3 = go(&consumer, &["test", "-count=1", "./internal/socketpatchguard/"], &test_env); - assert!( - t3.status.success(), - "guard test should pass after self-heal:\n{}\n{}", - String::from_utf8_lossy(&t3.stdout), - String::from_utf8_lossy(&t3.stderr) - ); - - // Best-effort: relax perms so the temp cache cleans up. - chmod_writable(tmp.path()); -} - -#[test] -fn guard_is_noop_outside_module_tree() { - if !has_command("go") || !has_command("zip") { - eprintln!("skipping e2e_golang_build: `go`/`zip` not installed"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let (consumer, modcache, proxy_url) = stage(tmp.path()); - let cs = consumer.to_str().unwrap(); - let mc = modcache.to_str().unwrap(); - let goenv = go_env(mc, &proxy_url); - let bin = binary(); - - // Patch + apply + wire the guard, then build a real binary. - write_patch(&consumer); - assert_eq!( - run_with_env(&consumer, &["apply", "--offline", "--ecosystems", "golang", "--cwd", cs], &[("GOMODCACHE", mc)]).0, - 0 - ); - run_with_env( + let (code, _so, _se) = run_with_env( &consumer, - &["setup", "--cwd", cs, "--yes"], - &[("GOMODCACHE", mc), ("SOCKET_PATCH_BIN", bin.to_str().unwrap())], + &["apply", "--check", "--ecosystems", "golang", "--cwd", cs], + &[("GOMODCACHE", mc)], ); - let build = go(&consumer, &["build", "-o", "app", "."], &goenv); - assert!(build.status.success(), "go build failed: {}", String::from_utf8_lossy(&build.stderr)); - - // Copy the binary OUT of the module tree (simulating a shipped binary with - // no .socket/ alongside it) and run it from a dir with no go.mod ancestor. - let outside = tmp.path().join("shipped"); - std::fs::create_dir_all(&outside).unwrap(); - let app = outside.join("app"); - std::fs::copy(consumer.join("app"), &app).unwrap(); - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&app, std::fs::Permissions::from_mode(0o755)).unwrap(); - } + assert_ne!(code, 0, "apply --check must detect drift in the committed copy"); - // The guard's init() must be a SILENT no-op here: the binary runs normally - // even though socket-patch isn't on PATH and there is no .socket/manifest. - let out = Command::new(&app) - .current_dir(&outside) - .env_remove("SOCKET_PATCH_BIN") - .env("PATH", "/usr/bin:/bin") // ensure no socket-patch on PATH - .output() - .expect("run shipped binary"); - assert!( - out.status.success(), - "shipped binary outside the module tree must NOT be bricked by the guard:\nstdout:{}\nstderr:{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) + // A fresh `apply` re-materialises the copy and `go build` links PATCHED again. + let (code, _so, _se) = run_with_env( + &consumer, + &["apply", "--offline", "--ecosystems", "golang", "--cwd", cs], + &[("GOMODCACHE", mc)], ); + assert_eq!(code, 0, "re-apply should heal the drifted copy"); + let healed = go(&consumer, &["run", "."], &goenv); assert!( - String::from_utf8_lossy(&out.stdout).contains("OUT: PATCHED"), - "the binary should still run its (patched) code: {}", - String::from_utf8_lossy(&out.stdout) + String::from_utf8_lossy(&healed.stdout).contains("OUT: PATCHED"), + "re-apply should restore the patched bytes: {}", + String::from_utf8_lossy(&healed.stdout) ); + // Best-effort: relax perms so the temp cache cleans up. chmod_writable(tmp.path()); } diff --git a/crates/socket-patch-cli/tests/guard_build_integration.rs b/crates/socket-patch-cli/tests/guard_build_integration.rs index 74eae37..5545ebd 100644 --- a/crates/socket-patch-cli/tests/guard_build_integration.rs +++ b/crates/socket-patch-cli/tests/guard_build_integration.rs @@ -12,7 +12,7 @@ //! path dependency, so `cargo build --offline` needs no downloads. //! //! These shell out to a real `cargo build`, but — like the crate's other cargo -//! shell-out tests (`e2e_cargo.rs`, `docker_e2e_cargo.rs`, `setup_matrix_cargo.rs`) +//! shell-out tests (`e2e_cargo.rs`, `docker_e2e_cargo.rs`) //! — they run as part of the normal suite and self-skip via `has_command("cargo")` //! when the toolchain is absent, rather than being `#[ignore]`d (an `#[ignore]`d //! guard test protects nothing in CI). `#[cfg(unix)]` for the shell stub. diff --git a/crates/socket-patch-cli/tests/setup_cargo_invariants.rs b/crates/socket-patch-cli/tests/setup_cargo_invariants.rs deleted file mode 100644 index 624f9ff..0000000 --- a/crates/socket-patch-cli/tests/setup_cargo_invariants.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! Integration tests for `setup`'s cargo branch (the project-local -//! `[patch.crates-io]` redirect guard). Like the npm/python suites these run -//! entirely on disk — `setup` adds the `socket-patch-guard` dependency to each -//! workspace member's `Cargo.toml` and writes `[env] SOCKET_PATCH_ROOT` to the -//! workspace-root `.cargo/config.toml`. No network, no `cargo` invocation. -//! -//! Gated on the `cargo` feature (enabled by default): without it `setup` has no -//! cargo branch and these projects would report `no_files`. -#![cfg(feature = "cargo")] - -use std::collections::BTreeSet; -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} - -/// Every `SOCKET_*` var that steers `setup`; scrubbed from each child so -/// behaviour is decided by flags + on-disk fixtures alone (mirrors -/// setup_invariants.rs). The cargo backend additionally reads -/// `SOCKET_PATCH_ROOT` / `SOCKET_PATCH_BIN`, so those matter here especially. -const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_ECOSYSTEMS", - "SOCKET_OFFLINE", - "SOCKET_JSON", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_API_TOKEN", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_PATCH_ROOT", - "SOCKET_PATCH_BIN", - "SOCKET_PATCH_DEBUG", -]; - -/// Run `setup --json` with a scrubbed environment and telemetry disabled. -/// `home` is pointed at a sentinel dir so we can assert nothing is written -/// outside the repo. -fn run_setup_in(cwd: &Path, home: &Path, extra: &[&str]) -> (i32, serde_json::Value) { - let mut args = vec!["setup", "--json"]; - args.extend_from_slice(extra); - let mut cmd = Command::new(binary()); - cmd.args(&args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); - } - cmd.env("HOME", home); - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); - let out = cmd.output().expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - let v = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("stdout must be JSON ({e}):\n{stdout}")); - (out.status.code().unwrap_or(-1), v) -} - -fn write(path: &Path, content: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::write(path, content).expect("write file"); -} - -fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read file") -} - -fn files_under(dir: &Path) -> BTreeSet { - fn walk(base: &Path, dir: &Path, out: &mut BTreeSet) { - if let Ok(rd) = std::fs::read_dir(dir) { - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - walk(base, &p, out); - } else { - // Normalize to forward slashes so relative-path keys are - // platform-stable: on Windows `strip_prefix` yields - // `.cargo\config.toml`, but the assertions compare against - // forward-slash literals like `.cargo/config.toml`. - out.insert( - p.strip_prefix(base) - .unwrap() - .to_string_lossy() - .replace(std::path::MAIN_SEPARATOR, "/"), - ); - } - } - } - } - let mut out = BTreeSet::new(); - walk(dir, dir, &mut out); - out -} - -const SINGLE_CRATE: &str = - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nserde = \"1\"\n"; - -// --------------------------------------------------------------------------- -// Property 5 — in-repo and committable. The cargo branch writes the guard dep -// into the in-repo Cargo.toml and `[env] SOCKET_PATCH_ROOT` into the in-repo -// `.cargo/config.toml`; it must not touch `$HOME` (notably never `~/.cargo`). -// (CLI_CONTRACT.md → "Setup command contract", property 5.) -// --------------------------------------------------------------------------- - -#[test] -fn setup_cargo_writes_only_inside_repo() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - write(&proj.path().join("Cargo.toml"), SINGLE_CRATE); - assert!(files_under(home.path()).is_empty(), "sentinel HOME must start empty"); - - let (code, v) = run_setup_in(proj.path(), home.path(), &["--yes"]); - assert_eq!(code, 0, "cargo setup should succeed: {v}"); - assert_eq!(v["status"], "success"); - - // Nothing written outside the repo (in particular, no ~/.cargo/config.toml). - assert!( - files_under(home.path()).is_empty(), - "cargo setup must not write outside --cwd; HOME gained: {:?}", - files_under(home.path()) - ); - // The guard dep + the workspace-root [env] both landed inside the repo. - assert!( - read(&proj.path().join("Cargo.toml")).contains("socket-patch-guard"), - "Cargo.toml must gain the guard dependency" - ); - let config = read(&proj.path().join(".cargo/config.toml")); - assert!( - config.contains("SOCKET_PATCH_ROOT"), - ".cargo/config.toml must declare [env] SOCKET_PATCH_ROOT; got:\n{config}" - ); - // All new files are under the repo tree. - let repo_files = files_under(proj.path()); - assert!(repo_files.contains("Cargo.toml")); - assert!(repo_files.contains(".cargo/config.toml")); -} - -// --------------------------------------------------------------------------- -// Property 8 — graceful remove restores the per-member Cargo.toml byte-for-byte -// (the guard dependency is the only edit). NB: the `.cargo/config.toml` that -// setup creates is NOT fully cleaned up on remove today — that residue is -// guarded separately as a RED pin in setup_contract_gaps.rs. -// (CLI_CONTRACT.md → "Setup command contract", property 8.) -// --------------------------------------------------------------------------- - -#[test] -fn setup_cargo_remove_round_trips_cargo_toml() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - let manifest = proj.path().join("Cargo.toml"); - write(&manifest, SINGLE_CRATE); - - let (c1, _) = run_setup_in(proj.path(), home.path(), &["--yes"]); - assert_eq!(c1, 0); - assert!( - read(&manifest).contains("socket-patch-guard"), - "precondition: setup added the guard dep" - ); - - let (code, v) = run_setup_in(proj.path(), home.path(), &["--remove", "--yes"]); - assert_eq!(code, 0, "remove should succeed: {v}"); - assert_eq!(v["status"], "success"); - - // The member manifest is restored to its exact pre-setup bytes. - assert_eq!( - read(&manifest), - SINGLE_CRATE, - "remove must restore Cargo.toml byte-for-byte" - ); - // And the [env] key is gone, so the project no longer registers as set up. - let (cc, cv) = run_setup_in(proj.path(), home.path(), &["--check"]); - assert_eq!(cc, 1, "after remove, --check must fail again: {cv}"); - assert_eq!(cv["status"], "needs_configuration"); -} - -// --------------------------------------------------------------------------- -// Property 9 (base case) — nested workspaces. Every cargo workspace member gets -// the guard dependency and a single workspace-root [env] is written. -// (CLI_CONTRACT.md → "Setup command contract", property 9.) -// --------------------------------------------------------------------------- - -#[test] -fn setup_cargo_configures_workspace_members() { - let tmp = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - write( - &tmp.path().join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/*\"]\nresolver = \"2\"\n", - ); - let member = "[package]\nname = \"NAME\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n"; - write( - &tmp.path().join("crates/a/Cargo.toml"), - &member.replace("NAME", "a"), - ); - write( - &tmp.path().join("crates/b/Cargo.toml"), - &member.replace("NAME", "b"), - ); - - let (code, v) = run_setup_in(tmp.path(), home.path(), &["--yes"]); - assert_eq!(code, 0, "workspace setup should succeed: {v}"); - assert_eq!(v["status"], "success"); - // Two members + the one workspace-root [env] entry. - assert_eq!( - v["updated"], 3, - "both members + the root [env] must be configured: {v}" - ); - - for m in ["crates/a/Cargo.toml", "crates/b/Cargo.toml"] { - assert!( - read(&tmp.path().join(m)).contains("socket-patch-guard"), - "workspace member {m} must gain the guard dependency" - ); - } - // Exactly one [env] config, at the workspace root. - let config = read(&tmp.path().join(".cargo/config.toml")); - assert!(config.contains("SOCKET_PATCH_ROOT"), "root [env] must be written"); - - // The cargo_env entry must be reported exactly once. - let env_entries = v["files"] - .as_array() - .unwrap() - .iter() - .filter(|f| f["kind"] == "cargo_env") - .count(); - assert_eq!(env_entries, 1, "exactly one cargo_env entry: {v}"); -} - -// --------------------------------------------------------------------------- -// Release-flow guard. `setup` MUST write the guard as a bare crates.io version -// spec (`socket-patch-guard = ""`) — never a `path =`/`git =` -// table. Production resolves that version from crates.io, so the crate has to be -// published every release (release.yml's cargo-publish job). The other cargo -// tests in this repo wire the guard via a local `path =` dep, which would mask a -// regression that swapped the production spec to a path/git form (and hide the -// publish requirement entirely — exactly how the unpublished-guard breakage went -// unnoticed). This pins the published-version contract on the real `setup` path. -// --------------------------------------------------------------------------- - -#[test] -fn setup_writes_guard_as_publishable_crates_io_version() { - // Mirrors `guard_version()` in commands/setup.rs: the major.minor of the - // crate version, which is the workspace version that gets published. - let full = env!("CARGO_PKG_VERSION"); - let mut parts = full.split('.'); - let expected = match (parts.next(), parts.next()) { - (Some(major), Some(minor)) => format!("{major}.{minor}"), - _ => full.to_string(), - }; - - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - let manifest = proj.path().join("Cargo.toml"); - write(&manifest, SINGLE_CRATE); - - let (code, v) = run_setup_in(proj.path(), home.path(), &["--yes"]); - assert_eq!(code, 0, "cargo setup should succeed: {v}"); - - let toml = read(&manifest); - // Bare-version form, pinned to the crate's major.minor so the published - // `socket-patch-guard` resolves the `^{major.minor}` spec. - assert!( - toml.contains(&format!("socket-patch-guard = \"{expected}\"")), - "guard must be a bare crates.io version `socket-patch-guard = \"{expected}\"` \ - (publishable form); got Cargo.toml:\n{toml}" - ); - // Never a path/git table — that would 404-mask the publish requirement. - assert!( - !toml.contains("socket-patch-guard = {"), - "guard must NOT be a path/git table dependency (the crate must resolve \ - from crates.io); got Cargo.toml:\n{toml}" - ); -} diff --git a/crates/socket-patch-cli/tests/setup_cargo_roundtrip.rs b/crates/socket-patch-cli/tests/setup_cargo_roundtrip.rs deleted file mode 100644 index 49ce8d6..0000000 --- a/crates/socket-patch-cli/tests/setup_cargo_roundtrip.rs +++ /dev/null @@ -1,345 +0,0 @@ -#![cfg(feature = "cargo")] -//! `socket-patch setup` round-trip for the cargo guard, driven through the CLI -//! binary (no Docker, no network, no real `cargo`). -//! -//! Covers, across a 2-member workspace: -//! * `setup` adds `socket-patch-guard` to every member's `[dependencies]` and -//! writes `[env] SOCKET_PATCH_ROOT` into `.cargo/config.toml`; -//! * a member's pre-existing user `build.rs` is left **byte-for-byte -//! unchanged** (the regression the dedicated guard crate buys us); -//! * `setup --check` exits 0 when configured; -//! * `setup --remove` reverts the dep + `[env]`; -//! * `setup --check` then exits non-zero. - -use std::path::Path; -use std::process::Command; - -#[path = "common/mod.rs"] -mod common; - -const USER_BUILD_RS: &str = "fn main() {\n println!(\"cargo:rerun-if-changed=build.rs\");\n}\n"; - -/// Run the CLI binary with `args` in `cwd`, scrubbing **all** ambient -/// `SOCKET_*` env vars from the child. The shared `common::run` only strips -/// `SOCKET_API_TOKEN`; setup/check resolve discovery roots and offline gates -/// from the environment, so an ambient `SOCKET_*` could otherwise satisfy a -/// flag-driven assertion via the environment and mask a regression. This keeps -/// the round-trip flag-driven and parallel-safe. -fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(common::binary()); - cmd.args(args).current_dir(cwd); - for (k, _) in std::env::vars() { - if k.starts_with("SOCKET_") { - cmd.env_remove(k); - } - } - let out = cmd.output().expect("failed to execute socket-patch binary"); - let code = out.status.code().unwrap_or(-1); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - (code, stdout, stderr) -} - -/// Run `setup --check --json` and return `(exit_code, parsed_envelope)`. -/// Asserting on the JSON (not just the exit code) closes two holes in an -/// exit-code-only check: -/// * exit 0 is ALSO returned by `report_no_files` when discovery finds -/// nothing — so a broken cargo discovery would make "--check passes after -/// setup" pass vacuously; -/// * exit 1 conflates `needs_configuration` with `error` (a parse failure), -/// so a check that errored instead of reporting "needs setup" would still -/// look like the expected before/after-remove state. -fn check_json(cwd: &Path, root_s: &str) -> (i32, serde_json::Value) { - let (code, stdout, stderr) = run(cwd, &["setup", "--check", "--json", "--cwd", root_s]); - let env: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { - panic!("setup --check --json did not emit parseable JSON: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") - }); - (code, env) -} - -/// Extract the per-member cargo check states and the `[env]` state from a -/// `setup --check --json` envelope, asserting the workspace shape we staged -/// (exactly two `cargo` member entries + one `cargo_env` entry, and NOTHING -/// else — no stray npm/pth entries leaking in). Returns -/// `(member_statuses, env_status)`. -fn cargo_check_states(env: &serde_json::Value) -> (Vec, String) { - let files = env - .get("files") - .and_then(|f| f.as_array()) - .unwrap_or_else(|| panic!("check envelope has no `files` array:\n{env}")); - let mut members = Vec::new(); - let mut env_status: Option = None; - for f in files { - let kind = f - .get("kind") - .and_then(|k| k.as_str()) - .unwrap_or_else(|| panic!("check entry missing string `kind`:\n{f}")); - let status = f - .get("status") - .and_then(|s| s.as_str()) - .unwrap_or_else(|| panic!("check entry missing string `status`:\n{f}")) - .to_string(); - match kind { - "cargo" => members.push(status), - "cargo_env" => { - assert!( - env_status.replace(status).is_none(), - "more than one cargo_env entry in check envelope:\n{env}" - ); - } - other => panic!( - "unexpected check entry kind {other:?} (only cargo/cargo_env expected for a \ - pure-cargo workspace):\n{env}" - ), - } - } - assert_eq!( - members.len(), - 2, - "expected exactly two cargo member check entries (crates/a, crates/b):\n{env}" - ); - let env_status = - env_status.unwrap_or_else(|| panic!("no cargo_env check entry:\n{env}")); - (members, env_status) -} - -fn stage_workspace(root: &Path) { - std::fs::create_dir_all(root.join("crates/a/src")).unwrap(); - std::fs::create_dir_all(root.join("crates/b/src")).unwrap(); - std::fs::write( - root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/*\"]\nresolver = \"2\"\n", - ) - .unwrap(); - std::fs::write( - root.join("crates/a/Cargo.toml"), - "[package]\nname = \"a\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", - ) - .unwrap(); - std::fs::write( - root.join("crates/b/Cargo.toml"), - "[package]\nname = \"b\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", - ) - .unwrap(); - std::fs::write(root.join("crates/a/src/main.rs"), "fn main() {}\n").unwrap(); - std::fs::write(root.join("crates/b/src/lib.rs"), "\n").unwrap(); - // A user-authored build.rs that setup must NOT touch. - std::fs::write(root.join("crates/a/build.rs"), USER_BUILD_RS).unwrap(); -} - -// ── independent (dependency-free) TOML probes ───────────────────────────── -// -// These deliberately do NOT use the production `toml_edit`/`cargo_config` -// parsers — those are the very code paths under test, so reusing them would -// make the oracle circular. A minimal hand-rolled scan keeps the test honest: -// it can disagree with a broken writer. - -/// Return the trimmed right-hand side of `key = ` inside the `[section]` -/// table of `doc`, scanning only until the next table header. `None` if the -/// section or key is absent. Top-level keys use `section = ""`. -fn toml_value_in_section(doc: &str, section: &str, key: &str) -> Option { - let header = format!("[{section}]"); - // `section == ""` means top-level (before any header). - let mut in_section = section.is_empty(); - for line in doc.lines() { - let t = line.trim(); - if t.starts_with('#') || t.is_empty() { - continue; - } - if t.starts_with('[') { - in_section = t == header; - continue; - } - if in_section { - if let Some((k, v)) = t.split_once('=') { - if k.trim() == key { - return Some(v.trim().to_string()); - } - } - } - } - None -} - -/// Assert the guard dep is a real `[dependencies].socket-patch-guard` entry -/// carrying a plausible `"."` version string — not merely a -/// substring lurking in a comment or the wrong table. -fn assert_guard_dep_versioned(toml: &str, who: &str) { - let rhs = toml_value_in_section(toml, "dependencies", "socket-patch-guard") - .unwrap_or_else(|| panic!("no [dependencies].socket-patch-guard in {who}:\n{toml}")); - // A bare version string is double-quoted; reject table/path forms that - // would mean setup wrote something other than a published version pin. - let inner = rhs - .strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - .unwrap_or_else(|| { - panic!("guard dep in {who} is not a quoted version string: {rhs}\n{toml}") - }); - let parts: Vec<&str> = inner.split('.').collect(); - assert!( - parts.len() >= 2 && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())), - "guard dep version in {who} is not a numeric major.minor: {inner:?}\n{toml}" - ); -} - -#[test] -fn setup_check_remove_check_roundtrip() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - stage_workspace(root); - let root_s = root.to_str().unwrap(); - - // ── check (before setup) ──────────────────────────────────────── - // A pristine workspace is unconfigured: `--check` must report that, - // proving the check reads real state rather than hardcoding 0. We assert - // on the JSON so exit 1 can't be satisfied by an *error* (parse failure) - // or by "no files found" instead of the genuine "needs configuration". - let (code, env) = check_json(root, root_s); - assert_eq!(code, 1, "setup --check should fail before setup"); - assert_eq!( - env.get("status").and_then(|s| s.as_str()), - Some("needs_configuration"), - "pristine workspace must report needs_configuration, not error/no_files:\n{env}" - ); - assert_eq!( - env.get("errors").and_then(|e| e.as_u64()), - Some(0), - "pristine check must have zero parse errors:\n{env}" - ); - let (members, env_state) = cargo_check_states(&env); - assert!( - members.iter().all(|s| s == "needs_configuration"), - "both members must report needs_configuration before setup, got {members:?}\n{env}" - ); - assert_eq!( - env_state, "needs_configuration", - "[env] must report needs_configuration before setup:\n{env}" - ); - - // ── setup ─────────────────────────────────────────────────────── - let (code, stdout, stderr) = run(root, &["setup", "--cwd", root_s, "--yes"]); - assert_eq!( - code, 0, - "setup failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); - - let a_toml = std::fs::read_to_string(root.join("crates/a/Cargo.toml")).unwrap(); - let b_toml = std::fs::read_to_string(root.join("crates/b/Cargo.toml")).unwrap(); - // Guard must be a real, version-pinned [dependencies] entry in BOTH - // members (b started with no [dependencies] table at all, so this also - // proves setup created the table correctly). - assert_guard_dep_versioned(&a_toml, "crates/a/Cargo.toml"); - assert_guard_dep_versioned(&b_toml, "crates/b/Cargo.toml"); - - let config = std::fs::read_to_string(root.join(".cargo/config.toml")).unwrap(); - // The [env] entry must carry the exact relative-root spec the build-time - // guard relies on (`{ value = ".", relative = true }`) — not just the key - // name with an arbitrary/empty/absolute value. - let env_rhs = toml_value_in_section(&config, "env", "SOCKET_PATCH_ROOT") - .unwrap_or_else(|| panic!("[env] SOCKET_PATCH_ROOT missing:\n{config}")); - let normalized: String = env_rhs.split_whitespace().collect::>().join(" "); - assert_eq!( - normalized, r#"{ value = ".", relative = true }"#, - "[env] SOCKET_PATCH_ROOT must be the relative project-root spec, got: {env_rhs}\n{config}" - ); - - // The user's build.rs is untouched, byte-for-byte. - assert_eq!( - std::fs::read_to_string(root.join("crates/a/build.rs")).unwrap(), - USER_BUILD_RS, - "setup must never modify a user's build.rs" - ); - - // ── check (configured) ────────────────────────────────────────── - // Exit 0 alone is ambiguous (`report_no_files` also returns 0); assert the - // envelope proves every cargo entry — both members AND the [env] — is - // independently reported `configured`, with no errors. - let (code, env) = check_json(root, root_s); - assert_eq!(code, 0, "setup --check should pass after setup"); - assert_eq!( - env.get("status").and_then(|s| s.as_str()), - Some("configured"), - "configured workspace must report status=configured (not no_files):\n{env}" - ); - assert_eq!( - env.get("needsConfiguration").and_then(|n| n.as_u64()), - Some(0), - "no entry should still need configuration after setup:\n{env}" - ); - assert_eq!( - env.get("errors").and_then(|e| e.as_u64()), - Some(0), - "configured check must have zero errors:\n{env}" - ); - assert_eq!( - env.get("configured").and_then(|c| c.as_u64()), - Some(3), - "all three cargo entries (2 members + [env]) must be configured:\n{env}" - ); - let (members, env_state) = cargo_check_states(&env); - assert!( - members.iter().all(|s| s == "configured"), - "both members must report configured after setup, got {members:?}\n{env}" - ); - assert_eq!( - env_state, "configured", - "[env] must report configured after setup:\n{env}" - ); - - // ── remove ────────────────────────────────────────────────────── - let (code, stdout, stderr) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes"]); - assert_eq!( - code, 0, - "setup --remove failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); - let a_toml = std::fs::read_to_string(root.join("crates/a/Cargo.toml")).unwrap(); - let b_toml = std::fs::read_to_string(root.join("crates/b/Cargo.toml")).unwrap(); - assert!( - toml_value_in_section(&a_toml, "dependencies", "socket-patch-guard").is_none() - && !a_toml.contains("socket-patch-guard"), - "guard dep should be removed from a:\n{a_toml}" - ); - assert!( - toml_value_in_section(&b_toml, "dependencies", "socket-patch-guard").is_none() - && !b_toml.contains("socket-patch-guard"), - "guard dep should be removed from b:\n{b_toml}" - ); - let config = std::fs::read_to_string(root.join(".cargo/config.toml")).unwrap_or_default(); - assert!( - toml_value_in_section(&config, "env", "SOCKET_PATCH_ROOT").is_none() - && !config.contains("SOCKET_PATCH_ROOT"), - "[env] root should be removed:\n{config}" - ); - - // build.rs still untouched after remove. - assert_eq!( - std::fs::read_to_string(root.join("crates/a/build.rs")).unwrap(), - USER_BUILD_RS, - "setup --remove must never modify a user's build.rs" - ); - - // ── check (needs configuration) ───────────────────────────────── - // After remove we must be back to the genuine needs_configuration state — - // not an error, and not no_files (which would also exit non-1 / 0). - let (code, env) = check_json(root, root_s); - assert_eq!(code, 1, "setup --check should fail after remove"); - assert_eq!( - env.get("status").and_then(|s| s.as_str()), - Some("needs_configuration"), - "after remove the workspace must report needs_configuration again:\n{env}" - ); - assert_eq!( - env.get("errors").and_then(|e| e.as_u64()), - Some(0), - "post-remove check must have zero parse errors:\n{env}" - ); - let (members, env_state) = cargo_check_states(&env); - assert!( - members.iter().all(|s| s == "needs_configuration"), - "both members must report needs_configuration after remove, got {members:?}\n{env}" - ); - assert_eq!( - env_state, "needs_configuration", - "[env] must report needs_configuration after remove:\n{env}" - ); -} diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index cf973d4..d69b87d 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -250,48 +250,6 @@ fn vex_omits_patches_for_unconfigured_ecosystem() { ); } -// =========================================================================== -// Property 8 (residue) — graceful, exact remove. A `.cargo/config.toml` that -// `setup` *created* should be cleaned up on `--remove`, restoring the exact -// pre-setup tree. -// -// SHIPPED: `edit_config` (cargo_config.rs) now deletes an emptied socket-created -// `.cargo/config.toml` and prunes the now-empty `.cargo/` dir, so a repo that had -// no `.cargo/` before setup is restored exactly. This pin is now an active -// (non-ignored) regression guard. -// =========================================================================== - -#[cfg(feature = "cargo")] -#[test] -fn setup_remove_cleans_up_cargo_config_it_created() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - write( - &proj.path().join("Cargo.toml"), - "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nserde = \"1\"\n", - ); - // Precondition: no .cargo/ before setup. - assert!(!proj.path().join(".cargo").exists()); - - let (c1, _) = run(proj.path(), home.path(), &["setup", "--json", "--yes"]); - assert_eq!(c1, 0); - assert!( - proj.path().join(".cargo/config.toml").exists(), - "precondition: setup created .cargo/config.toml" - ); - - let (c2, _) = run(proj.path(), home.path(), &["setup", "--remove", "--json", "--yes"]); - assert_eq!(c2, 0); - - // Exact restoration: the .cargo/config.toml setup created must be gone, not - // lingering empty. - assert!( - !proj.path().join(".cargo/config.toml").exists(), - "remove must delete the .cargo/config.toml it created, restoring the exact \ - pre-setup tree (property 8); an empty file is being left behind" - ); -} - // =========================================================================== // Property 9 (exclude) — SHIPPED. `setup --exclude ` skips that member // and PERSISTS the exclusion under `.socket/manifest.json`'s `setup.exclude`, so diff --git a/crates/socket-patch-cli/tests/setup_go_roundtrip.rs b/crates/socket-patch-cli/tests/setup_go_roundtrip.rs deleted file mode 100644 index cbf824e..0000000 --- a/crates/socket-patch-cli/tests/setup_go_roundtrip.rs +++ /dev/null @@ -1,135 +0,0 @@ -#![cfg(feature = "golang")] -//! `socket-patch setup` round-trip for the Go fail-closed guard, driven through -//! the CLI binary (no Docker, no network, no `go` toolchain — `setup` with no -//! manifest materialises nothing, so this exercises pure guard wiring). -//! -//! Covers: -//! * `setup` writes `internal/socketpatchguard/{guard.go,guard_test.go}` and a -//! generated `socket_patch_guard_import.go` in every `package main` dir -//! (and ONLY there); -//! * a user file at the generated import name is left byte-for-byte untouched; -//! * `setup --check` exits 0 when configured; -//! * `setup --remove` deletes the guard package + generated imports (pruning -//! `internal/`), sparing the user file; -//! * `setup --check` then exits non-zero. - -use std::path::Path; - -#[path = "common/mod.rs"] -mod common; - -use common::run; - -const USER_IMPORT_FILE: &str = "package main\n\n// hand-written, not ours\n"; - -fn stage_module(root: &Path) { - std::fs::create_dir_all(root.join("cmd/app")).unwrap(); - std::fs::create_dir_all(root.join("internal/lib")).unwrap(); - std::fs::write( - root.join("go.mod"), - "module example.com/app\n\ngo 1.21\n", - ) - .unwrap(); - // A main package (gets the blank import). - std::fs::write( - root.join("cmd/app/main.go"), - "package main\n\nfunc main() {}\n", - ) - .unwrap(); - // A library package (must NOT get the blank import). - std::fs::write(root.join("internal/lib/lib.go"), "package lib\n").unwrap(); -} - -#[test] -fn setup_check_remove_check_roundtrip() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - stage_module(root); - let root_s = root.to_str().unwrap(); - - let guard_go = root.join("internal/socketpatchguard/guard.go"); - let guard_test = root.join("internal/socketpatchguard/guard_test.go"); - let app_import = root.join("cmd/app/socket_patch_guard_import.go"); - let lib_import = root.join("internal/lib/socket_patch_guard_import.go"); - - // ── setup ─────────────────────────────────────────────────────── - let (code, stdout, stderr) = run(root, &["setup", "--cwd", root_s, "--yes"]); - assert_eq!(code, 0, "setup failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); - - // Guard package written with the right package clause + delegating logic. - let guard_src = std::fs::read_to_string(&guard_go).unwrap(); - assert!( - guard_src.contains("package socketpatchguard") && guard_src.contains("func init()"), - "guard.go missing/!right:\n{guard_src}" - ); - assert!( - std::fs::read_to_string(&guard_test) - .unwrap() - .contains("func TestSocketPatchesApplied"), - "guard_test.go missing the test" - ); - - // Blank import ONLY in the main package dir. - let import_src = std::fs::read_to_string(&app_import).unwrap(); - assert!( - import_src.contains("import _ \"example.com/app/internal/socketpatchguard\""), - "main blank import missing/wrong:\n{import_src}" - ); - assert!( - !lib_import.exists(), - "a non-main package must NOT get the blank import" - ); - - // ── check (configured) ────────────────────────────────────────── - let (code, o, e) = run(root, &["setup", "--check", "--cwd", root_s]); - assert_eq!(code, 0, "setup --check should pass after setup.\n{o}\n{e}"); - - // ── remove ────────────────────────────────────────────────────── - let (code, stdout, stderr) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes"]); - assert_eq!( - code, 0, - "setup --remove failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); - assert!(!guard_go.exists() && !guard_test.exists(), "guard files should be gone"); - assert!(!app_import.exists(), "generated import should be gone"); - assert!( - !root.join("internal/socketpatchguard").exists(), - "empty guard dir should be pruned" - ); - - // ── check (needs configuration) ───────────────────────────────── - let (code, _o, _e) = run(root, &["setup", "--check", "--cwd", root_s]); - assert_eq!(code, 1, "setup --check should fail after remove"); -} - -#[test] -fn remove_spares_user_authored_import_file() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - stage_module(root); - let root_s = root.to_str().unwrap(); - - // A user file at the generated name, WITHOUT our marker, in the main dir. - let app_import = root.join("cmd/app/socket_patch_guard_import.go"); - std::fs::write(&app_import, USER_IMPORT_FILE).unwrap(); - - // setup must refuse to clobber it (its content differs from ours, but it is - // at the generated path) — add_main_imports overwrites only if content - // differs; since it differs, setup WILL overwrite to install the guard. - // Then remove must only delete OUR (marker-bearing) file. - run(root, &["setup", "--cwd", root_s, "--yes"]); - // After setup the file now carries our marker (we own that path), so this is - // the documented behaviour: the generated import path is socket-owned. - assert!(std::fs::read_to_string(&app_import) - .unwrap() - .contains("internal/socketpatchguard")); - - // Restore a user file (no marker) to prove remove spares it. - std::fs::write(&app_import, USER_IMPORT_FILE).unwrap(); - run(root, &["setup", "--remove", "--cwd", root_s, "--yes"]); - assert_eq!( - std::fs::read_to_string(&app_import).unwrap(), - USER_IMPORT_FILE, - "remove must not delete a non-marker user file at the generated path" - ); -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_cargo.rs b/crates/socket-patch-cli/tests/setup_matrix_cargo.rs deleted file mode 100644 index 1676439..0000000 --- a/crates/socket-patch-cli/tests/setup_matrix_cargo.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! setup-matrix: cargo ecosystem. -//! -//! This Docker-based matrix exercises the *install → apply → patched-file-on-disk* -//! flow. Cargo's local backend redirects to a project-local **copy** via -//! `[patch.crates-io]` rather than patching the installed crate in place, and -//! the patch is consumed at `cargo build` resolution time (by the -//! `socket-patch-guard` build script), so there is no in-place file mutation -//! for this harness to observe — the with-setup cases remain an EXPECTED -//! BASELINE GAP *here*. The real cargo `setup`/`apply`/`rollback`/`--check` -//! behaviour is covered by the dedicated, non-Docker suites: -//! * `setup_cargo_roundtrip.rs` — setup → check → remove → check + user -//! `build.rs` untouched; -//! * `e2e_cargo_coexist.rs` — apply redirect + registry isolation, reconcile, -//! rollback, self-heal, and `--check` drift detection. -//! -//! IMPORTANT — why this file carries a real assertion of its own: -//! `smc::run_pm("cargo", "cargo")` routes cargo through the shared Docker -//! matrix harness, which (a) *soft-skips and silently passes* whenever Docker -//! or the `cargo` image is absent (the common case locally and in this eval), -//! and (b) when it DOES run, it models "applied" as an in-place file mutation — -//! which cargo's redirect backend never performs — so every with-setup cargo -//! case is classified as a non-fatal `BASELINE GAP`. The net effect is that the -//! matrix call can *never* turn red for a genuine cargo `setup` regression: it -//! is either skipped (green) or it fails as a documented gap (also tolerated by -//! the non-blocking suite). On its own it protects nothing. -//! -//! To close that loophole WITHOUT touching the shared harness, this file adds -//! [`cargo_setup_roundtrip_host`]: a self-contained, host-only (no Docker, no -//! network, no real `cargo` toolchain) exercise of the actual `socket-patch` -//! binary against a real cargo project. It runs unconditionally and fails -//! loudly if cargo `setup` / `setup --check` / `setup --remove` regress. It -//! deliberately checks state with an *independent* hand-rolled TOML probe (not -//! the production parser) so the oracle can disagree with a broken writer. -//! -//! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_cargo` -#![cfg(feature = "setup-e2e")] - -#[path = "setup_matrix_common/mod.rs"] -mod smc; - -/// Documentation/negative-control pass through the shared Docker matrix. -/// Kept for parity with the other ecosystems and to run the cargo negative -/// controls when Docker + the `cargo` image are present. NOTE: this is the -/// path that silently no-ops on skip — it is NOT a regression guard. The real -/// teeth live in [`cargo_setup_roundtrip_host`] below. -#[test] -fn cargo() { - smc::run_pm("cargo", "cargo"); -} - -// ───────────────────────────────────────────────────────────────────────── -// Real, non-skippable regression guard for cargo `setup`. -// -// Only meaningful when the binary was built with the `cargo` feature (the -// default). Under `--no-default-features` the binary's cargo `setup` fails -// closed, so the assertion is intentionally compiled out there. -// ───────────────────────────────────────────────────────────────────────── -#[cfg(feature = "cargo")] -mod host_guard { - use std::path::Path; - use std::process::Command; - - const USER_BUILD_RS: &str = "fn main() {\n println!(\"cargo:rerun-if-changed=build.rs\");\n}\n"; - - /// Every `SOCKET_*` env var clap consults for the surface this test drives. - /// They are stripped from the child so the run reflects ONLY the explicit - /// flags (`--cwd`, `--yes`, `--check`, `--remove`). Without this, an ambient - /// `SOCKET_CWD` / `SOCKET_YES` / `SOCKET_OFFLINE` in the shell or CI could - /// satisfy an assertion via the environment rather than the flag under test - /// — masking a regression in flag wiring. (Mirrors the scrub used by the - /// `cli_parse_*` suites.) - const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_SAVE_ONLY", - "SOCKET_ONE_OFF", - "SOCKET_ALL_RELEASES", - // cargo redirect-backend specific knobs. - "SOCKET_PATCH_ROOT", - "SOCKET_PATCH_GUARD", - ]; - - /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. - fn binary() -> std::path::PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() - } - - /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The entire `SOCKET_*` surface is stripped so behaviour reflects the - /// explicit flags alone (see [`SOCKET_ENV_VARS`]) — nothing reaches authed - /// endpoints and no ambient var can stand in for a flag. - fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); - } - let out = cmd - .output() - .expect("failed to execute socket-patch binary"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) - } - - fn stage_single_crate(root: &Path) { - std::fs::create_dir_all(root.join("src")).unwrap(); - std::fs::write( - root.join("Cargo.toml"), - "[package]\nname = \"sm-cargo-proj\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ncfg-if = \"=1.0.0\"\n", - ) - .unwrap(); - std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); - // A user-authored build.rs that setup must NEVER rewrite (the - // regression the dedicated guard crate buys us). - std::fs::write(root.join("build.rs"), USER_BUILD_RS).unwrap(); - } - - // ── independent (dependency-free) TOML probe ────────────────────────── - // - // Deliberately does NOT use the production `toml_edit` parser — that is the - // very code path under test, so reusing it would make the oracle circular. - // A minimal hand-rolled scan keeps the test honest: it can disagree with a - // broken writer. - // - /// Right-hand side of `key = ` inside the `[section]` table of `doc`, - /// scanning only until the next table header. `None` if absent. Top-level - /// keys use `section == ""`. - fn toml_value_in_section(doc: &str, section: &str, key: &str) -> Option { - let header = format!("[{section}]"); - let mut in_section = section.is_empty(); - for line in doc.lines() { - let t = line.trim(); - if t.starts_with('#') || t.is_empty() { - continue; - } - if t.starts_with('[') { - in_section = t == header; - continue; - } - if in_section { - if let Some((k, v)) = t.split_once('=') { - if k.trim() == key { - return Some(v.trim().to_string()); - } - } - } - } - None - } - - /// Assert the guard dep is a real `[dependencies].socket-patch-guard` entry - /// carrying a plausible quoted `"."` version — not a substring - /// in a comment, nor a path/table form, nor an empty value. - fn assert_guard_dep_versioned(toml: &str, who: &str) { - let rhs = toml_value_in_section(toml, "dependencies", "socket-patch-guard") - .unwrap_or_else(|| panic!("no [dependencies].socket-patch-guard in {who}:\n{toml}")); - let inner = rhs - .strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - .unwrap_or_else(|| { - panic!("guard dep in {who} is not a quoted version string: {rhs}\n{toml}") - }); - let parts: Vec<&str> = inner.split('.').collect(); - assert!( - parts.len() >= 2 - && parts - .iter() - .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())), - "guard dep version in {who} is not a numeric major.minor: {inner:?}\n{toml}" - ); - } - - /// setup → check → remove → check, asserting REAL on-disk state at every - /// stage. This is the assertion the Docker matrix can never make for cargo. - #[test] - fn cargo_setup_roundtrip_host() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - stage_single_crate(root); - let root_s = root.to_str().unwrap(); - - // ── pristine precondition ────────────────────────────────────────── - // Pin the BEFORE state so the post-setup assertions genuinely prove - // that `setup` *created* the redirect config — not that a leftover - // fixture happened to already contain it. - let pristine_toml = std::fs::read_to_string(root.join("Cargo.toml")).unwrap(); - assert!( - toml_value_in_section(&pristine_toml, "dependencies", "socket-patch-guard").is_none() - && !pristine_toml.contains("socket-patch-guard"), - "fixture must start WITHOUT the guard dep:\n{pristine_toml}" - ); - assert!( - !root.join(".cargo/config.toml").exists(), - ".cargo/config.toml must not exist before setup" - ); - - // ── check (before setup): unconfigured → must report non-zero ────── - // Proves `--check` reads real state instead of hardcoding success. - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s]); - assert_eq!( - code, 1, - "setup --check must FAIL (exit 1) on a pristine, unconfigured project.\nstdout:\n{out}\nstderr:\n{err}" - ); - - // ── setup ────────────────────────────────────────────────────────── - let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes"]); - assert_eq!(code, 0, "setup must succeed.\nstdout:\n{out}\nstderr:\n{err}"); - - let toml = std::fs::read_to_string(root.join("Cargo.toml")).unwrap(); - assert_guard_dep_versioned(&toml, "Cargo.toml"); - - // The redirect backend hinges on this exact relative-root [env] spec; - // a key with an empty/absolute/non-relative value would silently break - // build-time resolution, so pin it precisely. - let config = std::fs::read_to_string(root.join(".cargo/config.toml")) - .unwrap_or_else(|e| panic!(".cargo/config.toml must exist after setup: {e}")); - let env_rhs = toml_value_in_section(&config, "env", "SOCKET_PATCH_ROOT") - .unwrap_or_else(|| panic!("[env] SOCKET_PATCH_ROOT missing:\n{config}")); - let normalized: String = env_rhs.split_whitespace().collect::>().join(" "); - assert_eq!( - normalized, - r#"{ value = ".", relative = true }"#, - "[env] SOCKET_PATCH_ROOT must be the relative project-root spec, got: {env_rhs}\n{config}" - ); - - // The user's build.rs is untouched, byte-for-byte. - assert_eq!( - std::fs::read_to_string(root.join("build.rs")).unwrap(), - USER_BUILD_RS, - "setup must never modify a user's build.rs" - ); - - // ── check (configured): must report zero ─────────────────────────── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s]); - assert_eq!( - code, 0, - "setup --check must PASS (exit 0) after setup.\nstdout:\n{out}\nstderr:\n{err}" - ); - - // ── remove ────────────────────────────────────────────────────────── - let (code, out, err) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes"]); - assert_eq!(code, 0, "setup --remove must succeed.\nstdout:\n{out}\nstderr:\n{err}"); - - let toml = std::fs::read_to_string(root.join("Cargo.toml")).unwrap(); - assert!( - toml_value_in_section(&toml, "dependencies", "socket-patch-guard").is_none() - && !toml.contains("socket-patch-guard"), - "guard dep must be removed from Cargo.toml:\n{toml}" - ); - let config = std::fs::read_to_string(root.join(".cargo/config.toml")).unwrap_or_default(); - assert!( - toml_value_in_section(&config, "env", "SOCKET_PATCH_ROOT").is_none() - && !config.contains("SOCKET_PATCH_ROOT"), - "[env] SOCKET_PATCH_ROOT must be removed:\n{config}" - ); - - // build.rs still pristine after remove. - assert_eq!( - std::fs::read_to_string(root.join("build.rs")).unwrap(), - USER_BUILD_RS, - "setup --remove must never modify a user's build.rs" - ); - - // ── check (after remove): back to needs-configuration ─────────────── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s]); - assert_eq!( - code, 1, - "setup --check must FAIL (exit 1) again after remove.\nstdout:\n{out}\nstderr:\n{err}" - ); - } -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_golang.rs b/crates/socket-patch-cli/tests/setup_matrix_golang.rs deleted file mode 100644 index a417f0d..0000000 --- a/crates/socket-patch-cli/tests/setup_matrix_golang.rs +++ /dev/null @@ -1,307 +0,0 @@ -//! setup-matrix: golang ecosystem (go modules). `setup` wires a project-local -//! fail-closed guard (`internal/socketpatchguard` + a blank import in each -//! `package main` dir) via the go.mod-redirect backend (#104). The Docker -//! matrix `go()` case is still an EXPECTED BASELINE GAP (its image carries an -//! older binary and `matrix.json` marks go `baseline_supported=false`); the -//! real configure→check→remove contract is pinned by the host guard below. -//! -//! IMPORTANT — why this file carries a real assertion of its own: -//! `smc::run_pm("golang", "go")` routes go through the shared Docker matrix -//! harness, which (a) *soft-skips and silently passes* whenever Docker or the -//! `golang` image is absent (the common case locally and in this eval), and -//! (b) is NOT npm-family (`is_npm_family` is false for go — see the harness), -//! so the check/remove behavioral round-trip is skipped entirely. go's -//! `baseline_supported` is also false in matrix.json, so the only verdict the -//! matrix could ever produce is the coarse `actual_applied == expect_applied` -//! — and on a crashed / never-run case `actual_applied` defaults to the same -//! `false` that satisfies every negative-control scenario. Net effect: the -//! matrix call can never turn red for a genuine go `setup` regression. On its -//! own it protects nothing. -//! -//! To close that loophole WITHOUT touching the shared harness or the bash -//! driver, [`host_guard::go_setup_configures_and_removes_guard_host`] runs -//! unconditionally (no Docker, no network, no go toolchain) and pins go -//! `setup`'s *actual current contract*: `--check` on an un-wired project -//! reports `needs_configuration` (exit 1); `setup` wires the guard package + -//! blank import (status `success`, `updated=2`) without mutating the go -//! sources; `--check` then reports `configured` (exit 0); and `--remove` tears -//! it back out, restoring the byte-for-byte original tree. It verifies on-disk -//! state with an *independent* recursive directory snapshot (not any production -//! helper) so the oracle can disagree with a broken implementation. It fails -//! loudly if go `setup` regresses to a no-op, mis-reports state, leaks files, -//! or aborts. -//! -//! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_golang` -#![cfg(feature = "setup-e2e")] - -#[path = "setup_matrix_common/mod.rs"] -mod smc; - -/// Documentation/negative-control pass through the shared Docker matrix. -/// Kept for parity with the other ecosystems and to run the go negative -/// controls when Docker + the `golang` image are present. NOTE: this is the -/// path that silently no-ops on skip — it is NOT a regression guard. The real -/// teeth live in [`host_guard`] below. -#[test] -fn go() { - smc::run_pm("golang", "go"); -} - -// ───────────────────────────────────────────────────────────────────────── -// Real, non-skippable regression guard for go `setup`. -// -// Since #104's go.mod-redirect backend, `setup` wires a project-local -// fail-closed guard (`internal/socketpatchguard` + a blank import per -// `package main` dir) and `--remove` tears it back out. This guard pins that -// configure→check→remove round-trip — the assertion the Docker matrix can -// never make for go — and would fail loudly if a regression dropped the -// wiring, mis-reported state, leaked files, or aborted. -// ───────────────────────────────────────────────────────────────────────── -mod host_guard { - use std::collections::BTreeMap; - use std::path::Path; - use std::process::Command; - - /// A faithful single-module go project mirroring the matrix `golang` - /// target (`github.com/gin-gonic/gin@v1.9.1`): a `go.mod`, a `go.sum`, and - /// a `main.go`. None of these is a surface `setup` configures, so the whole - /// tree must come back byte-for-byte unchanged. - const GO_MOD: &str = "module example.com/sm-go-proj\n\ngo 1.21\n\nrequire github.com/gin-gonic/gin v1.9.1\n"; - const GO_SUM: &str = "github.com/gin-gonic/gin v1.9.1 h1:placeholderhashplaceholderhashplace= \ngithub.com/gin-gonic/gin v1.9.1/go.mod h1:placeholdermodhashplaceholderhash=\n"; - const MAIN_GO: &str = "package main\n\nimport \"github.com/gin-gonic/gin\"\n\nfunc main() {\n\t_ = gin.New()\n}\n"; - - /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. - fn binary() -> std::path::PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() - } - - /// Every `SOCKET_*` env var clap consults for the surface this test drives. - /// They are stripped from the child so behaviour reflects ONLY the explicit - /// flags (`--cwd`, `--yes`, `--check`, `--remove`, `--json`). Without this, - /// an ambient `SOCKET_CWD` could point setup at a *different* directory than - /// the go fixture (e.g. a real package.json elsewhere), masking a regression - /// by making the run report on something other than the go project. - const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_SAVE_ONLY", - "SOCKET_ONE_OFF", - "SOCKET_ALL_RELEASES", - "SOCKET_PATCH_ROOT", - "SOCKET_PATCH_GUARD", - ]; - - /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The whole `SOCKET_*` surface is stripped so behaviour reflects the - /// explicit flags alone and nothing reaches authed endpoints. - fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); - } - let out = cmd.output().expect("failed to execute socket-patch binary"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) - } - - /// Parse the CLI's `--json` stdout into a single JSON object. Panics - /// (loudly) if stdout is not the single JSON object the command promises — - /// a non-JSON / multi-line dump means the command did not run the path we - /// think it did. - fn parse_json(stdout: &str, who: &str) -> serde_json::Value { - serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { - panic!("{who}: stdout was not a single JSON object ({e}):\n{stdout}") - }) - } - - fn json_str_field(v: &serde_json::Value, key: &str, who: &str) -> String { - v.get(key) - .and_then(|s| s.as_str()) - .unwrap_or_else(|| panic!("{who}: JSON has no string `{key}` field:\n{v}")) - .to_string() - } - - fn json_i64_field(v: &serde_json::Value, key: &str, who: &str) -> i64 { - v.get(key) - .and_then(|n| n.as_i64()) - .unwrap_or_else(|| panic!("{who}: JSON has no integer `{key}` field:\n{v}")) - } - - /// Independent oracle: a recursive `relative-path -> bytes` snapshot of the - /// project tree. Deliberately does NOT reuse any production discovery / - /// detection helper, so it can disagree with a broken `setup` that litters - /// or mutates the go project. Used to prove the tree is byte-for-byte - /// identical before and after every sub-command. - fn snapshot(root: &Path) -> BTreeMap> { - let mut map = BTreeMap::new(); - fn walk(dir: &Path, base: &Path, map: &mut BTreeMap>) { - for entry in std::fs::read_dir(dir).expect("read_dir") { - let entry = entry.expect("dir entry"); - let path = entry.path(); - let ft = entry.file_type().expect("file_type"); - if ft.is_dir() { - walk(&path, base, map); - } else { - let rel = path - .strip_prefix(base) - .expect("strip base") - .to_string_lossy() - .into_owned(); - map.insert(rel, std::fs::read(&path).expect("read file")); - } - } - } - walk(root, root, &mut map); - map - } - - /// Assert the snapshot is exactly the three go fixture files (unchanged), - /// proving `setup` neither littered the tree with a hook file - /// (package.json / .cargo/config.toml / *.pth) nor mutated the go sources. - fn assert_pristine_go_tree(root: &Path, who: &str) { - let snap = snapshot(root); - let names: Vec<&str> = snap.keys().map(String::as_str).collect(); - assert_eq!( - names, - vec!["go.mod", "go.sum", "main.go"], - "{who}: go project tree must contain ONLY the original go files \ - (setup must not write a hook into a go project); found: {names:?}" - ); - assert_eq!(snap["go.mod"], GO_MOD.as_bytes(), "{who}: go.mod must be unchanged"); - assert_eq!(snap["go.sum"], GO_SUM.as_bytes(), "{who}: go.sum must be unchanged"); - assert_eq!(snap["main.go"], MAIN_GO.as_bytes(), "{who}: main.go must be unchanged"); - } - - #[test] - fn go_setup_configures_and_removes_guard_host() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - std::fs::write(root.join("go.mod"), GO_MOD).unwrap(); - std::fs::write(root.join("go.sum"), GO_SUM).unwrap(); - std::fs::write(root.join("main.go"), MAIN_GO).unwrap(); - let root_s = root.to_str().unwrap(); - - // Pin the BEFORE state: exactly the three go files, no hook artifacts. - assert_pristine_go_tree(root, "fixture (pristine)"); - - // The fail-closed guard surfaces setup wires into a `package main` dir: - // a guard package under `internal/socketpatchguard/` and a blank import - // beside the `package main` file (here, the repo root). - let guard_dir = root.join("internal").join("socketpatchguard"); - let guard_go = guard_dir.join("guard.go"); - let guard_test = guard_dir.join("guard_test.go"); - let import_go = root.join("socket_patch_guard_import.go"); - - // ── check (pristine): since #104's go.mod-redirect guard backend, go IS - // a configurable surface — an un-wired project reports - // `needs_configuration` and exits 1 (NOT `no_files`/exit 0). ────────── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 1, - "setup --check on an un-wired go project must exit 1 (guard not configured).\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "check (pristine)"); - assert_eq!( - json_str_field(&v, "status", "check (pristine)"), - "needs_configuration", - "an un-wired go project must report needs_configuration.\nstderr:\n{err}" - ); - let kinds: Vec<&str> = v["files"] - .as_array() - .expect("check must report a files array") - .iter() - .filter_map(|f| f["kind"].as_str()) - .collect(); - assert!( - kinds.contains(&"go_guard") && kinds.contains(&"go_import"), - "check must surface the go_guard + go_import targets; got kinds={kinds:?}\n{out}" - ); - // --check must not write anything. - assert_pristine_go_tree(root, "after check"); - - // ── setup: wires the guard package + the blank import. ─────────────── - let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!( - code, 0, - "setup on a go project must exit 0.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "setup"); - assert_eq!( - json_str_field(&v, "status", "setup"), - "success", - "setup must report success now that go is a configurable surface.\nstderr:\n{err}" - ); - assert_eq!( - json_i64_field(&v, "updated", "setup"), - 2, - "setup wires exactly the guard package + the blank import.\n{out}" - ); - assert_eq!(json_i64_field(&v, "errors", "setup"), 0, "setup must report zero errors.\n{out}"); - // Independent on-disk oracle: the guard package + blank import now exist, - // and the original go sources are byte-for-byte untouched. (Use path - // joins, not snapshot string keys, so this is separator-correct on - // Windows.) - assert!(guard_go.exists(), "setup must write internal/socketpatchguard/guard.go"); - assert!(guard_test.exists(), "setup must write internal/socketpatchguard/guard_test.go"); - assert!(import_go.exists(), "setup must write the blank socket_patch_guard_import.go"); - assert_eq!(std::fs::read(root.join("go.mod")).unwrap(), GO_MOD.as_bytes(), "go.mod must be unchanged"); - assert_eq!(std::fs::read(root.join("main.go")).unwrap(), MAIN_GO.as_bytes(), "main.go must be unchanged"); - - // ── check (post-setup): now configured, exit 0. ────────────────────── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 0, - "setup --check must exit 0 once the guard is wired.\nstdout:\n{out}\nstderr:\n{err}" - ); - assert_eq!( - json_str_field(&parse_json(&out, "check (post-setup)"), "status", "check (post-setup)"), - "configured", - "go must report configured after setup wired the guard.\nstderr:\n{err}" - ); - - // ── remove: tears down the guard + import (pruning internal/) and - // restores the exact pre-setup tree. ──────────────────────────────── - let (code, out, err) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes", "--json"]); - assert_eq!( - code, 0, - "setup --remove on a configured go project must exit 0.\nstdout:\n{out}\nstderr:\n{err}" - ); - assert_eq!( - json_str_field(&parse_json(&out, "remove"), "status", "remove"), - "success", - "remove must report success when it tears the guard back out.\nstderr:\n{err}" - ); - // Decisive anti-leak check: the tree is byte-for-byte the original three - // files — the guard package + blank import are gone and internal/ pruned. - assert_pristine_go_tree(root, "after remove"); - - // ── check (post-remove): back to needs_configuration, exit 1. ──────── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 1, - "setup --check must exit 1 again once the guard is removed.\nstdout:\n{out}\nstderr:\n{err}" - ); - } -} diff --git a/crates/socket-patch-cli/tests/setup_monorepo_invariants.rs b/crates/socket-patch-cli/tests/setup_monorepo_invariants.rs deleted file mode 100644 index aab5396..0000000 --- a/crates/socket-patch-cli/tests/setup_monorepo_invariants.rs +++ /dev/null @@ -1,241 +0,0 @@ -//! Integration tests for `setup` on heterogeneous / multi-workspace monorepos: -//! multiple ecosystems in one repo (polyglot) and nested-workspace recursion. -//! -//! GREEN pins lock behavior that holds today. GAP pins are `#[ignore]`d — they -//! encode the *intended* behavior for cases that are not implemented yet -//! (nested-workspace recursion), kept off the blocking CI suite and runnable via -//! `-- --ignored`. See CLI_CONTRACT.md "Setup command contract" (property 9 + -//! "Monorepo / multi-project discovery model"). -//! -//! Gated on the `cargo` feature (enabled by default): the polyglot all-three -//! test needs the cargo branch. -#![cfg(feature = "cargo")] - -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} - -/// `SOCKET_*` vars scrubbed from every child so behaviour is decided by flags + -/// fixtures alone (mirrors setup_invariants.rs / setup_cargo_invariants.rs). -const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_TOKEN", - "SOCKET_ECOSYSTEMS", - "SOCKET_OFFLINE", - "SOCKET_JSON", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_PATCH_ROOT", - "SOCKET_PATCH_BIN", - "SOCKET_PATCH_DEBUG", -]; - -/// Run the binary with a scrubbed environment, telemetry off, and HOME pointed -/// at `home` (so we'd notice any out-of-repo write). Returns (exit code, JSON). -fn run(cwd: &Path, home: &Path, args: &[&str]) -> (i32, serde_json::Value) { - let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); - } - cmd.env("HOME", home); - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); - let out = cmd.output().expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - let v = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("stdout must be JSON ({e}):\n{stdout}")); - (out.status.code().unwrap_or(-1), v) -} - -fn write(path: &Path, content: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::write(path, content).expect("write file"); -} - -fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read file") -} - -/// The set of `files[*].kind` values in a setup/check/remove envelope. -fn kinds(v: &serde_json::Value) -> Vec { - let mut ks: Vec = v["files"] - .as_array() - .expect("files array") - .iter() - .map(|f| f["kind"].as_str().unwrap_or("").to_string()) - .collect(); - ks.sort(); - ks -} - -/// Stage a polyglot repo: npm + python + cargo manifests in one directory. -fn write_polyglot(root: &Path) { - write(&root.join("package.json"), r#"{ "name": "app", "version": "1.0.0" }"#); - write(&root.join("requirements.txt"), "requests==2.31.0\n"); - write( - &root.join("Cargo.toml"), - "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", - ); -} - -// =========================================================================== -// GREEN — multiple ecosystems in one repo (property: each ecosystem is detected -// and configured independently). CLI_CONTRACT 'Setup command contract'. -// =========================================================================== - -#[test] -fn setup_configures_npm_python_cargo_together() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - write_polyglot(proj.path()); - - let (code, v) = run(proj.path(), home.path(), &["setup", "--json", "--yes"]); - assert_eq!(code, 0, "polyglot setup should succeed: {v}"); - assert_eq!(v["status"], "success"); - // npm (package.json) + python (.pth dep) + cargo (guard dep) + the one - // workspace-root [env] entry = four configured files. - assert_eq!(v["updated"], 4, "all three ecosystems must be configured: {v}"); - assert_eq!(v["errors"], 0); - assert_eq!( - kinds(&v), - vec!["cargo", "cargo_env", "package_json", "pth"], - "the envelope must carry one entry per ecosystem surface: {v}" - ); - - // Each manifest gained its real hook on disk (not just an envelope claim). - assert!( - read(&proj.path().join("package.json")).contains("socket-patch"), - "package.json must gain the npm hook" - ); - assert_eq!( - read(&proj.path().join("requirements.txt")), - "requests==2.31.0\nsocket-patch[hook]\n", - "requirements.txt must gain the python hook dep" - ); - assert!( - read(&proj.path().join("Cargo.toml")).contains("socket-patch-guard"), - "Cargo.toml must gain the guard dependency" - ); - assert!( - read(&proj.path().join(".cargo/config.toml")).contains("SOCKET_PATCH_ROOT"), - ".cargo/config.toml must declare [env] SOCKET_PATCH_ROOT" - ); -} - -#[test] -fn setup_check_and_remove_handle_all_three_ecosystems() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - write_polyglot(proj.path()); - let pristine_req = read(&proj.path().join("requirements.txt")); - let pristine_cargo = read(&proj.path().join("Cargo.toml")); - - let (c0, _) = run(proj.path(), home.path(), &["setup", "--json", "--yes"]); - assert_eq!(c0, 0); - - // --check: all three ecosystems report configured. - let (cc, cv) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); - assert_eq!(cc, 0, "configured polyglot repo must pass --check: {cv}"); - assert_eq!(cv["status"], "configured"); - assert_eq!(cv["configured"], 4, "all four surfaces configured: {cv}"); - assert_eq!( - kinds(&cv), - vec!["cargo", "cargo_env", "package_json", "pth"] - ); - - // --remove: the three editable manifests round-trip byte-for-byte. (The - // empty .cargo/config.toml residue is a known gap, guarded separately in - // setup_contract_gaps.rs.) - let (rc, rv) = run(proj.path(), home.path(), &["setup", "--remove", "--json", "--yes"]); - assert_eq!(rc, 0, "remove should succeed: {rv}"); - assert_eq!(rv["status"], "success"); - // package.json: setup pretty-prints JSON, so the round-trip is semantic (not - // byte-exact) — the hooks are gone and the user's keys are preserved. - let pkg = read(&proj.path().join("package.json")); - assert!(!pkg.contains("socket-patch"), "npm hook removed from package.json:\n{pkg}"); - let parsed: serde_json::Value = serde_json::from_str(&pkg).expect("valid package.json"); - assert_eq!(parsed["name"], "app"); - assert_eq!(parsed["version"], "1.0.0"); - assert!(parsed["scripts"].get("postinstall").is_none(), "postinstall key dropped"); - // requirements.txt + Cargo.toml restore byte-for-byte (line/toml preserving). - assert_eq!(read(&proj.path().join("requirements.txt")), pristine_req, "requirements.txt restored"); - assert_eq!(read(&proj.path().join("Cargo.toml")), pristine_cargo, "Cargo.toml restored"); -} - -// =========================================================================== -// GAP — nested npm workspace recursion (property 9). A workspace member that is -// itself a workspace root should have ITS members configured too. -// -// SHIPPED: `find_workspace_packages` now recurses into a member that declares -// its own `workspaces`, so `packages/inner/sub/leaf` is configured. This pin is -// now an active (non-ignored) regression guard. -// =========================================================================== - -#[test] -fn setup_recurses_into_nested_npm_workspace() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - // Root workspace whose member `packages/inner` is ITSELF a workspace root. - write( - &proj.path().join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] }"#, - ); - write( - &proj.path().join("packages/inner/package.json"), - r#"{ "name": "inner", "workspaces": ["sub/*"] }"#, - ); - write( - &proj.path().join("packages/inner/sub/leaf/package.json"), - r#"{ "name": "leaf", "version": "1.0.0" }"#, - ); - - let (code, v) = run(proj.path(), home.path(), &["setup", "--json", "--yes"]); - assert_eq!(code, 0, "setup should succeed: {v}"); - // The intended behavior: the nested-workspace leaf is also configured. - assert!( - read(&proj.path().join("packages/inner/sub/leaf/package.json")).contains("socket-patch"), - "nested-workspace member `leaf` must be configured (recursion into member workspaces)" - ); -} - -// =========================================================================== -// GAP — deeply-nested cargo workspace members via the recursive `**` glob. -// Cargo itself accepts `members = ["crates/**"]` (and forbids true nested -// workspaces), but `discover_cargo_project` only expands a single-level -// `crates/*`, so a member at `crates/group/leaf` is never configured. -// -// SHIPPED: `expand_member` now expands the recursive `crates/**` glob -// (`glob_dir_recursive`), so a member at `crates/group/leaf` is configured. -// This pin is now an active (non-ignored) regression guard. -// =========================================================================== - -#[test] -fn setup_expands_recursive_cargo_member_glob() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - write( - &proj.path().join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/**\"]\nresolver = \"2\"\n", - ); - // A member nested two directories deep — matched by `crates/**` but not by - // the single-level `crates/*` the discoverer supports today. - write( - &proj.path().join("crates/group/leaf/Cargo.toml"), - "[package]\nname = \"leaf\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", - ); - - let (code, v) = run(proj.path(), home.path(), &["setup", "--json", "--yes"]); - assert_eq!(code, 0, "setup should succeed: {v}"); - assert!( - read(&proj.path().join("crates/group/leaf/Cargo.toml")).contains("socket-patch-guard"), - "deeply-nested cargo member (via `crates/**`) must gain the guard dependency" - ); -} diff --git a/crates/socket-patch-core/src/cargo_setup/discover.rs b/crates/socket-patch-core/src/cargo_setup/discover.rs deleted file mode 100644 index cb32533..0000000 --- a/crates/socket-patch-core/src/cargo_setup/discover.rs +++ /dev/null @@ -1,629 +0,0 @@ -//! Discover a Cargo project's root + member `Cargo.toml`s so `setup` can add -//! the guard dependency to each member (so any member's build runs the guard) -//! and write `[env] SOCKET_PATCH_ROOT` once at the workspace root. -//! -//! There is no existing Cargo workspace reader in the crate (the npm/pnpm -//! workspace logic in `package_json::find` is JS-specific), so this is a -//! minimal `[workspace] members` reader built on `toml_edit`. - -use std::collections::HashSet; -use std::path::{Path, PathBuf}; - -use tokio::fs; -use toml_edit::{DocumentMut, Item}; - -/// A discovered Cargo project. -#[derive(Debug, Clone)] -pub struct CargoProject { - /// Directory containing the workspace (or single-crate) `Cargo.toml`. The - /// `.cargo/config.toml` + `[env] SOCKET_PATCH_ROOT` live here. - pub root: PathBuf, - /// Every member's `Cargo.toml` path (the guard dep is added to each). - pub members: Vec, -} - -/// Find the Cargo project that `cwd` belongs to, resolving the workspace root -/// and its members. Returns `None` if there is no `Cargo.toml` at or above -/// `cwd`. -pub async fn discover_cargo_project(cwd: &Path) -> Option { - let nearest = find_cargo_toml_upwards(cwd).await?; - // The workspace root is the nearest ancestor `Cargo.toml` (including - // `nearest`) that declares `[workspace]`; otherwise `nearest` is a - // standalone crate that is its own root. - let ws_manifest = find_workspace_root(&nearest).await.unwrap_or(nearest); - let root = ws_manifest.parent()?.to_path_buf(); - - let content = fs::read_to_string(&ws_manifest).await.ok()?; - let doc = content.parse::().ok()?; - - let mut members: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - - // The root manifest is itself a member when it has a `[package]`. - if doc.get("package").is_some() { - push_unique(&mut members, &mut seen, ws_manifest.clone()); - } - - // `[workspace] members = [...]` (with single-trailing-`*` glob support). - // Read via `as_table_like` so the equally-valid inline form - // `workspace = { members = [...] }` is honored too — otherwise its members - // are silently dropped even though `find_workspace_root` (which only checks - // `.is_some()`) still treats it as the workspace root. Mirrors the - // inline-aware `[dependencies]` handling in `update::is_guard_dep_present`. - if let Some(arr) = doc - .get("workspace") - .and_then(Item::as_table_like) - .and_then(|w| w.get("members")) - .and_then(Item::as_array) - { - for pattern in arr.iter().filter_map(|v| v.as_str()) { - for manifest in expand_member(&root, pattern).await { - push_unique(&mut members, &mut seen, manifest); - } - } - } - - // Neither a `[package]` nor any resolvable members → treat the manifest - // itself as the sole member (e.g. a virtual manifest with globbed members - // that matched nothing — fall back so setup still has something to edit). - if members.is_empty() { - push_unique(&mut members, &mut seen, ws_manifest); - } - - Some(CargoProject { root, members }) -} - -fn push_unique(members: &mut Vec, seen: &mut HashSet, path: PathBuf) { - if seen.insert(path.clone()) { - members.push(path); - } -} - -/// Walk up from `start` looking for a `Cargo.toml`. -async fn find_cargo_toml_upwards(start: &Path) -> Option { - let mut dir = start.to_path_buf(); - loop { - let candidate = dir.join("Cargo.toml"); - if fs::metadata(&candidate).await.is_ok() { - return Some(candidate); - } - dir = dir.parent()?.to_path_buf(); - } -} - -/// Walk up from `start_manifest`'s directory looking for a `Cargo.toml` that -/// declares `[workspace]`. Returns that manifest, or `None` if none exists. -async fn find_workspace_root(start_manifest: &Path) -> Option { - let mut dir = start_manifest.parent()?.to_path_buf(); - loop { - let candidate = dir.join("Cargo.toml"); - if let Ok(content) = fs::read_to_string(&candidate).await { - if content - .parse::() - .ok() - .map(|d| d.get("workspace").is_some()) - .unwrap_or(false) - { - return Some(candidate); - } - } - dir = dir.parent()?.to_path_buf(); - } -} - -/// Expand one `[workspace] members` pattern (relative to `root`) into member -/// `Cargo.toml` paths. Supports a bare path (`crate-a`), a single-level glob -/// (`crates/*` / `*`), and the recursive glob (`crates/**` / `**`), which Cargo -/// accepts and which `setup` must honor so a deeply-nested member is configured -/// (property 9). `/**` is checked before `/*` (a `crates/**` pattern ends in -/// `**`, not `/*`, but the explicit order keeps intent clear). -async fn expand_member(root: &Path, pattern: &str) -> Vec { - let pattern = pattern.replace('\\', "/"); - if let Some(prefix) = pattern.strip_suffix("/**") { - glob_dir_recursive(&root.join(prefix)).await - } else if pattern == "**" { - glob_dir_recursive(root).await - } else if let Some(prefix) = pattern.strip_suffix("/*") { - glob_dir(&root.join(prefix)).await - } else if pattern == "*" { - glob_dir(root).await - } else { - let manifest = root.join(&pattern).join("Cargo.toml"); - if fs::metadata(&manifest).await.is_ok() { - vec![manifest] - } else { - Vec::new() - } - } -} - -/// Every immediate subdirectory of `base` that contains a `Cargo.toml`. -async fn glob_dir(base: &Path) -> Vec { - let mut out = Vec::new(); - let mut rd = match fs::read_dir(base).await { - Ok(rd) => rd, - Err(_) => return out, - }; - while let Ok(Some(entry)) = rd.next_entry().await { - // `entry.file_type()` reflects the dir entry itself, which for a - // symlink reports `is_dir() == false` — so a symlinked member - // directory (which Cargo accepts and expands) would be silently - // skipped. Stat the path instead so symlinks are followed. - let path = entry.path(); - if fs::metadata(&path).await.map(|m| m.is_dir()).unwrap_or(false) { - let manifest = path.join("Cargo.toml"); - if fs::metadata(&manifest).await.is_ok() { - out.push(manifest); - } - } - } - out -} - -/// Recursive-glob (`**`) expansion: every subdirectory of `base`, at any depth, -/// that contains a `Cargo.toml`. Skips hidden dirs and `target/` so a build -/// tree is never walked. Bounded depth as a loop backstop. -async fn glob_dir_recursive(base: &Path) -> Vec { - let mut out = Vec::new(); - collect_manifests_recursive(base, 0, &mut out).await; - out -} - -async fn collect_manifests_recursive(dir: &Path, depth: usize, out: &mut Vec) { - if depth > 20 { - return; - } - let mut rd = match fs::read_dir(dir).await { - Ok(rd) => rd, - Err(_) => return, - }; - while let Ok(Some(entry)) = rd.next_entry().await { - // Use the dir-entry's own type (does NOT follow symlinks): a `**` walk - // must not traverse a symlinked dir — it could loop back to an ancestor - // (so the workspace root's own `Cargo.toml` reappears as a duplicate - // member) or escape the repo entirely (so `setup` would edit an - // out-of-tree `Cargo.toml`, breaking the in-repo-only contract). The - // `glob` crate's `**` likewise does not follow symlinks. (The - // single-level `glob_dir` still follows a symlinked direct member.) - let ft = match entry.file_type().await { - Ok(ft) => ft, - Err(_) => continue, - }; - if ft.is_symlink() || !ft.is_dir() { - continue; - } - let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.starts_with('.') || name == "target" { - continue; - } - let path = entry.path(); - let manifest = path.join("Cargo.toml"); - if fs::metadata(&manifest).await.is_ok() { - out.push(manifest); - } - Box::pin(collect_manifests_recursive(&path, depth + 1, out)).await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - async fn write(path: &Path, body: &str) { - if let Some(p) = path.parent() { - fs::create_dir_all(p).await.unwrap(); - } - fs::write(path, body).await.unwrap(); - } - - #[tokio::test] - async fn test_single_crate() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - &root.join("Cargo.toml"), - "[package]\nname = \"x\"\nversion = \"0.1.0\"\n", - ) - .await; - - let proj = discover_cargo_project(root).await.unwrap(); - assert_eq!(proj.root, root); - assert_eq!(proj.members, vec![root.join("Cargo.toml")]); - } - - #[tokio::test] - async fn test_workspace_with_glob_and_root_package() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - &root.join("Cargo.toml"), - "[package]\nname = \"root\"\nversion = \"0.1.0\"\n\n[workspace]\nmembers = [\"crates/*\"]\n", - ) - .await; - write( - &root.join("crates/a/Cargo.toml"), - "[package]\nname=\"a\"\nversion=\"0.1.0\"\n", - ) - .await; - write( - &root.join("crates/b/Cargo.toml"), - "[package]\nname=\"b\"\nversion=\"0.1.0\"\n", - ) - .await; - // A non-crate dir under crates/ is ignored. - fs::create_dir_all(root.join("crates/notacrate")) - .await - .unwrap(); - - let proj = discover_cargo_project(root).await.unwrap(); - assert_eq!(proj.root, root); - // Root package + the two globbed members. - assert!(proj.members.contains(&root.join("Cargo.toml"))); - assert!(proj.members.contains(&root.join("crates/a/Cargo.toml"))); - assert!(proj.members.contains(&root.join("crates/b/Cargo.toml"))); - assert_eq!(proj.members.len(), 3); - } - - #[tokio::test] - async fn test_workspace_recursive_double_glob() { - // Property 9: `members = ["crates/**"]` must reach a member nested - // several directories deep (`crates/group/leaf`), which the single-level - // `crates/*` expansion would miss. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - &root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/**\"]\n", - ) - .await; - write( - &root.join("crates/group/leaf/Cargo.toml"), - "[package]\nname=\"leaf\"\nversion=\"0.1.0\"\n", - ) - .await; - // A sibling at a different depth is also matched by `**`. - write( - &root.join("crates/top/Cargo.toml"), - "[package]\nname=\"top\"\nversion=\"0.1.0\"\n", - ) - .await; - // A `target/` build dir must NOT be walked even if it holds a Cargo.toml. - write( - &root.join("crates/group/target/junk/Cargo.toml"), - "[package]\nname=\"junk\"\nversion=\"0.1.0\"\n", - ) - .await; - - let proj = discover_cargo_project(root).await.unwrap(); - assert!( - proj.members.contains(&root.join("crates/group/leaf/Cargo.toml")), - "deeply-nested member must be discovered via `crates/**`, got {:?}", - proj.members - ); - assert!(proj.members.contains(&root.join("crates/top/Cargo.toml"))); - assert!( - !proj.members.iter().any(|m| m.to_string_lossy().contains("target")), - "target/ build dir must not be walked, got {:?}", - proj.members - ); - assert_eq!(proj.members.len(), 2, "exactly leaf + top: {:?}", proj.members); - } - - // A recursive `crates/**` glob must NOT follow symlinked directories: a - // loop symlink back to the root would re-add the workspace manifest as a - // duplicate member, and an escaping symlink would let `setup` edit an - // out-of-tree `Cargo.toml`. (Contrast the single-level `crates/*` case, - // which intentionally follows a symlinked direct member.) - #[cfg(unix)] - #[tokio::test] - async fn test_recursive_glob_does_not_follow_symlinks() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - &root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/**\"]\n", - ) - .await; - write( - &root.join("crates/real/Cargo.toml"), - "[package]\nname=\"real\"\nversion=\"0.1.0\"\n", - ) - .await; - fs::create_dir_all(root.join("crates")).await.unwrap(); - // Loop: crates/loop -> the workspace root (would re-discover root Cargo.toml). - std::os::unix::fs::symlink(root, root.join("crates/loop")).unwrap(); - // Escape: crates/escape -> an unrelated dir OUTSIDE the repo. - let outside = tempfile::tempdir().unwrap(); - write( - &outside.path().join("Cargo.toml"), - "[package]\nname=\"outside\"\nversion=\"0.1.0\"\n", - ) - .await; - std::os::unix::fs::symlink(outside.path(), root.join("crates/escape")).unwrap(); - - let proj = discover_cargo_project(root).await.unwrap(); - assert_eq!( - proj.members, - vec![root.join("crates/real/Cargo.toml")], - "recursive `**` must find only the real nested member — never the root \ - via the loop symlink, never the out-of-tree crate via the escape symlink; got {:?}", - proj.members - ); - } - - #[tokio::test] - async fn test_virtual_manifest_explicit_members() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - // No [package] — a virtual workspace manifest. - write( - &root.join("Cargo.toml"), - "[workspace]\nmembers = [\"app\", \"lib\"]\n", - ) - .await; - write( - &root.join("app/Cargo.toml"), - "[package]\nname=\"app\"\nversion=\"0.1.0\"\n", - ) - .await; - write( - &root.join("lib/Cargo.toml"), - "[package]\nname=\"lib\"\nversion=\"0.1.0\"\n", - ) - .await; - - let proj = discover_cargo_project(root).await.unwrap(); - assert!( - !proj.members.contains(&root.join("Cargo.toml")), - "virtual manifest is not a member" - ); - assert!(proj.members.contains(&root.join("app/Cargo.toml"))); - assert!(proj.members.contains(&root.join("lib/Cargo.toml"))); - assert_eq!(proj.members.len(), 2); - } - - #[tokio::test] - async fn test_discovers_workspace_root_from_member_cwd() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - &root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/a\"]\n", - ) - .await; - let member = root.join("crates/a"); - write( - &member.join("Cargo.toml"), - "[package]\nname=\"a\"\nversion=\"0.1.0\"\n", - ) - .await; - - // Run discovery from inside the member dir. - let proj = discover_cargo_project(&member).await.unwrap(); - assert_eq!(proj.root, root, "should resolve up to the workspace root"); - assert_eq!(proj.members, vec![root.join("crates/a/Cargo.toml")]); - } - - // A member directory reached through a symlink (Cargo follows symlinked - // members when expanding a `crates/*` glob) must still be discovered. The - // old `DirEntry::file_type()` gate reported the symlink as a non-directory - // and silently dropped it, leaving that member unconfigured by `setup`. - #[cfg(unix)] - #[tokio::test] - async fn test_globbed_member_through_symlink() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - &root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/*\"]\n", - ) - .await; - // The real crate lives outside `crates/`; `crates/a` is a symlink to it. - write( - &root.join("real/Cargo.toml"), - "[package]\nname=\"a\"\nversion=\"0.1.0\"\n", - ) - .await; - fs::create_dir_all(root.join("crates")).await.unwrap(); - std::os::unix::fs::symlink(root.join("real"), root.join("crates/a")).unwrap(); - - let proj = discover_cargo_project(root).await.unwrap(); - assert!( - proj.members.contains(&root.join("crates/a/Cargo.toml")), - "symlinked workspace member must be discovered, got {:?}", - proj.members - ); - // It must be the real member, not the virtual-manifest fallback. - assert!(!proj.members.contains(&root.join("Cargo.toml"))); - assert_eq!(proj.members.len(), 1); - } - - #[tokio::test] - async fn test_no_cargo_toml() { - let dir = tempfile::tempdir().unwrap(); - assert!(discover_cargo_project(dir.path()).await.is_none()); - } - - // A bare-path member that does not resolve to a `Cargo.toml` must be - // silently skipped without aborting discovery of the valid siblings. - #[tokio::test] - async fn test_nonexistent_bare_member_skipped() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - &root.join("Cargo.toml"), - "[workspace]\nmembers = [\"app\", \"ghost\", \"lib\"]\n", - ) - .await; - write( - &root.join("app/Cargo.toml"), - "[package]\nname=\"app\"\nversion=\"0.1.0\"\n", - ) - .await; - write( - &root.join("lib/Cargo.toml"), - "[package]\nname=\"lib\"\nversion=\"0.1.0\"\n", - ) - .await; - // `ghost` is listed but the directory has no Cargo.toml (it doesn't even - // exist) — Cargo would error, but `setup` must just skip it. - - let proj = discover_cargo_project(root).await.unwrap(); - assert!(proj.members.contains(&root.join("app/Cargo.toml"))); - assert!(proj.members.contains(&root.join("lib/Cargo.toml"))); - assert!( - !proj.members.iter().any(|m| m.to_string_lossy().contains("ghost")), - "unresolved member must not be added, got {:?}", - proj.members - ); - assert_eq!(proj.members.len(), 2); - } - - // Root `[package]` + recursive `crates/**`: the root manifest is a member - // (via `[package]`) and every nested crate is discovered, with no path - // appearing twice. - #[tokio::test] - async fn test_recursive_glob_with_root_package_and_dedup() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - &root.join("Cargo.toml"), - "[package]\nname=\"root\"\nversion=\"0.1.0\"\n\n[workspace]\nmembers = [\"crates/**\"]\n", - ) - .await; - write( - &root.join("crates/a/Cargo.toml"), - "[package]\nname=\"a\"\nversion=\"0.1.0\"\n", - ) - .await; - write( - &root.join("crates/group/deep/Cargo.toml"), - "[package]\nname=\"deep\"\nversion=\"0.1.0\"\n", - ) - .await; - - let proj = discover_cargo_project(root).await.unwrap(); - assert!(proj.members.contains(&root.join("Cargo.toml"))); - assert!(proj.members.contains(&root.join("crates/a/Cargo.toml"))); - assert!(proj.members.contains(&root.join("crates/group/deep/Cargo.toml"))); - assert_eq!(proj.members.len(), 3, "no duplicates: {:?}", proj.members); - - // No path appears twice. - let mut sorted = proj.members.clone(); - sorted.sort(); - let deduped_len = { - let mut s = sorted.clone(); - s.dedup(); - s.len() - }; - assert_eq!(sorted.len(), deduped_len, "members contain a duplicate: {:?}", proj.members); - } - - // Single-level `*` at the workspace root finds direct crate dirs and ignores - // an immediate subdir that has no `Cargo.toml`. - #[tokio::test] - async fn test_single_level_star_at_root() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("Cargo.toml"), "[workspace]\nmembers = [\"*\"]\n").await; - write( - &root.join("alpha/Cargo.toml"), - "[package]\nname=\"alpha\"\nversion=\"0.1.0\"\n", - ) - .await; - write( - &root.join("beta/Cargo.toml"), - "[package]\nname=\"beta\"\nversion=\"0.1.0\"\n", - ) - .await; - // A non-crate dir at the same level is ignored. - fs::create_dir_all(root.join("docs")).await.unwrap(); - - let proj = discover_cargo_project(root).await.unwrap(); - assert!(proj.members.contains(&root.join("alpha/Cargo.toml"))); - assert!(proj.members.contains(&root.join("beta/Cargo.toml"))); - assert_eq!(proj.members.len(), 2, "only the two crate dirs: {:?}", proj.members); - } - - // The `[workspace]`/`members` tables may be written as an inline table — - // `workspace = { members = [...] }` is valid TOML that Cargo (serde) - // accepts exactly like a `[workspace]` section. The reader must see through - // it via `as_table_like`, just as `is_guard_dep_present` does for inline - // `[dependencies]`. The old `as_table` gate returned None for the inline - // form, so every member was silently dropped (only the virtual-manifest - // fallback survived) — leaving the members unconfigured by `setup`, even - // though `find_workspace_root` still treats it as the workspace root. - #[tokio::test] - async fn test_inline_workspace_members_are_discovered() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - // Inline workspace table — NO `[package]`, so the only way to get real - // members is to read the inline `members` array. - write( - &root.join("Cargo.toml"), - "workspace = { members = [\"crates/*\"] }\n", - ) - .await; - write( - &root.join("crates/a/Cargo.toml"), - "[package]\nname=\"a\"\nversion=\"0.1.0\"\n", - ) - .await; - write( - &root.join("crates/b/Cargo.toml"), - "[package]\nname=\"b\"\nversion=\"0.1.0\"\n", - ) - .await; - - let proj = discover_cargo_project(root).await.unwrap(); - assert_eq!(proj.root, root); - assert!( - proj.members.contains(&root.join("crates/a/Cargo.toml")), - "inline-workspace member `a` must be discovered, got {:?}", - proj.members - ); - assert!( - proj.members.contains(&root.join("crates/b/Cargo.toml")), - "inline-workspace member `b` must be discovered, got {:?}", - proj.members - ); - // Exactly the two real members — NOT the virtual-manifest fallback - // (which would wrongly list the root `Cargo.toml` alone). - assert_eq!( - proj.members.len(), - 2, - "must be the two inline members, not the virtual fallback: {:?}", - proj.members - ); - assert!(!proj.members.contains(&root.join("Cargo.toml"))); - } - - // An inline workspace table with an explicit (non-glob) member list must - // also resolve through the same `as_table_like` path. - #[tokio::test] - async fn test_inline_workspace_explicit_members() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - &root.join("Cargo.toml"), - "workspace = { members = [\"app\", \"lib\"] }\n", - ) - .await; - write( - &root.join("app/Cargo.toml"), - "[package]\nname=\"app\"\nversion=\"0.1.0\"\n", - ) - .await; - write( - &root.join("lib/Cargo.toml"), - "[package]\nname=\"lib\"\nversion=\"0.1.0\"\n", - ) - .await; - - let proj = discover_cargo_project(root).await.unwrap(); - assert!(proj.members.contains(&root.join("app/Cargo.toml"))); - assert!(proj.members.contains(&root.join("lib/Cargo.toml"))); - assert_eq!(proj.members.len(), 2, "{:?}", proj.members); - } -} diff --git a/crates/socket-patch-core/src/cargo_setup/mod.rs b/crates/socket-patch-core/src/cargo_setup/mod.rs deleted file mode 100644 index c853bb2..0000000 --- a/crates/socket-patch-core/src/cargo_setup/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Cargo `setup` support: add/remove the `socket-patch-guard` build-time -//! dependency and discover a project's member `Cargo.toml`s. Analogous to -//! [`crate::package_json`] for npm and [`crate::pth_hook`] for Python. -//! -//! The `[env] SOCKET_PATCH_ROOT` part of setup is written via -//! [`crate::patch::cargo_config`] (shared with the apply-time redirect writer). - -pub mod discover; -pub mod update; - -pub use discover::{discover_cargo_project, CargoProject}; -pub use update::{ - add_guard_dep, is_guard_dep_present, remove_guard_dep, CargoEditResult, CargoSetupStatus, - GUARD_CRATE, -}; diff --git a/crates/socket-patch-core/src/cargo_setup/update.rs b/crates/socket-patch-core/src/cargo_setup/update.rs deleted file mode 100644 index e584808..0000000 --- a/crates/socket-patch-core/src/cargo_setup/update.rs +++ /dev/null @@ -1,404 +0,0 @@ -//! Add / remove the `socket-patch-guard` build-time dependency in a crate's -//! `Cargo.toml`, and statically check whether it is present. -//! -//! Edits go through `toml_edit` so the user's formatting + comments survive, -//! and the user's `build.rs` (if any) is **never** touched — that's the whole -//! reason the guard is a separate crate. Mirrors the contract style of -//! [`crate::package_json::update`] (idempotent, `dry_run`-aware, -//! `Updated`/`AlreadyConfigured`/`Error` status). - -use std::path::Path; - -use tokio::fs; -use toml_edit::{DocumentMut, Item, Table, TableLike, Value}; - -/// The guard crate's package name. -pub const GUARD_CRATE: &str = "socket-patch-guard"; - -/// Outcome of editing one `Cargo.toml`. Mirrors -/// `package_json::update::UpdateStatus` / `pth_hook::edit::PthStatus`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CargoSetupStatus { - Updated, - AlreadyConfigured, - Error, -} - -#[derive(Debug, Clone)] -pub struct CargoEditResult { - pub path: String, - pub status: CargoSetupStatus, - pub error: Option, -} - -impl CargoEditResult { - fn ok(path: &Path, status: CargoSetupStatus) -> Self { - Self { - path: path.display().to_string(), - status, - error: None, - } - } - fn err(path: &Path, msg: impl Into) -> Self { - Self { - path: path.display().to_string(), - status: CargoSetupStatus::Error, - error: Some(msg.into()), - } - } -} - -/// Add `socket-patch-guard = ""` under `[dependencies]`. Idempotent -/// (an existing entry of any value shape is left untouched → `AlreadyConfigured`). -/// A missing `Cargo.toml` is an error (we don't synthesize one). -pub async fn add_guard_dep(cargo_toml: &Path, version: &str, dry_run: bool) -> CargoEditResult { - let content = match fs::read_to_string(cargo_toml).await { - Ok(c) => c, - Err(e) => return CargoEditResult::err(cargo_toml, e.to_string()), - }; - match guard_dep_add(&content, version) { - Ok(None) => CargoEditResult::ok(cargo_toml, CargoSetupStatus::AlreadyConfigured), - Ok(Some(new)) => { - if !dry_run { - if let Err(e) = fs::write(cargo_toml, &new).await { - return CargoEditResult::err(cargo_toml, e.to_string()); - } - } - CargoEditResult::ok(cargo_toml, CargoSetupStatus::Updated) - } - Err(e) => CargoEditResult::err(cargo_toml, e), - } -} - -/// Remove the `socket-patch-guard` dependency. Idempotent (already-absent → -/// `AlreadyConfigured`). A missing `Cargo.toml` is a no-op (`AlreadyConfigured`). -pub async fn remove_guard_dep(cargo_toml: &Path, dry_run: bool) -> CargoEditResult { - let content = match fs::read_to_string(cargo_toml).await { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return CargoEditResult::ok(cargo_toml, CargoSetupStatus::AlreadyConfigured) - } - Err(e) => return CargoEditResult::err(cargo_toml, e.to_string()), - }; - match guard_dep_remove(&content) { - Ok(None) => CargoEditResult::ok(cargo_toml, CargoSetupStatus::AlreadyConfigured), - Ok(Some(new)) => { - if !dry_run { - if let Err(e) = fs::write(cargo_toml, &new).await { - return CargoEditResult::err(cargo_toml, e.to_string()); - } - } - CargoEditResult::ok(cargo_toml, CargoSetupStatus::Updated) - } - Err(e) => CargoEditResult::err(cargo_toml, e), - } -} - -/// Static check: is `socket-patch-guard` present under `[dependencies]`? -/// Pure parse — exactly what a GitHub App reads to audit a repo. Returns -/// `false` on malformed TOML. -pub fn is_guard_dep_present(content: &str) -> bool { - content - .parse::() - .ok() - .and_then(|doc| { - doc.get("dependencies") - .and_then(Item::as_table_like) - .map(|deps| deps.contains_key(GUARD_CRATE)) - }) - .unwrap_or(false) -} - -// ── pure transforms ────────────────────────────────────────────────────────── - -/// Get `parent[key]` as a mutable table, creating an empty `[key]` table if -/// it's absent. Accepts both standard (`[dependencies]`) and inline -/// (`dependencies = { … }`) tables via `as_table_like_mut` so it stays in -/// lockstep with [`is_guard_dep_present`] (which reads via `as_table_like`). -fn ensure_table<'a>(parent: &'a mut Table, key: &str) -> Result<&'a mut dyn TableLike, String> { - if !parent.contains_key(key) { - parent.insert(key, Item::Table(Table::new())); - } - parent - .get_mut(key) - .and_then(Item::as_table_like_mut) - .ok_or_else(|| format!("`{key}` is not a table")) -} - -fn guard_dep_add(content: &str, version: &str) -> Result, String> { - let mut doc = content - .parse::() - .map_err(|e| format!("Invalid Cargo.toml: {e}"))?; - // A *virtual* workspace manifest (`[workspace]` but no `[package]`) cannot - // carry a `[dependencies]` section — cargo rejects it with "this virtual - // manifest specifies a `dependencies` section, which is not allowed". Adding - // the guard here would corrupt the manifest, and there is no crate to build - // anyway (the guard belongs in each *member*). Refuse rather than write a - // file cargo can no longer parse. (Reachable via `discover`'s empty-members - // fallback, which hands the workspace root to `setup`.) - if doc.contains_key("workspace") && !doc.contains_key("package") { - return Err( - "Cargo.toml is a virtual workspace manifest (no `[package]`); the guard \ - dependency belongs in each member crate, not the workspace root" - .to_string(), - ); - } - let root = doc.as_table_mut(); - let deps = ensure_table(root, "dependencies")?; - if deps.contains_key(GUARD_CRATE) { - return Ok(None); - } - deps.insert(GUARD_CRATE, Item::Value(Value::from(version))); - Ok(Some(doc.to_string())) -} - -fn guard_dep_remove(content: &str) -> Result, String> { - let mut doc = content - .parse::() - .map_err(|e| format!("Invalid Cargo.toml: {e}"))?; - let removed = doc - .get_mut("dependencies") - .and_then(Item::as_table_like_mut) - .map(|deps| deps.remove(GUARD_CRATE).is_some()) - .unwrap_or(false); - if !removed { - return Ok(None); - } - Ok(Some(doc.to_string())) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_add_into_existing_deps() { - let toml = "[package]\nname = \"x\"\n\n[dependencies]\nserde = \"1\"\n"; - let out = guard_dep_add(toml, "3.3").unwrap().unwrap(); - assert!(out.contains("socket-patch-guard = \"3.3\"")); - assert!(out.contains("serde = \"1\"")); - // Idempotent. - assert!(guard_dep_add(&out, "3.3").unwrap().is_none()); - } - - #[test] - fn test_add_creates_dependencies_table() { - let toml = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n"; - let out = guard_dep_add(toml, "3.3").unwrap().unwrap(); - let doc = out.parse::().unwrap(); - assert_eq!(doc["dependencies"][GUARD_CRATE].as_str(), Some("3.3")); - } - - #[test] - fn test_add_preserves_existing_guard_entry() { - // A user who pinned a richer spec (path/version table) keeps it. - let toml = "[dependencies]\nsocket-patch-guard = { version = \"3.3\", optional = true }\n"; - assert!(guard_dep_add(toml, "3.3").unwrap().is_none()); - } - - #[test] - fn test_add_preserves_comments_and_build_section() { - let toml = "# my crate\n[package]\nname = \"x\"\n\n[dependencies]\nserde = \"1\" # json\n"; - let out = guard_dep_add(toml, "3.3").unwrap().unwrap(); - assert!(out.contains("# my crate")); - assert!(out.contains("serde = \"1\" # json")); - } - - #[test] - fn test_add_into_inline_dependencies_table() { - // `dependencies = { … }` is a valid (if uncommon) *root-level* inline - // table. The reader (`is_guard_dep_present`) sees through it via - // `as_table_like`, so the writer must insert INTO it too — otherwise add - // would either error or fork a second `[dependencies]` (a duplicate key, - // which is invalid TOML). The `dependencies` key must be at the document - // root, NOT under `[package]` (where it would belong to `package.*` and - // the writer would never touch it — masking this very regression). - let toml = "dependencies = { serde = \"1\" }\n"; - let out = guard_dep_add(toml, "3.3").unwrap().unwrap(); - assert!(is_guard_dep_present(&out)); - assert!(out.contains("serde = \"1\"")); - // Round-trips through a parser (proves it is not a duplicate-key file). - let doc = out.parse::().unwrap(); - assert_eq!(doc["dependencies"][GUARD_CRATE].as_str(), Some("3.3")); - // The guard lives in the SAME (inline) table as serde — there is exactly - // one `dependencies` key, still inline. - assert!(doc["dependencies"].is_inline_table()); - } - - #[test] - fn test_add_inline_dependencies_idempotent() { - // Guard already present in an inline table → AlreadyConfigured (Ok(None)), - // NOT an error. Mirrors `is_guard_dep_present` returning true here. - let toml = "dependencies = { socket-patch-guard = \"3.3\", serde = \"1\" }\n"; - assert!(is_guard_dep_present(toml)); - assert!(guard_dep_add(toml, "3.3").unwrap().is_none()); - } - - #[test] - fn test_remove_from_inline_dependencies_table() { - // The dangerous case: a `remove` that silently no-ops while the guard - // is still present (reports AlreadyConfigured but leaves it behind). - let toml = "dependencies = { socket-patch-guard = \"3.3\", serde = \"1\" }\n"; - assert!(is_guard_dep_present(toml)); - let out = guard_dep_remove(toml).unwrap().unwrap(); - assert!(!is_guard_dep_present(&out), "guard must actually be removed"); - assert!(out.contains("serde = \"1\"")); - } - - #[test] - fn test_remove() { - let toml = "[dependencies]\nserde = \"1\"\nsocket-patch-guard = \"3.3\"\n"; - let out = guard_dep_remove(toml).unwrap().unwrap(); - assert!(!out.contains("socket-patch-guard")); - assert!(out.contains("serde = \"1\"")); - } - - #[test] - fn test_remove_absent_is_noop() { - assert!(guard_dep_remove("[dependencies]\nserde = \"1\"\n") - .unwrap() - .is_none()); - } - - #[test] - fn test_is_guard_dep_present() { - assert!(is_guard_dep_present( - "[dependencies]\nsocket-patch-guard = \"3.3\"\n" - )); - assert!(is_guard_dep_present( - "[dependencies]\nsocket-patch-guard = { version = \"3.3\" }\n" - )); - assert!(!is_guard_dep_present("[dependencies]\nserde = \"1\"\n")); - assert!(!is_guard_dep_present("not valid toml [")); - } - - #[test] - fn test_invalid_toml_errors() { - assert!(guard_dep_add("not = = toml [[", "3.3").is_err()); - } - - #[tokio::test] - async fn test_add_missing_file_is_error() { - let dir = tempfile::tempdir().unwrap(); - let res = add_guard_dep(&dir.path().join("Cargo.toml"), "3.3", false).await; - assert_eq!(res.status, CargoSetupStatus::Error); - } - - #[tokio::test] - async fn test_remove_missing_file_is_noop() { - let dir = tempfile::tempdir().unwrap(); - let res = remove_guard_dep(&dir.path().join("Cargo.toml"), false).await; - assert_eq!(res.status, CargoSetupStatus::AlreadyConfigured); - } - - #[test] - fn test_add_to_virtual_workspace_manifest_is_error() { - // A virtual manifest (`[workspace]`, no `[package]`) cannot hold a - // `[dependencies]` section — cargo refuses to parse it. `add` must NOT - // produce such a file; it errors instead so `setup` surfaces the problem - // rather than silently corrupting the workspace root. - let toml = "[workspace]\nmembers = [\"crates/*\"]\n"; - let err = guard_dep_add(toml, "3.3").unwrap_err(); - assert!( - err.contains("virtual workspace manifest"), - "expected a virtual-manifest error, got: {err}" - ); - // The async wrapper reports it as Error, not a (corrupting) Updated. - // (Covered indirectly; the pure transform is the contract.) - } - - #[test] - fn test_add_to_root_package_with_workspace_is_allowed() { - // A *root package* (`[package]` AND `[workspace]`) is a real crate and - // CAN carry `[dependencies]` — the virtual-manifest guard must not reject - // it. This is the common single-repo-with-root-crate layout. - let toml = "[package]\nname = \"root\"\nversion = \"0.1.0\"\n\n[workspace]\nmembers = [\"crates/*\"]\n"; - let out = guard_dep_add(toml, "3.3").unwrap().unwrap(); - assert!(is_guard_dep_present(&out)); - // The produced manifest still parses (no duplicate/invalid section). - assert!(out.parse::().is_ok()); - } - - #[test] - fn test_add_into_root_inline_does_not_fork_a_second_table() { - // Regression guard: inserting into a root-level inline `dependencies` - // must mutate THAT table, never append a separate `[dependencies]` - // header (which would be a duplicate key → unparseable). - let toml = "dependencies = { serde = \"1\" }\n"; - let out = guard_dep_add(toml, "3.3").unwrap().unwrap(); - assert_eq!( - out.matches("dependencies").count(), - 1, - "must not fork a second dependencies table: {out}" - ); - assert!(out.parse::().is_ok(), "must stay valid TOML: {out}"); - } - - #[test] - fn test_add_then_remove_round_trips_byte_for_byte() { - // add into an existing `[dependencies]`, then remove, must restore the - // original manifest exactly (formatting + comments preserved). - let toml = "# top\n[package]\nname = \"x\"\n\n[dependencies]\nserde = \"1\" # json\n"; - let added = guard_dep_add(toml, "3.3").unwrap().unwrap(); - let removed = guard_dep_remove(&added).unwrap().unwrap(); - assert_eq!(removed, toml, "add→remove must round-trip byte-for-byte"); - } - - #[test] - fn test_dotted_guard_header_is_present_and_removable() { - // The guard pinned via a `[dependencies.socket-patch-guard]` section - // header (a sub-table) must be detected AND actually removed — not a - // silent no-op that leaves it behind. - let toml = "[dependencies.socket-patch-guard]\nversion = \"3.3\"\nfeatures = [\"x\"]\n"; - assert!(is_guard_dep_present(toml)); - // Idempotent add (already configured). - assert!(guard_dep_add(toml, "3.3").unwrap().is_none()); - let out = guard_dep_remove(toml).unwrap().unwrap(); - assert!(!is_guard_dep_present(&out), "dotted guard must be removed"); - } - - #[tokio::test] - async fn test_remove_dry_run_does_not_write() { - // The remove dry-run branch was previously untested. - let dir = tempfile::tempdir().unwrap(); - let cargo = dir.path().join("Cargo.toml"); - let body = "[dependencies]\nsocket-patch-guard = \"3.3\"\nserde = \"1\"\n"; - tokio::fs::write(&cargo, body).await.unwrap(); - let res = remove_guard_dep(&cargo, true).await; - assert_eq!(res.status, CargoSetupStatus::Updated); - let on_disk = tokio::fs::read_to_string(&cargo).await.unwrap(); - assert_eq!(on_disk, body, "dry-run must not modify the file"); - } - - #[tokio::test] - async fn test_add_to_virtual_manifest_wrapper_reports_error_without_writing() { - // End-to-end: the async wrapper turns the virtual-manifest refusal into - // an Error result and leaves the file byte-for-byte unchanged. - let dir = tempfile::tempdir().unwrap(); - let cargo = dir.path().join("Cargo.toml"); - let body = "[workspace]\nmembers = [\"a\", \"b\"]\n"; - tokio::fs::write(&cargo, body).await.unwrap(); - let res = add_guard_dep(&cargo, "3.3", false).await; - assert_eq!(res.status, CargoSetupStatus::Error); - let on_disk = tokio::fs::read_to_string(&cargo).await.unwrap(); - assert_eq!(on_disk, body, "must not corrupt the virtual manifest"); - } - - #[tokio::test] - async fn test_add_dry_run_does_not_write() { - let dir = tempfile::tempdir().unwrap(); - let cargo = dir.path().join("Cargo.toml"); - tokio::fs::write(&cargo, "[package]\nname=\"x\"\n") - .await - .unwrap(); - let res = add_guard_dep(&cargo, "3.3", true).await; - assert_eq!(res.status, CargoSetupStatus::Updated); - let body = tokio::fs::read_to_string(&cargo).await.unwrap(); - assert!( - !body.contains("socket-patch-guard"), - "dry-run must not write" - ); - } -} - - diff --git a/crates/socket-patch-core/src/gem_setup/mod.rs b/crates/socket-patch-core/src/gem_setup/mod.rs index bd33d0a..3fe697a 100644 --- a/crates/socket-patch-core/src/gem_setup/mod.rs +++ b/crates/socket-patch-core/src/gem_setup/mod.rs @@ -2,10 +2,9 @@ //! //! Bundler has no after-each-install hook that survives a cached/no-op //! `bundle install`, but it loads any declared **plugin** during the Gemfile -//! pass on every `bundle` invocation. So — like [`crate::go_setup`], which -//! ships committed source the user's toolchain runs — setup delivers the gate -//! as a generated, git-committed Bundler plugin plus a `plugin` directive in -//! the Gemfile: +//! pass on every `bundle` invocation. So setup delivers the gate as a +//! generated, git-committed Bundler plugin plus a `plugin` directive in the +//! Gemfile: //! //! * `.socket/bundler-plugin/{plugins.rb, socket-patch.gemspec}` — a generated //! plugin whose `plugins.rb` re-runs `socket-patch apply --ecosystems gem` @@ -54,8 +53,7 @@ pub struct BundlerProject { /// Find the Bundler project that `cwd` belongs to by walking up to the nearest /// directory holding a `Gemfile` (or Bundler's alternate `gems.rb`) — exactly -/// how `bundle` itself resolves the manifest, and matching the upward search in -/// [`crate::cargo_setup::discover`] / [`crate::go_setup`]. The discovered +/// how `bundle` itself resolves the manifest. The discovered /// directory (not `cwd`) becomes the project `root`, so `.socket/` and the /// plugin dir land next to the Gemfile even when `setup` is run from a /// subdirectory. Returns `None` when no ancestor has one — a `Gemfile.lock` diff --git a/crates/socket-patch-core/src/gem_setup/update.rs b/crates/socket-patch-core/src/gem_setup/update.rs index b186e49..8cf772c 100644 --- a/crates/socket-patch-core/src/gem_setup/update.rs +++ b/crates/socket-patch-core/src/gem_setup/update.rs @@ -1,11 +1,10 @@ //! Add / remove the managed `plugin "socket-patch"` block in a Bundler //! `Gemfile`, and statically check whether it is present. //! -//! A Gemfile is Ruby, not a structured config, so — unlike cargo's `toml_edit` -//! edits — this appends/strips a clearly-marked, byte-exact block (mirrors the -//! reversibility contract of [`crate::cargo_setup::update`]: idempotent, +//! A Gemfile is Ruby, not a structured config, so this appends/strips a +//! clearly-marked, byte-exact block under a reversibility contract: idempotent, //! `dry_run`-aware, `Updated`/`AlreadyConfigured`/`Error`, and a `--remove` that -//! restores the file byte-for-byte). +//! restores the file byte-for-byte. use std::path::Path; @@ -13,7 +12,7 @@ use tokio::fs; use super::{add_plugin_files, remove_plugin_files, BundlerProject}; -/// Outcome of one setup edit. Mirrors `cargo_setup::CargoSetupStatus`. +/// Outcome of one setup edit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GemSetupStatus { Updated, diff --git a/crates/socket-patch-core/src/go_setup/mod.rs b/crates/socket-patch-core/src/go_setup/mod.rs deleted file mode 100644 index f57774f..0000000 --- a/crates/socket-patch-core/src/go_setup/mod.rs +++ /dev/null @@ -1,809 +0,0 @@ -//! Go `setup` support: wire a project's fail-closed patch guard. -//! -//! Go has **no build hook** (no `build.rs` equivalent), so — unlike cargo, whose -//! guard rides in via a build-dependency — the gate is delivered as committed -//! source the user's own toolchain runs: -//! -//! * `internal/socketpatchguard/{guard.go,guard_test.go}` — a generated -//! package whose `TestSocketPatchesApplied` is the CI gate (`go test ./...`) -//! and whose `init()` guards `go run` / a binary launched from the module -//! tree. Both delegate to `socket-patch apply --check` / `apply` (the same -//! fail-closed, self-healing contract as the cargo build-script guard). -//! * `socket_patch_guard_import.go` in each `package main` directory — a tiny -//! generated file that blank-imports the guard so its `init()` fires. A -//! *separate generated file* (never an edit to the user's sources) keeps -//! setup non-destructive and removal exact. -//! -//! The actual `replace` redirects + copies are materialised by `apply` -//! (`crate::patch::go_redirect`), triggered by `setup` and re-checked by the -//! guard — this module only manages the guard wiring. - -use std::path::{Path, PathBuf}; - -use tokio::fs; - -use crate::crawlers::go_crawler::parse_go_mod_module; - -/// The in-module guard package directory (forward-slashed; `internal/` so only -/// the owning module can import it). -pub const GUARD_DIR: &str = "internal/socketpatchguard"; -/// Generated blank-import file name dropped into each `package main` dir. -pub const IMPORT_FILE: &str = "socket_patch_guard_import.go"; -/// First line of every generated file — the ownership signal for removal (we -/// never delete a file lacking it). -pub const GENERATED_MARKER: &str = "// Code generated by `socket-patch setup`. DO NOT EDIT."; - -/// `guard.go` — the runtime/CI guard logic (static; the package needs no -/// knowledge of the module path). -pub const GUARD_GO: &str = include_str!("templates/guard.go.tmpl"); -/// `guard_test.go` — the `go test ./...` gate. -pub const GUARD_TEST: &str = include_str!("templates/guard_test.go.tmpl"); - -/// The blank-import file body for a `package main` dir. -pub fn main_import_source(module_path: &str) -> String { - format!( - "{GENERATED_MARKER}\npackage main\n\nimport _ \"{module_path}/internal/socketpatchguard\"\n" - ) -} - -/// A Go module path safe to interpolate into a generated `import` string: -/// non-empty, no whitespace / quotes / backslash / control characters. (Go's -/// own module-path grammar is stricter, but this is enough to bar injection / -/// a syntactically broken generated file.) -fn is_safe_module_path(p: &str) -> bool { - !p.is_empty() - && p.chars().all(|c| { - !c.is_whitespace() && !c.is_control() && c != '"' && c != '\'' && c != '\\' && c != '`' - }) -} - -/// A discovered Go module. -#[derive(Debug, Clone)] -pub struct GoModule { - /// Directory containing `go.mod` (the project root). - pub root: PathBuf, - /// The module path from the `module` directive (e.g. `example.com/app`). - pub module_path: String, -} - -/// Find the Go module that `cwd` belongs to by walking up to the nearest -/// `go.mod`. Returns `None` if there is none, or if its `module` directive is -/// missing/malformed (we cannot form the guard import path without it). -pub async fn discover_go_module(cwd: &Path) -> Option { - let mut dir = cwd.to_path_buf(); - loop { - let candidate = dir.join("go.mod"); - if let Ok(content) = fs::read_to_string(&candidate).await { - let module_path = parse_go_mod_module(&content)?; - // Defense-in-depth: the path is interpolated verbatim into a Go - // `import _ "/internal/socketpatchguard"` string. Real module - // paths never contain whitespace/quotes/control chars; reject a - // hostile/malformed one rather than emit a broken (or injected) - // import file. A valid `go.mod` always passes this. - if !is_safe_module_path(&module_path) { - return None; - } - return Some(GoModule { - root: dir, - module_path, - }); - } - dir = dir.parent()?.to_path_buf(); - } -} - -/// Status of a single guard-wiring edit (mirrors cargo's `CargoSetupStatus`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GoSetupStatus { - Updated, - AlreadyConfigured, - Error, -} - -/// Result of one guard-wiring edit. -#[derive(Debug, Clone)] -pub struct GoEditResult { - /// Envelope `files[].kind` (`go_guard` | `go_import`). - pub kind: &'static str, - pub path: String, - pub status: GoSetupStatus, - pub error: Option, -} - -// ── guard package (internal/socketpatchguard) ──────────────────────────────── - -fn guard_go_path(root: &Path) -> PathBuf { - root.join(GUARD_DIR).join("guard.go") -} -fn guard_test_path(root: &Path) -> PathBuf { - root.join(GUARD_DIR).join("guard_test.go") -} - -/// Whether both guard files exist (the `setup --check` "configured" signal). -pub async fn guard_files_present(root: &Path) -> bool { - fs::metadata(guard_go_path(root)).await.is_ok() - && fs::metadata(guard_test_path(root)).await.is_ok() -} - -/// Write `internal/socketpatchguard/{guard.go,guard_test.go}` to their generated -/// content. Idempotent: `AlreadyConfigured` when both already match. -pub async fn add_guard(root: &Path, dry_run: bool) -> GoEditResult { - let dir = root.join(GUARD_DIR); - let result = async { - let go_changed = needs_write(&guard_go_path(root), GUARD_GO).await; - let test_changed = needs_write(&guard_test_path(root), GUARD_TEST).await; - if !go_changed && !test_changed { - return Ok(false); - } - if !dry_run { - fs::create_dir_all(&dir) - .await - .map_err(|e| format!("create {}: {e}", dir.display()))?; - if go_changed { - write_file(&guard_go_path(root), GUARD_GO).await?; - } - if test_changed { - write_file(&guard_test_path(root), GUARD_TEST).await?; - } - } - Ok(true) - } - .await; - edit_result("go_guard", dir.display().to_string(), result) -} - -/// Remove the guard package (both files + the now-empty `internal/` dirs). -pub async fn remove_guard(root: &Path, dry_run: bool) -> GoEditResult { - let dir = root.join(GUARD_DIR); - let result = async { - let present = guard_files_present(root).await - || fs::metadata(guard_go_path(root)).await.is_ok() - || fs::metadata(guard_test_path(root)).await.is_ok(); - if !present { - return Ok(false); - } - if !dry_run { - let _ = fs::remove_file(guard_go_path(root)).await; - let _ = fs::remove_file(guard_test_path(root)).await; - // Prune now-empty internal/socketpatchguard and internal/. - let _ = fs::remove_dir(&dir).await; - let _ = fs::remove_dir(root.join("internal")).await; - } - Ok(true) - } - .await; - edit_result("go_guard", dir.display().to_string(), result) -} - -// ── main-package blank imports ──────────────────────────────────────────────── - -/// The generated blank-import file path for a `package main` directory. -pub fn import_file_path(main_dir: &Path) -> PathBuf { - main_dir.join(IMPORT_FILE) -} - -/// Add the blank-import file to every `package main` directory. -pub async fn add_main_imports(root: &Path, module_path: &str, dry_run: bool) -> Vec { - let body = main_import_source(module_path); - let mut out = Vec::new(); - for dir in find_main_package_dirs(root).await { - let path = import_file_path(&dir); - let result = async { - if !needs_write(&path, &body).await { - return Ok(false); - } - if !dry_run { - write_file(&path, &body).await?; - } - Ok(true) - } - .await; - out.push(edit_result("go_import", path.display().to_string(), result)); - } - out -} - -/// Remove the generated blank-import files (identified by the marker, so a -/// user's same-named file is never deleted) from every `package main` dir. -pub async fn remove_main_imports(root: &Path, dry_run: bool) -> Vec { - let mut out = Vec::new(); - for dir in find_main_package_dirs(root).await { - let path = import_file_path(&dir); - match fs::read_to_string(&path).await { - Ok(content) if content.starts_with(GENERATED_MARKER) => { - let result = async { - if !dry_run { - fs::remove_file(&path) - .await - .map_err(|e| format!("remove {}: {e}", path.display()))?; - } - Ok(true) - } - .await; - out.push(edit_result("go_import", path.display().to_string(), result)); - } - // Absent, or a user-authored file with the same name — leave it. - _ => {} - } - } - out -} - -/// Every `package main` directory under `root`, excluding `vendor/`, `.socket/`, -/// the guard dir, hidden / `_`-prefixed / `testdata` dirs (which the Go -/// toolchain itself ignores). -pub async fn find_main_package_dirs(root: &Path) -> Vec { - let mut out = Vec::new(); - find_main_dirs_inner(root, root, &mut out).await; - out.sort(); - out -} - -fn find_main_dirs_inner<'a>( - root: &'a Path, - dir: &'a Path, - out: &'a mut Vec, -) -> std::pin::Pin + 'a>> { - Box::pin(async move { - let mut has_main = false; - let mut subdirs: Vec = Vec::new(); - let mut rd = match fs::read_dir(dir).await { - Ok(rd) => rd, - Err(_) => return, - }; - while let Ok(Some(entry)) = rd.next_entry().await { - let name = entry.file_name().to_string_lossy().to_string(); - let ft = match entry.file_type().await { - Ok(ft) => ft, - Err(_) => continue, - }; - // `file_type()` does NOT traverse symlinks, so a symlinked dir is - // reported as a symlink (is_dir() == false) and skipped here — - // which also makes symlink loops (`cmd -> ..`) impossible to - // recurse into. The explicit guard documents that invariant. - if ft.is_symlink() { - continue; - } - if ft.is_dir() { - if is_skipped_dir(&name) { - continue; - } - subdirs.push(entry.path()); - } else if ft.is_file() - && name.ends_with(".go") - && !name.ends_with("_test.go") - && !is_skipped_go_file(&name) - && file_is_package_main(&entry.path()).await - { - has_main = true; - } - } - if has_main { - out.push(dir.to_path_buf()); - } - for sub in subdirs { - // Never descend into the guard package dir. - if sub == root.join(GUARD_DIR) { - continue; - } - find_main_dirs_inner(root, &sub, out).await; - } - }) -} - -fn is_skipped_dir(name: &str) -> bool { - name == "vendor" - || name == ".socket" - || name == "testdata" - || name.starts_with('.') - || name.starts_with('_') -} - -/// True if the go tool itself ignores this `.go` file by name. Files whose names -/// begin with `.` or `_` are excluded from the build by `go/build` (the same -/// convention that hides `.`/`_`-prefixed directories). A `_gen.go` declaring -/// `package main` inside a library package must NOT make that dir look like a -/// main dir — doing so would drop a conflicting `package main` import file and -/// break the build (the file-level twin of the `//go:build ignore` guard). -fn is_skipped_go_file(name: &str) -> bool { - name.starts_with('.') || name.starts_with('_') -} - -/// True if a `.go` file's package clause is `package main` AND the file is not -/// excluded from the build by an `ignore` build constraint. The `ignore` tag is -/// the conventional marker for files the toolchain never compiles (e.g. `go run -/// gen.go` generators), which commonly declare `package main` while living in a -/// directory whose real package is something else — counting them would make us -/// drop a `package main` import file into a non-main package and break the build. -async fn file_is_package_main(path: &Path) -> bool { - let Ok(content) = fs::read_to_string(path).await else { - return false; - }; - // Go permits a leading UTF-8 BOM (U+FEFF) as the first code point of a source - // file. Strip it before parsing, or the package clause would read as the - // token `"\u{feff}package"` and a real `main` package would be missed — a - // fail-open: the dir gets no guard import. - let content = content.strip_prefix('\u{feff}').unwrap_or(&content); - if has_ignore_build_tag(content) { - return false; - } - // The package clause is the first non-blank, non-comment line. Strip BOTH - // comment forms first: a `//`-line comment alone is not enough, because a - // library file's doc/example block comment can contain a line that reads - // `package main` (`/* … package main … */`). Counting that would drop a - // `package main` import file into a non-main dir and break the build with - // two conflicting package clauses. We also must stop AT the package clause, - // not scan the whole file, for the same reason. - let cleaned = strip_go_comments(content); - for line in cleaned.lines() { - let t = line.trim(); - if t.is_empty() { - continue; - } - // First code line: it is the package clause. `package` + name, any - // amount of whitespace between (gofmt uses one space, but a tab or - // extra spaces are still valid Go and must not be a false negative). - let mut toks = t.split_whitespace(); - return toks.next() == Some("package") - && toks.next() == Some("main") - && toks.next().is_none(); - } - false -} - -/// Strip Go `//` line comments and `/* … */` block comments from `src`, -/// preserving newlines so line structure survives. Used only to locate the -/// package clause, which precedes any string literals, so no string-literal -/// awareness is needed. Block comments do not nest in Go. -fn strip_go_comments(src: &str) -> String { - let bytes = src.as_bytes(); - let mut out = String::with_capacity(src.len()); - let mut i = 0; - // 0 = code, 1 = line comment (to EOL), 2 = block comment (to `*/`). - let mut state = 0u8; - while i < bytes.len() { - let c = bytes[i]; - let next = bytes.get(i + 1).copied(); - match state { - 1 => { - if c == b'\n' { - state = 0; - out.push('\n'); - } - i += 1; - } - 2 => { - if c == b'*' && next == Some(b'/') { - state = 0; - i += 2; - } else { - if c == b'\n' { - out.push('\n'); - } - i += 1; - } - } - _ => { - if c == b'/' && next == Some(b'/') { - state = 1; - i += 2; - } else if c == b'/' && next == Some(b'*') { - state = 2; - i += 2; - } else { - // Preserve the byte. The package clause is ASCII; only - // pre-package code (none) or the clause itself reaches here. - out.push(c as char); - i += 1; - } - } - } - } - out -} - -/// True if the file's build-constraint header carries the `ignore` tag (either -/// `//go:build ignore` form or a `// +build ... ignore` line). Constraints must -/// precede the package clause, so scanning stops at the first non-comment line. -fn has_ignore_build_tag(content: &str) -> bool { - for line in content.lines() { - let t = line.trim(); - if t.is_empty() { - continue; - } - if let Some(expr) = t.strip_prefix("//go:build ") { - // `ignore` as a build term is never satisfied → file excluded. - if expr.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.')) - .any(|tok| tok == "ignore") - { - return true; - } - } else if let Some(rest) = t.strip_prefix("// +build ") { - if rest.split_whitespace().any(|tok| tok == "ignore") { - return true; - } - } else if !t.starts_with("//") { - break; // reached real code; constraints (if any) are all above - } - } - false -} - -// ── helpers ────────────────────────────────────────────────────────────────── - -/// True if the file is absent or its content differs from `desired`. -async fn needs_write(path: &Path, desired: &str) -> bool { - match fs::read_to_string(path).await { - Ok(c) => c != desired, - Err(_) => true, - } -} - -async fn write_file(path: &Path, body: &str) -> Result<(), String> { - if let Some(p) = path.parent() { - fs::create_dir_all(p) - .await - .map_err(|e| format!("create {}: {e}", p.display()))?; - } - fs::write(path, body) - .await - .map_err(|e| format!("write {}: {e}", path.display())) -} - -fn edit_result(kind: &'static str, path: String, result: Result) -> GoEditResult { - match result { - Ok(true) => GoEditResult { - kind, - path, - status: GoSetupStatus::Updated, - error: None, - }, - Ok(false) => GoEditResult { - kind, - path, - status: GoSetupStatus::AlreadyConfigured, - error: None, - }, - Err(e) => GoEditResult { - kind, - path, - status: GoSetupStatus::Error, - error: Some(e), - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - async fn write(path: &Path, body: &str) { - if let Some(p) = path.parent() { - fs::create_dir_all(p).await.unwrap(); - } - fs::write(path, body).await.unwrap(); - } - - #[tokio::test] - async fn test_discover_module_walks_up() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; - let sub = root.join("cmd/server"); - fs::create_dir_all(&sub).await.unwrap(); - let m = discover_go_module(&sub).await.unwrap(); - assert_eq!(m.root, root); - assert_eq!(m.module_path, "example.com/app"); - } - - #[tokio::test] - async fn test_discover_none_without_go_mod() { - let dir = tempfile::tempdir().unwrap(); - assert!(discover_go_module(dir.path()).await.is_none()); - } - - #[test] - fn test_main_import_source() { - let s = main_import_source("example.com/app"); - assert!(s.starts_with(GENERATED_MARKER)); - assert!(s.contains("package main")); - assert!(s.contains("import _ \"example.com/app/internal/socketpatchguard\"")); - } - - #[test] - fn test_guard_templates_are_socketpatchguard_package() { - // Structural pins: catch corruption of the load-bearing control flow, - // not just the package name. - assert!(GUARD_GO.contains("package socketpatchguard")); - assert!(GUARD_GO.contains("func init()")); - assert!(GUARD_GO.contains("func check() (string, bool)")); - assert!(GUARD_GO.contains("func moduleRoot() string")); - // The probe + heal must use the read-only check and the golang scope. - assert!(GUARD_GO.contains("\"apply\", \"--check\", \"--offline\", \"--ecosystems\", \"golang\"")); - assert!(GUARD_GO.contains("\"apply\", \"--offline\", \"--ecosystems\", \"golang\"")); - // Fail-closed primitives. - assert!(GUARD_GO.contains("panic(msg)")); - assert!(GUARD_GO.contains("manifest.json"), "must gate on a socket manifest"); - assert!(GUARD_TEST.contains("package socketpatchguard")); - assert!(GUARD_TEST.contains("func TestSocketPatchesApplied")); - assert!(GUARD_TEST.contains("t.Fatal(msg)")); - } - - #[test] - fn test_is_safe_module_path() { - assert!(is_safe_module_path("github.com/foo/bar")); - assert!(is_safe_module_path("example.com/x/v2")); - assert!(!is_safe_module_path("")); - assert!(!is_safe_module_path("foo bar")); // whitespace - assert!(!is_safe_module_path("foo\"bar")); // quote (import-string injection) - assert!(!is_safe_module_path("foo\nimport evil")); // newline - assert!(!is_safe_module_path("foo\\bar")); - } - - #[tokio::test] - async fn test_discover_rejects_unsafe_module_path() { - let dir = tempfile::tempdir().unwrap(); - // A go.mod whose module directive carries a quote would inject into the - // generated import string — must be rejected. - write(&dir.path().join("go.mod"), "module \"ev\\\"il\"\n\ngo 1.21\n").await; - assert!(discover_go_module(dir.path()).await.is_none()); - } - - #[tokio::test] - async fn test_ignore_tagged_main_file_is_not_a_main_dir() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; - // A real library package dir that also holds a `go run`-style generator - // tagged `//go:build ignore` with `package main`. Go excludes the - // generator from the build, so this dir's package is `lib`, NOT main — - // we must not drop a `package main` import file here. - write(&root.join("pkg/lib.go"), "package lib\n").await; - write( - &root.join("pkg/gen.go"), - "//go:build ignore\n\npackage main\n\nfunc main() {}\n", - ) - .await; - // Legacy `// +build ignore` form too. - write(&root.join("tool/tool.go"), "package tool\n").await; - write( - &root.join("tool/gen2.go"), - "// +build ignore\n\npackage main\n\nfunc main() {}\n", - ) - .await; - - let dirs = find_main_package_dirs(root).await; - assert!( - !dirs.contains(&root.join("pkg")), - "ignore-tagged generator must not make pkg/ a main dir: {dirs:?}" - ); - assert!( - !dirs.contains(&root.join("tool")), - "+build ignore generator must not make tool/ a main dir" - ); - } - - #[tokio::test] - async fn test_underscore_and_dot_prefixed_main_file_is_not_a_main_dir() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; - // A real library package dir that also holds `_`/`.`-prefixed files that - // declare `package main`. The go tool IGNORES files whose names begin - // with `.` or `_`, so this dir's package is `lib`, NOT main — we must not - // drop a `package main` import file here (it would conflict and break the - // build). Twin of the `//go:build ignore` exclusion. - write(&root.join("pkg/lib.go"), "package lib\n").await; - write( - &root.join("pkg/_gen.go"), - "package main\n\nfunc main() {}\n", - ) - .await; - write( - &root.join("pkg/.hidden.go"), - "package main\n\nfunc main() {}\n", - ) - .await; - - let dirs = find_main_package_dirs(root).await; - assert!( - !dirs.contains(&root.join("pkg")), - "`_`/`.`-prefixed main file must not make pkg/ a main dir: {dirs:?}" - ); - } - - #[tokio::test] - async fn test_bom_prefixed_main_file_is_detected() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; - // Go permits a leading UTF-8 BOM. A real `package main` file that starts - // with one must still be detected — a false negative is fail-open (the - // guard import is never wired for this binary). - write( - &root.join("cmd/app/main.go"), - "\u{feff}package main\n\nfunc main() {}\n", - ) - .await; - let dirs = find_main_package_dirs(root).await; - assert!( - dirs.contains(&root.join("cmd/app")), - "BOM-prefixed `package main` must still be detected: {dirs:?}" - ); - } - - #[test] - fn test_is_skipped_go_file() { - assert!(is_skipped_go_file("_gen.go")); - assert!(is_skipped_go_file(".hidden.go")); - assert!(!is_skipped_go_file("main.go")); - assert!(!is_skipped_go_file("gen_main.go")); // underscore not at start - } - - #[tokio::test] - async fn test_block_comment_package_main_is_not_a_main_dir() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; - // A library package whose doc comment shows an *example* `package main` - // inside a /* … */ block. The real package clause is `lib`, so we must - // NOT treat this dir as main (doing so drops a conflicting `package - // main` import file and breaks the build). - write( - &root.join("pkg/doc.go"), - "/*\nPackage lib is great.\n\nExample:\n\n\tpackage main\n\n\tfunc main() {}\n*/\npackage lib\n", - ) - .await; - // Also a real main dir as a positive control. - write(&root.join("main.go"), "package main\n\nfunc main() {}\n").await; - - let dirs = find_main_package_dirs(root).await; - assert!( - !dirs.contains(&root.join("pkg")), - "block-comment example `package main` must not make pkg/ a main dir: {dirs:?}" - ); - assert!(dirs.contains(&root.to_path_buf()), "real main dir still found"); - } - - #[tokio::test] - async fn test_package_main_with_irregular_whitespace_is_detected() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; - // Valid Go: a tab / multiple spaces between `package` and `main`. Must - // still be detected (a false negative here is fail-open: no guard). - write(&root.join("cmd/a/main.go"), "package\tmain\n\nfunc main() {}\n").await; - write(&root.join("cmd/b/main.go"), "package main\n\nfunc main() {}\n").await; - - let dirs = find_main_package_dirs(root).await; - assert!(dirs.contains(&root.join("cmd/a")), "tab-separated clause detected: {dirs:?}"); - assert!(dirs.contains(&root.join("cmd/b")), "multi-space clause detected: {dirs:?}"); - } - - #[tokio::test] - async fn test_trailing_block_comment_package_main_after_clause_ignored() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; - // The real clause is `lib`; a later block comment mentions `package - // main`. Scanning must stop at the (first) clause, not keep going. - write( - &root.join("pkg/x.go"), - "package lib\n\n/* historical note:\npackage main\n*/\n\nfunc Do() {}\n", - ) - .await; - let dirs = find_main_package_dirs(root).await; - assert!(!dirs.contains(&root.join("pkg")), "trailing block comment must not matter: {dirs:?}"); - } - - #[test] - fn test_strip_go_comments() { - assert_eq!(strip_go_comments("package main // hi\n").trim(), "package main"); - assert_eq!(strip_go_comments("/* a */package lib\n").trim(), "package lib"); - // Block comment content is removed but newlines are preserved. - let cleaned = strip_go_comments("/*\npackage main\n*/\npackage lib\n"); - let first = cleaned.lines().map(str::trim).find(|l| !l.is_empty()); - assert_eq!(first, Some("package lib")); - } - - #[tokio::test] - async fn test_find_main_dirs_terminates_on_symlink_loop() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; - write(&root.join("main.go"), "package main\n\nfunc main() {}\n").await; - std::fs::create_dir_all(root.join("sub")).unwrap(); - // A symlink loop: sub/loop -> .. (the module root). Must not recurse - // forever / overflow the stack. - #[cfg(unix)] - std::os::unix::fs::symlink(root, root.join("sub/loop")).unwrap(); - - // Completes (does not hang/overflow) and finds the real main dir. - let dirs = find_main_package_dirs(root).await; - assert!(dirs.contains(&root.to_path_buf())); - } - - #[tokio::test] - async fn test_add_then_remove_guard_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let r = add_guard(root, false).await; - assert_eq!(r.status, GoSetupStatus::Updated); - assert!(guard_files_present(root).await); - assert_eq!( - fs::read_to_string(guard_go_path(root)).await.unwrap(), - GUARD_GO - ); - // Idempotent. - assert_eq!(add_guard(root, false).await.status, GoSetupStatus::AlreadyConfigured); - // Remove. - let rr = remove_guard(root, false).await; - assert_eq!(rr.status, GoSetupStatus::Updated); - assert!(!guard_files_present(root).await); - assert!(!root.join("internal").exists(), "empty internal/ pruned"); - // Remove again → not_configured. - assert_eq!(remove_guard(root, false).await.status, GoSetupStatus::AlreadyConfigured); - } - - #[tokio::test] - async fn test_add_guard_dry_run_writes_nothing() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let r = add_guard(root, true).await; - assert_eq!(r.status, GoSetupStatus::Updated, "dry-run reports the change"); - assert!(!guard_files_present(root).await, "dry-run wrote nothing"); - } - - #[tokio::test] - async fn test_find_main_dirs_and_imports() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; - // A main package at cmd/server. - write(&root.join("cmd/server/main.go"), "package main\n\nfunc main() {}\n").await; - // A library package (not main) — must be ignored. - write(&root.join("pkg/lib/lib.go"), "package lib\n").await; - // A main package at root. - write(&root.join("main.go"), "package main\n\nfunc main() {}\n").await; - // vendored main — must be ignored. - write(&root.join("vendor/x/cmd/main.go"), "package main\n\nfunc main() {}\n").await; - - let dirs = find_main_package_dirs(root).await; - assert!(dirs.contains(&root.to_path_buf())); - assert!(dirs.contains(&root.join("cmd/server"))); - assert!(!dirs.iter().any(|d| d.starts_with(root.join("vendor")))); - assert!(!dirs.contains(&root.join("pkg/lib"))); - - // Add imports → one per main dir. - let added = add_main_imports(root, "example.com/app", false).await; - assert_eq!(added.len(), 2); - assert!(added.iter().all(|r| r.status == GoSetupStatus::Updated)); - let import_body = fs::read_to_string(import_file_path(&root.join("cmd/server"))) - .await - .unwrap(); - assert!(import_body.contains("import _ \"example.com/app/internal/socketpatchguard\"")); - - // Idempotent. - assert!(add_main_imports(root, "example.com/app", false) - .await - .iter() - .all(|r| r.status == GoSetupStatus::AlreadyConfigured)); - - // Remove only marker-bearing files. - let removed = remove_main_imports(root, false).await; - assert_eq!(removed.len(), 2); - assert!(!import_file_path(&root.join("cmd/server")).exists()); - } - - #[tokio::test] - async fn test_remove_main_imports_spares_user_file() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("main.go"), "package main\n\nfunc main() {}\n").await; - // A user file at the generated name WITHOUT our marker. - write(&import_file_path(root), "package main\n\n// mine\n").await; - let removed = remove_main_imports(root, false).await; - assert!(removed.is_empty(), "user file must not be removed"); - assert!(import_file_path(root).exists()); - } -} diff --git a/crates/socket-patch-core/src/go_setup/templates/guard.go.tmpl b/crates/socket-patch-core/src/go_setup/templates/guard.go.tmpl deleted file mode 100644 index 8128462..0000000 --- a/crates/socket-patch-core/src/go_setup/templates/guard.go.tmpl +++ /dev/null @@ -1,173 +0,0 @@ -// Code generated by `socket-patch setup`. DO NOT EDIT. -// -// socket-patch fail-closed guard for Go. -// -// Go has no build hook, so this enforces the Socket patch manifest in two ways: -// - `go test ./...` runs TestSocketPatchesApplied (the CI gate); -// - launching a binary / `go run` from within the module tree fires init(). -// -// Both verify that the committed go.mod `replace` copies under -// .socket/go-patches/ match .socket/manifest.json, by delegating to the -// socket-patch CLI (`apply --check`). On drift they self-heal (`apply`) and then -// FAIL — the current process already linked stale/unpatched code, so it must not -// be trusted. Outside a module tree (e.g. an installed binary with no .socket/), -// the guard is a silent no-op, so shipped binaries are never affected. -package socketpatchguard - -import ( - "errors" - "os" - "os/exec" - "path/filepath" - "strings" -) - -// binEnv overrides the socket-patch executable (else "socket-patch" from PATH). -const binEnv = "SOCKET_PATCH_BIN" - -func init() { - // Under `go test`, TestSocketPatchesApplied is the gate; skip init() so the - // probe (and the apply lock) is not exercised twice in the same run. - if isGoTest() { - return - } - if msg, fail := check(); fail { - panic(msg) - } -} - -func isGoTest() bool { - return len(os.Args) > 0 && strings.HasSuffix(os.Args[0], ".test") -} - -type probeResult int - -const ( - inSync probeResult = iota - drift - spawnFailed -) - -// check verifies the committed go patches against the manifest, self-healing on -// drift. It returns (message, fail); fail == true means the caller must abort. -func check() (string, bool) { - root := moduleRoot() - if root == "" { - return "", false // not in a module tree (e.g. an installed binary) — nothing to guard - } - if _, err := os.Stat(filepath.Join(root, ".socket", "manifest.json")); err != nil { - return "", false // project does not use socket-patch - } - // Register the drift-relevant files as test inputs. `go test` caches a - // result keyed on the files the test reads IN-PROCESS (the testlog - // mechanism), but NOT on files a subprocess reads — so without this the - // `apply --check` verdict below would be served from a stale cache after - // drift (a dependency bump, an un-re-applied patch, a tampered copy). These - // reads make the cache re-run the gate whenever go.mod / the manifest / a - // committed copy changes. - registerCacheInputs(root) - bin := binPath() - - switch probe(bin, root) { - case inSync: - return "", false - case spawnFailed: - return spawnMsg(bin), true - default: // drift — heal, then fail this run (it already linked stale code) - healErr := apply(bin, root) - switch probe(bin, root) { - case inSync: - return "socket-patch: go patches were out of date and have been regenerated " + - "under .socket/go-patches/ to match .socket/manifest.json. Re-run to build " + - "against the up-to-date patches (this run was failed to avoid using stale patches).", true - case spawnFailed: - return spawnMsg(bin), true - default: - msg := "socket-patch: go patches are out of sync and could NOT be reconciled by " + - "`apply` — a patched dependency may have resolved to a version the manifest does " + - "not patch, or the patch data/manifest is corrupt or missing. " + - "Run `socket-patch apply --ecosystems golang` and inspect." - if healErr != nil { - msg += "\n apply error: " + healErr.Error() - } - return msg, true - } - } -} - -func binPath() string { - if b := os.Getenv(binEnv); b != "" { - return b - } - return "socket-patch" -} - -// registerCacheInputs reads the files whose drift this guard detects, purely so -// the `go test` result cache treats them as inputs (see check). The bytes are -// discarded — the authoritative verdict comes from `apply --check`; this only -// ensures `go test` doesn't serve a stale PASS on a warm cache after drift. -func registerCacheInputs(root string) { - _, _ = os.ReadFile(filepath.Join(root, "go.mod")) - _, _ = os.ReadFile(filepath.Join(root, ".socket", "manifest.json")) - patches := filepath.Join(root, ".socket", "go-patches") - _ = filepath.WalkDir(patches, func(p string, d os.DirEntry, err error) error { - if err == nil && !d.IsDir() { - _, _ = os.ReadFile(p) - } - return nil - }) -} - -// probe runs the read-only `apply --check`. Exit 0 → inSync; a non-zero exit → -// drift; a failure to even start the process → spawnFailed (fail-closed: the -// CLI is required). -func probe(bin, root string) probeResult { - cmd := exec.Command(bin, "apply", "--check", "--offline", "--ecosystems", "golang", "--cwd", root) - cmd.Stderr = os.Stderr - err := cmd.Run() - if err == nil { - return inSync - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return drift - } - return spawnFailed -} - -// apply runs the healing `apply` (offline; the patch artifacts are committed -// under .socket/ and the pristine sources are already in the module cache). -// Heal copies pristine sources from the module cache, which `go` populates -// before it compiles — so by the time this fires the cache is warm. (A heal -// against a freshly-cleared cache can't restore the copy; that surfaces as the -// "could NOT be reconciled" failure below, never a silent pass.) -func apply(bin, root string) error { - cmd := exec.Command(bin, "apply", "--offline", "--ecosystems", "golang", "--cwd", root) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - return cmd.Run() -} - -func spawnMsg(bin string) string { - return "socket-patch: could not run `" + bin + " apply --check` to verify go patches are in " + - "sync; the socket-patch CLI is required. Install it or set " + binEnv + " to its path." -} - -// moduleRoot walks up from the current working directory to the dir containing -// go.mod, returning "" if none is found. -func moduleRoot() string { - dir, err := os.Getwd() - if err != nil { - return "" - } - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { - return "" - } - dir = parent - } -} diff --git a/crates/socket-patch-core/src/go_setup/templates/guard_test.go.tmpl b/crates/socket-patch-core/src/go_setup/templates/guard_test.go.tmpl deleted file mode 100644 index 949e8bc..0000000 --- a/crates/socket-patch-core/src/go_setup/templates/guard_test.go.tmpl +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by `socket-patch setup`. DO NOT EDIT. -package socketpatchguard - -import "testing" - -// TestSocketPatchesApplied fails — after first attempting to self-heal via -// `socket-patch apply` — when the committed go.mod `replace` copies under -// .socket/go-patches/ have drifted from .socket/manifest.json (a stray `go mod` -// change, an un-re-applied dependency bump, or a stale manifest). Make -// `go test ./...` a required CI step. -// -// check() reads go.mod, the manifest, and the committed copies IN-PROCESS, so -// `go test`'s result cache treats them as inputs and re-runs this gate whenever -// any of them change — it will NOT serve a stale PASS on a warm cache after -// drift. (The complementary always-on gate is this package's init(), which -// fires on every `go run`/binary launch.) -func TestSocketPatchesApplied(t *testing.T) { - if msg, fail := check(); fail { - t.Fatal(msg) - } -} diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index 22fe16a..af5ff75 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -1,13 +1,9 @@ pub mod api; -#[cfg(feature = "cargo")] -pub mod cargo_setup; #[cfg(feature = "composer")] pub mod composer_setup; pub mod constants; pub mod crawlers; pub mod gem_setup; -#[cfg(feature = "golang")] -pub mod go_setup; pub mod hash; pub mod manifest; pub mod package_json; diff --git a/crates/socket-patch-core/src/patch/cargo_config.rs b/crates/socket-patch-core/src/patch/cargo_config.rs deleted file mode 100644 index a89848f..0000000 --- a/crates/socket-patch-core/src/patch/cargo_config.rs +++ /dev/null @@ -1,788 +0,0 @@ -//! Read / write `/.cargo/config.toml` for the project-local -//! cargo `[patch]`-redirect backend. -//! -//! Mirrors the contract style of [`crate::pth_hook::edit`]: pure -//! `fn(&str) -> Result, String>` transforms (`Some(new)` = -//! changed, `None` = already in the desired state) wrapped by async -//! read-or-create / write helpers that honour `dry_run` and preserve the -//! user's existing formatting + comments via `toml_edit`. -//! -//! ## Ownership model (no sidecar manifest) -//! A `[patch.crates-io]` entry is *socket-owned* iff its `path` value lies -//! under `.socket/cargo-patches/`. Anything else — a `git`/`registry` source, -//! or a `path` pointing elsewhere — is user-authored and is never modified or -//! removed. This is the entire ownership signal; there is no `managed.json`. -//! -//! ## Relative-path semantics -//! A relative `path` in a config-file `[patch]` entry is resolved by cargo -//! relative to the **parent of the `.cargo/` directory** (i.e. the project -//! root), so the committed `/.socket/cargo-patches/-` -//! copy is found on any clone. `[env] SOCKET_PATCH_ROOT` is orthogonal: cargo -//! does not expand env vars inside `[patch]` paths. It is written -//! `{ value = ".", relative = true }`, which cargo resolves (same base — the -//! project root) to the absolute project root and exports for build scripts. -//! The build-time guard reads it to locate `Cargo.lock` + `.socket/` and to -//! pass `apply --cwd `. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use tokio::fs; -use toml_edit::{DocumentMut, InlineTable, Item, Table, Value}; - -/// Project-relative directory holding patched crate copies. An entry whose -/// `path` is under this prefix is how socket ownership is recognised. -pub const CARGO_PATCHES_DIR: &str = ".socket/cargo-patches"; - -/// The `[env]` key carrying the project root for the build-time guard. -const ENV_ROOT_KEY: &str = "SOCKET_PATCH_ROOT"; - -/// Info about one `[patch.crates-io]` entry, for reconcile / verify. -#[derive(Debug, Clone)] -pub struct PatchEntryInfo { - /// The `path` value as written (verbatim), or `None` for a non-path - /// source (e.g. `git`/`registry`). - pub path: Option, - /// True iff `path` is under `.socket/cargo-patches/`. - pub socket_owned: bool, -} - -/// The expected (project-root-relative) `[patch]` path for a crate copy. -/// Always forward-slashed — cargo accepts that on every platform. -pub fn expected_patch_path(name: &str, version: &str) -> String { - format!("{CARGO_PATCHES_DIR}/{name}-{version}") -} - -// ── public async API ───────────────────────────────────────────────────────── - -/// Upsert `[patch.crates-io]. = { path = "<.socket/cargo-patches/...>" }`. -/// Idempotent. Returns whether the file changed. Errors (without writing) if a -/// same-name entry exists but is user-authored. -pub async fn ensure_patch_entry( - project_root: &Path, - name: &str, - version: &str, - dry_run: bool, -) -> Result { - edit_config(project_root, dry_run, |c| { - upsert_patch_entry(c, name, version) - }) - .await -} - -/// Remove a *socket-owned* `[patch.crates-io].` entry, cleaning up empty -/// `[patch.crates-io]` / `[patch]` tables. A user-authored or absent entry is a -/// no-op. Returns whether the file changed. -pub async fn drop_patch_entry( - project_root: &Path, - name: &str, - dry_run: bool, -) -> Result { - edit_config(project_root, dry_run, |c| remove_patch_entry(c, name)).await -} - -/// Upsert `[env] SOCKET_PATCH_ROOT = { value = ".", relative = true }`. -/// Idempotent. Returns whether the file changed. -pub async fn ensure_env_root(project_root: &Path, dry_run: bool) -> Result { - edit_config(project_root, dry_run, upsert_env_root).await -} - -/// Remove the `[env] SOCKET_PATCH_ROOT` key (leaving any other `[env]` keys). -/// Returns whether the file changed. -pub async fn drop_env_root(project_root: &Path, dry_run: bool) -> Result { - edit_config(project_root, dry_run, remove_env_root).await -} - -/// Read all `[patch.crates-io]` entries. Read-only; a missing or malformed -/// config yields an empty map (callers treat that as "no managed entries"). -pub async fn read_patch_entries(project_root: &Path) -> HashMap { - let path = config_path(project_root).await; - match fs::read_to_string(&path).await { - Ok(content) => parse_patch_entries(&content), - Err(_) => HashMap::new(), - } -} - -/// Whether `.cargo/config.toml` declares `[env] SOCKET_PATCH_ROOT`. Read-only; -/// powers `setup --check` and the GitHub-App audit. A missing/malformed config -/// reads as `false`. -pub async fn env_root_present(project_root: &Path) -> bool { - let path = config_path(project_root).await; - match fs::read_to_string(&path).await { - Ok(content) => parse_has_env_root(&content), - Err(_) => false, - } -} - -fn parse_has_env_root(content: &str) -> bool { - content - .parse::() - .ok() - .and_then(|doc| { - doc.get("env") - .and_then(Item::as_table_like) - .map(|env| env.contains_key(ENV_ROOT_KEY)) - }) - .unwrap_or(false) -} - -// ── config-file resolution + read-or-create write ──────────────────────────── - -/// Resolve the config file under `/.cargo/`. Prefers an existing -/// `config.toml`, then an existing legacy `config`, else `config.toml` (created -/// on first write). -async fn config_path(project_root: &Path) -> PathBuf { - let dir = project_root.join(".cargo"); - let toml = dir.join("config.toml"); - if fs::metadata(&toml).await.is_ok() { - return toml; - } - let legacy = dir.join("config"); - if fs::metadata(&legacy).await.is_ok() { - return legacy; - } - toml -} - -/// Apply a pure transform to the config file, writing only if it changed and -/// `!dry_run`. A missing file is treated as empty (and created on write). -async fn edit_config( - project_root: &Path, - dry_run: bool, - transform: impl FnOnce(&str) -> Result, String>, -) -> Result { - let path = config_path(project_root).await; - let content = match fs::read_to_string(&path).await { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(e) => return Err(format!("read {}: {e}", path.display())), - }; - match transform(&content)? { - None => Ok(false), - Some(new) => { - if !dry_run { - if new.trim().is_empty() { - // The edit emptied the file (all socket-owned content removed - // and no user content — comments / other tables — remained). - // Delete it, and prune the now-empty `.cargo/` dir, so - // `setup --remove` restores the exact pre-setup tree rather - // than leaving an empty `.cargo/config.toml` behind - // (CLI_CONTRACT.md → "Setup command contract", property 8). - // A file with surviving user content never trims to empty, so - // this only fires for a config that was entirely socket's. - match fs::remove_file(&path).await { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("remove {}: {e}", path.display())), - } - if let Some(parent) = path.parent() { - // Best-effort: `remove_dir` only succeeds when the dir is - // empty, so a `.cargo/` holding other files is left intact. - let _ = fs::remove_dir(parent).await; - } - } else { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| format!("create {}: {e}", parent.display()))?; - } - atomic_write(&path, new.as_bytes()) - .await - .map_err(|e| format!("write {}: {e}", path.display()))?; - } - } - Ok(true) - } - } -} - -/// Atomically commit `content` to `path` via stage + fsync + rename. -/// -/// `.cargo/config.toml` is a *user-owned* file — it can hold `[build]`, -/// `[net]`, credentials-adjacent settings, and comments alongside our -/// `[patch]` / `[env]` entries. A bare `fs::write` truncates the target before -/// writing, so a crash, power loss, or `ENOSPC` mid-write would leave the -/// user's config truncated or empty, destroying content we only meant to add -/// two lines to. Instead we write a sibling stage file, fsync it, then rename -/// over the target (atomic on the same filesystem), so a reader/recovering -/// process only ever sees the complete old or the complete new bytes. Mirrors -/// the hardened writers in `patch/apply.rs` and `package_json/update.rs`. -async fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let stem = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "config.toml".to_string()); - let stage = parent.join(format!(".socket-stage-{}-{}", stem, uuid::Uuid::new_v4())); - - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&stage) - .await?; - - use tokio::io::AsyncWriteExt; - if let Err(e) = file.write_all(content).await { - let _ = fs::remove_file(&stage).await; - return Err(e); - } - if let Err(e) = file.sync_all().await { - let _ = fs::remove_file(&stage).await; - return Err(e); - } - drop(file); - - if let Err(e) = fs::rename(&stage, path).await { - let _ = fs::remove_file(&stage).await; - return Err(e); - } - - // The rename only updated the parent directory entry; fsync the directory - // so the rename itself survives a crash. Best-effort, Unix only. - #[cfg(unix)] - { - if let Ok(dir) = fs::File::open(parent).await { - let _ = dir.sync_all().await; - } - } - - Ok(()) -} - -// ── pure transforms ────────────────────────────────────────────────────────── - -/// True if a `[patch]` `path` value lies under `.socket/cargo-patches/`. -fn path_is_socket_owned(path: &str) -> bool { - let norm = path.replace('\\', "/"); - let prefix = format!("{CARGO_PATCHES_DIR}/"); - norm.starts_with(&prefix) || norm.contains(&format!("/{prefix}")) -} - -/// The `path` string of a `[patch]` entry (inline table or sub-table), if any. -fn entry_path(item: &Item) -> Option<&str> { - item.as_table_like() - .and_then(|t| t.get("path")) - .and_then(Item::as_str) -} - -/// Ensure `parent[key]` is a table, creating it if absent. Errors if present -/// but a non-table. Mirrors `pth_hook::edit::ensure_table`. -fn ensure_table<'a>( - parent: &'a mut Table, - key: &str, - implicit: bool, -) -> Result<&'a mut Table, String> { - if !parent.contains_key(key) { - let mut t = Table::new(); - t.set_implicit(implicit); - parent.insert(key, Item::Table(t)); - } - parent - .get_mut(key) - .and_then(Item::as_table_mut) - .ok_or_else(|| format!("`{key}` is not a table")) -} - -fn upsert_patch_entry(content: &str, name: &str, version: &str) -> Result, String> { - let mut doc = content - .parse::() - .map_err(|e| format!("Invalid .cargo/config.toml: {e}"))?; - let want = expected_patch_path(name, version); - - let root = doc.as_table_mut(); - // `[patch]` is a parent table that only ever holds `[patch.crates-io]`, so - // keep it implicit; `[patch.crates-io]` is the explicit one we write into. - let patch = ensure_table(root, "patch", true)?; - let crates_io = ensure_table(patch, "crates-io", false)?; - - if let Some(existing) = crates_io.get(name) { - match entry_path(existing) { - Some(p) if p == want => return Ok(None), // already correct - Some(p) if path_is_socket_owned(p) => {} // socket-owned, refresh - _ => { - return Err(format!( - "`patch.crates-io.{name}` is user-authored; refusing to overwrite" - )); - } - } - } - - let mut it = InlineTable::new(); - it.insert("path", Value::from(want)); - crates_io.insert(name, Item::Value(Value::InlineTable(it))); - Ok(Some(doc.to_string())) -} - -fn remove_patch_entry(content: &str, name: &str) -> Result, String> { - let mut doc = content - .parse::() - .map_err(|e| format!("Invalid .cargo/config.toml: {e}"))?; - - let mut removed = false; - if let Some(patch) = doc.get_mut("patch").and_then(Item::as_table_mut) { - let mut crates_io_empty = false; - if let Some(crates_io) = patch.get_mut("crates-io").and_then(Item::as_table_mut) { - if matches!(crates_io.get(name).and_then(entry_path), Some(p) if path_is_socket_owned(p)) - { - crates_io.remove(name); - removed = true; - crates_io_empty = crates_io.is_empty(); - } - } - if crates_io_empty { - patch.remove("crates-io"); - } - } - if !removed { - return Ok(None); - } - if doc - .get("patch") - .and_then(Item::as_table) - .map(Table::is_empty) - .unwrap_or(false) - { - doc.as_table_mut().remove("patch"); - } - Ok(Some(doc.to_string())) -} - -fn upsert_env_root(content: &str) -> Result, String> { - let mut doc = content - .parse::() - .map_err(|e| format!("Invalid .cargo/config.toml: {e}"))?; - let root = doc.as_table_mut(); - let env = ensure_table(root, "env", false)?; - - let already = env - .get(ENV_ROOT_KEY) - .and_then(Item::as_table_like) - .map(|t| { - t.get("value").and_then(Item::as_str) == Some(".") - && t.get("relative").and_then(Item::as_bool) == Some(true) - }) - .unwrap_or(false); - if already { - return Ok(None); - } - - let mut it = InlineTable::new(); - it.insert("value", Value::from(".")); - it.insert("relative", Value::from(true)); - env.insert(ENV_ROOT_KEY, Item::Value(Value::InlineTable(it))); - Ok(Some(doc.to_string())) -} - -fn remove_env_root(content: &str) -> Result, String> { - let mut doc = content - .parse::() - .map_err(|e| format!("Invalid .cargo/config.toml: {e}"))?; - let mut changed = false; - if let Some(env) = doc.get_mut("env").and_then(Item::as_table_mut) { - if env.remove(ENV_ROOT_KEY).is_some() { - changed = true; - } - } - if !changed { - return Ok(None); - } - if doc - .get("env") - .and_then(Item::as_table) - .map(Table::is_empty) - .unwrap_or(false) - { - doc.as_table_mut().remove("env"); - } - Ok(Some(doc.to_string())) -} - -fn parse_patch_entries(content: &str) -> HashMap { - let mut out = HashMap::new(); - let doc = match content.parse::() { - Ok(d) => d, - Err(_) => return out, - }; - let crates_io = doc - .get("patch") - .and_then(Item::as_table) - .and_then(|t| t.get("crates-io")) - .and_then(Item::as_table); - if let Some(tbl) = crates_io { - for (name, item) in tbl.iter() { - let path = entry_path(item).map(str::to_string); - let socket_owned = path.as_deref().map(path_is_socket_owned).unwrap_or(false); - out.insert(name.to_string(), PatchEntryInfo { path, socket_owned }); - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parse(s: &str) -> DocumentMut { - s.parse::().unwrap() - } - - // ── path ownership ─────────────────────────────────────────────── - #[test] - fn test_is_socket_owned() { - assert!(path_is_socket_owned(".socket/cargo-patches/cfg-if-1.0.0")); - assert!(path_is_socket_owned("./.socket/cargo-patches/x-1.0.0")); // contains "/.socket/.." - assert!(path_is_socket_owned("sub/.socket/cargo-patches/x-1.0.0")); - assert!(path_is_socket_owned(r".socket\cargo-patches\x-1.0.0")); // backslash normalised - assert!(!path_is_socket_owned("vendor/cfg-if")); - assert!(!path_is_socket_owned("../cfg-if")); - assert!(!path_is_socket_owned("/abs/.socketX/cargo-patches/x")); - } - - // ── upsert ─────────────────────────────────────────────────────── - #[test] - fn test_upsert_into_empty_creates_entry() { - let out = upsert_patch_entry("", "cfg-if", "1.0.0").unwrap().unwrap(); - let doc = parse(&out); - assert_eq!( - entry_path(&doc["patch"]["crates-io"]["cfg-if"]), - Some(".socket/cargo-patches/cfg-if-1.0.0") - ); - // Idempotent: a second upsert is a no-op. - assert!(upsert_patch_entry(&out, "cfg-if", "1.0.0") - .unwrap() - .is_none()); - } - - #[test] - fn test_upsert_preserves_user_content() { - let toml = "# my config\n[build]\njobs = 4\n\n[patch.crates-io]\nother = { git = \"https://example.com/o.git\" }\n"; - let out = upsert_patch_entry(toml, "cfg-if", "1.0.0") - .unwrap() - .unwrap(); - assert!(out.contains("# my config")); - assert!(out.contains("jobs = 4")); - let doc = parse(&out); - // The user's git entry survives alongside ours. - assert_eq!( - doc["patch"]["crates-io"]["other"] - .as_table_like() - .and_then(|t| t.get("git")) - .and_then(Item::as_str), - Some("https://example.com/o.git") - ); - assert_eq!( - entry_path(&doc["patch"]["crates-io"]["cfg-if"]), - Some(".socket/cargo-patches/cfg-if-1.0.0") - ); - } - - #[test] - fn test_upsert_refuses_user_authored_same_name() { - let toml = "[patch.crates-io]\ncfg-if = { git = \"https://example.com/c.git\" }\n"; - assert!(upsert_patch_entry(toml, "cfg-if", "1.0.0").is_err()); - } - - #[test] - fn test_upsert_refreshes_socket_owned_version_bump() { - let toml = - "[patch.crates-io]\ncfg-if = { path = \".socket/cargo-patches/cfg-if-1.0.0\" }\n"; - let out = upsert_patch_entry(toml, "cfg-if", "1.0.1") - .unwrap() - .unwrap(); - let doc = parse(&out); - assert_eq!( - entry_path(&doc["patch"]["crates-io"]["cfg-if"]), - Some(".socket/cargo-patches/cfg-if-1.0.1") - ); - } - - // ── remove ─────────────────────────────────────────────────────── - #[test] - fn test_remove_socket_owned_cleans_empty_tables() { - let toml = - "[patch.crates-io]\ncfg-if = { path = \".socket/cargo-patches/cfg-if-1.0.0\" }\n"; - let out = remove_patch_entry(toml, "cfg-if").unwrap().unwrap(); - assert!(!out.contains("cfg-if")); - // Empty [patch.crates-io] and [patch] are pruned. - assert!(!out.contains("[patch")); - } - - #[test] - fn test_remove_leaves_user_entry_and_table() { - let toml = "[patch.crates-io]\ncfg-if = { path = \".socket/cargo-patches/cfg-if-1.0.0\" }\nother = { git = \"https://example.com/o.git\" }\n"; - let out = remove_patch_entry(toml, "cfg-if").unwrap().unwrap(); - let doc = parse(&out); - assert!(doc["patch"]["crates-io"].get("cfg-if").is_none()); - assert!(doc["patch"]["crates-io"].get("other").is_some()); - } - - #[test] - fn test_remove_user_authored_same_name_is_noop() { - let toml = "[patch.crates-io]\ncfg-if = { git = \"https://example.com/c.git\" }\n"; - assert!(remove_patch_entry(toml, "cfg-if").unwrap().is_none()); - } - - #[test] - fn test_remove_absent_is_noop() { - assert!(remove_patch_entry("[build]\njobs = 2\n", "cfg-if") - .unwrap() - .is_none()); - } - - // ── env root ───────────────────────────────────────────────────── - #[test] - fn test_env_root_upsert_relative() { - let out = upsert_env_root("").unwrap().unwrap(); - let doc = parse(&out); - let env = doc["env"][ENV_ROOT_KEY].as_table_like().unwrap(); - assert_eq!(env.get("value").and_then(Item::as_str), Some(".")); - assert_eq!(env.get("relative").and_then(Item::as_bool), Some(true)); - // Idempotent. - assert!(upsert_env_root(&out).unwrap().is_none()); - } - - #[test] - fn test_env_root_remove_leaves_other_keys() { - let toml = - "[env]\nMY_VAR = \"x\"\nSOCKET_PATCH_ROOT = { value = \".socket\", relative = true }\n"; - let out = remove_env_root(toml).unwrap().unwrap(); - let doc = parse(&out); - assert!(doc["env"].get(ENV_ROOT_KEY).is_none()); - assert_eq!(doc["env"]["MY_VAR"].as_str(), Some("x")); - } - - #[test] - fn test_env_root_remove_prunes_empty_table() { - let toml = "[env]\nSOCKET_PATCH_ROOT = { value = \".socket\", relative = true }\n"; - let out = remove_env_root(toml).unwrap().unwrap(); - assert!(!out.contains("[env]")); - } - - // ── read_patch_entries / parse ─────────────────────────────────── - #[test] - fn test_parse_entries_classifies_ownership() { - let toml = "[patch.crates-io]\nmine = { path = \".socket/cargo-patches/mine-1.0.0\" }\nyours = { git = \"https://example.com/y.git\" }\ntheirs = { path = \"vendor/theirs\" }\n"; - let entries = parse_patch_entries(toml); - assert!(entries["mine"].socket_owned); - assert!(!entries["yours"].socket_owned); - assert_eq!(entries["yours"].path, None); - assert!(!entries["theirs"].socket_owned); - assert_eq!(entries["theirs"].path.as_deref(), Some("vendor/theirs")); - } - - #[test] - fn test_parse_entries_handles_subtable_form() { - let toml = "[patch.crates-io.mine]\npath = \".socket/cargo-patches/mine-1.0.0\"\n"; - let entries = parse_patch_entries(toml); - assert!(entries["mine"].socket_owned); - } - - #[test] - fn test_parse_malformed_is_empty() { - assert!(parse_patch_entries("this is = = not toml [[[").is_empty()); - } - - // ── formatting preservation ────────────────────────────────────── - #[test] - fn test_comments_and_indentation_preserved() { - // `.cargo/config.toml` is a managed file; toml_edit faithfully keeps - // comments and unrelated tables (it does NOT promise CRLF round-trips, - // which is harmless for a generated config). - let toml = "# socket-managed config\n[net]\nretry = 3 # keep retries\n"; - let out = upsert_patch_entry(toml, "cfg-if", "1.0.0") - .unwrap() - .unwrap(); - assert!(out.contains("# socket-managed config")); - assert!(out.contains("retry = 3 # keep retries")); - assert!(parse(&out)["patch"]["crates-io"].get("cfg-if").is_some()); - } - - // ── async wrappers ─────────────────────────────────────────────── - #[tokio::test] - async fn test_ensure_dry_run_does_not_create() { - let dir = tempfile::tempdir().unwrap(); - let changed = ensure_patch_entry(dir.path(), "cfg-if", "1.0.0", true) - .await - .unwrap(); - assert!(changed, "dry-run reports the change it would make"); - assert!( - !dir.path().join(".cargo/config.toml").exists(), - "dry-run must not create the file" - ); - } - - #[tokio::test] - async fn test_ensure_then_read_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - assert!(ensure_patch_entry(dir.path(), "cfg-if", "1.0.0", false) - .await - .unwrap()); - assert!(ensure_env_root(dir.path(), false).await.unwrap()); - let entries = read_patch_entries(dir.path()).await; - assert!(entries["cfg-if"].socket_owned); - assert_eq!( - entries["cfg-if"].path.as_deref(), - Some(".socket/cargo-patches/cfg-if-1.0.0") - ); - // Re-running is a no-op (idempotent on disk). - assert!(!ensure_patch_entry(dir.path(), "cfg-if", "1.0.0", false) - .await - .unwrap()); - // Drop everything. - assert!(drop_patch_entry(dir.path(), "cfg-if", false).await.unwrap()); - assert!(drop_env_root(dir.path(), false).await.unwrap()); - assert!(read_patch_entries(dir.path()).await.is_empty()); - } - - #[tokio::test] - async fn test_prefers_existing_legacy_config() { - let dir = tempfile::tempdir().unwrap(); - let cargo_dir = dir.path().join(".cargo"); - fs::create_dir_all(&cargo_dir).await.unwrap(); - // Only a legacy `config` (no extension) exists. - fs::write(cargo_dir.join("config"), "[build]\njobs = 2\n") - .await - .unwrap(); - assert!(ensure_patch_entry(dir.path(), "cfg-if", "1.0.0", false) - .await - .unwrap()); - // We wrote into the legacy file, not a fresh config.toml. - assert!(!cargo_dir.join("config.toml").exists()); - let body = fs::read_to_string(cargo_dir.join("config")).await.unwrap(); - assert!(body.contains("cfg-if")); - assert!(body.contains("jobs = 2")); - } - - // ── exact-restore: emptied socket-created config is deleted (property 8) ── - #[tokio::test] - async fn test_drop_env_root_deletes_socket_created_config_and_dir() { - let dir = tempfile::tempdir().unwrap(); - // No `.cargo/` before setup. - assert!(!dir.path().join(".cargo").exists()); - // setup creates `.cargo/config.toml` holding only [env] SOCKET_PATCH_ROOT. - assert!(ensure_env_root(dir.path(), false).await.unwrap()); - assert!(dir.path().join(".cargo/config.toml").exists()); - // remove empties it → both the file and the now-empty `.cargo/` are gone. - assert!(drop_env_root(dir.path(), false).await.unwrap()); - assert!( - !dir.path().join(".cargo/config.toml").exists(), - "an emptied socket-created config must be deleted, not left empty" - ); - assert!( - !dir.path().join(".cargo").exists(), - "the now-empty .cargo/ dir must be pruned" - ); - } - - #[tokio::test] - async fn test_drop_env_root_keeps_config_with_user_content() { - let dir = tempfile::tempdir().unwrap(); - let cargo_dir = dir.path().join(".cargo"); - fs::create_dir_all(&cargo_dir).await.unwrap(); - // A user config carrying a [build] table alongside our env entry. - fs::write( - cargo_dir.join("config.toml"), - "[build]\njobs = 4\n\n[env]\nSOCKET_PATCH_ROOT = { value = \".\", relative = true }\n", - ) - .await - .unwrap(); - assert!(drop_env_root(dir.path(), false).await.unwrap()); - // The file survives (user content remains); only our key is gone. - let body = fs::read_to_string(cargo_dir.join("config.toml")).await.unwrap(); - assert!(body.contains("jobs = 4"), "user [build] table must be preserved"); - assert!(!body.contains("SOCKET_PATCH_ROOT")); - } - - // ── atomic-commit: stage+rename leaves no litter, never truncates ──────── - /// List the non-hidden-temp entries left under `.cargo/` after a commit. The - /// atomic writer stages a `.socket-stage-*` sibling and renames it over the - /// target; if any stage file survives, the commit aborted mid-flight (or the - /// rename was actually a copy) — both are litter the user would have to clean. - async fn stage_litter(cargo_dir: &Path) -> Vec { - let mut names = Vec::new(); - let mut rd = fs::read_dir(cargo_dir).await.unwrap(); - while let Some(e) = rd.next_entry().await.unwrap() { - let n = e.file_name().to_string_lossy().into_owned(); - if n.contains("socket-stage") { - names.push(n); - } - } - names - } - - #[tokio::test] - async fn test_commit_leaves_no_stage_litter() { - let dir = tempfile::tempdir().unwrap(); - assert!(ensure_patch_entry(dir.path(), "cfg-if", "1.0.0", false) - .await - .unwrap()); - let cargo_dir = dir.path().join(".cargo"); - assert!( - stage_litter(&cargo_dir).await.is_empty(), - "create-path commit must rename the stage file away, not leave it" - ); - // A second, mutating upsert (version bump) must also clean up after itself. - assert!(ensure_patch_entry(dir.path(), "cfg-if", "1.0.1", false) - .await - .unwrap()); - assert!( - stage_litter(&cargo_dir).await.is_empty(), - "overwrite-path commit must rename the stage file away, not leave it" - ); - } - - #[tokio::test] - async fn test_commit_overwrites_existing_user_config_in_place() { - // The dangerous case the atomic writer protects: an existing user config - // we must edit in place. A non-atomic truncate-then-write would risk - // leaving this empty on a crash; here we assert the user content survives - // and the new entry lands, with no stage file left behind. - let dir = tempfile::tempdir().unwrap(); - let cargo_dir = dir.path().join(".cargo"); - fs::create_dir_all(&cargo_dir).await.unwrap(); - fs::write( - cargo_dir.join("config.toml"), - "# user comment\n[build]\njobs = 7\n\n[net]\nretry = 5\n", - ) - .await - .unwrap(); - - assert!(ensure_patch_entry(dir.path(), "cfg-if", "1.0.0", false) - .await - .unwrap()); - - let body = fs::read_to_string(cargo_dir.join("config.toml")) - .await - .unwrap(); - assert!(body.contains("# user comment"), "comment preserved"); - assert!(body.contains("jobs = 7"), "[build] preserved"); - assert!(body.contains("retry = 5"), "[net] preserved"); - assert!(body.contains("cfg-if"), "our entry was added"); - assert!( - stage_litter(&cargo_dir).await.is_empty(), - "in-place overwrite must not leave a stage file" - ); - } - - #[tokio::test] - async fn test_drop_env_root_keeps_nonempty_cargo_dir() { - let dir = tempfile::tempdir().unwrap(); - let cargo_dir = dir.path().join(".cargo"); - fs::create_dir_all(&cargo_dir).await.unwrap(); - // A sibling file (e.g. credentials) means `.cargo/` must survive even - // though our config is emptied + deleted. - fs::write(cargo_dir.join("credentials.toml"), "[registry]\ntoken = \"x\"\n") - .await - .unwrap(); - assert!(ensure_env_root(dir.path(), false).await.unwrap()); - assert!(drop_env_root(dir.path(), false).await.unwrap()); - assert!( - !cargo_dir.join("config.toml").exists(), - "emptied config is deleted" - ); - assert!( - cargo_dir.exists() && cargo_dir.join("credentials.toml").exists(), - ".cargo/ is kept because it still holds the user's credentials file" - ); - } -} diff --git a/crates/socket-patch-core/src/patch/cargo_redirect.rs b/crates/socket-patch-core/src/patch/cargo_redirect.rs deleted file mode 100644 index 7877dd3..0000000 --- a/crates/socket-patch-core/src/patch/cargo_redirect.rs +++ /dev/null @@ -1,1348 +0,0 @@ -//! Project-local cargo `[patch]`-redirect engine (local mode only). -//! -//! Instead of patching crates in place in the shared registry (the `--global` -//! path, still served by [`crate::patch::sidecars::cargo`]), this materialises -//! a project-local **patched copy** of each crate under -//! `/.socket/cargo-patches/-/` and points cargo at it with -//! a `[patch.crates-io]` entry in `/.cargo/config.toml`. Patches become -//! project-scoped, the `.cargo-checksum.json` rewrite disappears (a `[patch]` -//! path-dep is not checksum-verified), and removal is clean (drop the entry → -//! cargo falls back to the pristine registry). -//! -//! The copy is produced by **delegating to the hardened -//! [`apply_package_patch`] pipeline** pointed at the fresh copy, so all the -//! verify → package/diff/blob → atomic-write machinery is reused unchanged. - -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -use crate::manifest::schema::{PatchFileInfo, PatchManifest}; -use crate::patch::apply::{ - apply_package_patch, normalize_file_path, ApplyResult, PatchSources, VerifyResult, VerifyStatus, -}; -use crate::patch::file_hash::compute_file_git_sha256; -use crate::utils::purl::{build_cargo_purl, parse_cargo_purl}; - -use super::cargo_config::{self, expected_patch_path, CARGO_PATCHES_DIR}; -use super::copy_tree::{fresh_copy, remove_tree}; - -/// A discrepancy between the committed redirect artifacts and the manifest, -/// reported by [`verify_cargo_redirect_state`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Drift { - /// No patched-copy directory exists for an in-scope PURL. - MissingCopy { purl: String }, - /// A patched file in the copy does not hash to its manifest `afterHash` - /// (`found` is `None` when the file is missing/unreadable). - StaleCopy { - purl: String, - file: String, - expected: String, - found: Option, - }, - /// No managed `[patch.crates-io]` entry exists for an in-scope PURL. - MissingEntry { purl: String }, - /// A socket-owned `[patch.crates-io]` entry exists but its `path` points at a - /// different version's copy than the manifest desires. cargo keys `[patch]` by - /// name (the version lives in the path), so the build would redirect to the - /// wrong/unused copy and silently link unpatched code while the copy-hash - /// checks pass. - WrongEntryPath { - purl: String, - expected: String, - found: Option, - }, - /// A socket-owned `[patch.crates-io]` entry exists with no desired PURL. - OrphanEntry { name: String }, - /// `Cargo.lock` resolved this crate to version(s) that do NOT include the - /// patched version, so cargo's `[patch]` (keyed by name+version) is unused - /// and the build silently links the UNPATCHED registry crate. - ResolvedVersionMismatch { - purl: String, - patched_version: String, - locked_versions: Vec, - }, -} - -impl std::fmt::Display for Drift { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Drift::MissingCopy { purl } => { - write!(f, "missing patched copy for {purl}") - } - Drift::StaleCopy { - purl, - file, - expected, - found, - } => write!( - f, - "stale copy for {purl}: {file} expected {expected}, found {}", - found.as_deref().unwrap_or("") - ), - Drift::MissingEntry { purl } => { - write!(f, "missing [patch.crates-io] entry for {purl}") - } - Drift::WrongEntryPath { - purl, - expected, - found, - } => write!( - f, - "[patch.crates-io] entry for {purl} points at {} but should be {expected} \ - — cargo would redirect to the wrong copy and link the UNPATCHED crate", - found.as_deref().unwrap_or("") - ), - Drift::OrphanEntry { name } => { - write!( - f, - "orphan [patch.crates-io] entry `{name}` (no patch in manifest)" - ) - } - Drift::ResolvedVersionMismatch { - purl, - patched_version, - locked_versions, - } => write!( - f, - "{purl}: patched version {patched_version} is not the resolved version \ - (Cargo.lock has {}) — cargo would link the UNPATCHED crate", - locked_versions.join(", ") - ), - } - } -} - -/// Parse `/Cargo.lock` into `name -> {resolved versions}`. Returns `None` -/// when the lockfile is absent, unreadable, unparseable, or missing the -/// `[[package]]` array — in every such case the version cross-check is skipped -/// (a malformed lock would itself break a real `cargo build`, so this only -/// affects an offline audit on a clone where cargo isn't invoked). Reads only -/// the project lockfile: no registry, no network. -async fn read_locked_versions(project_root: &Path) -> Option>> { - let content = tokio::fs::read_to_string(project_root.join("Cargo.lock")) - .await - .ok()?; - let doc = content.parse::().ok()?; - let pkgs = doc.get("package")?.as_array_of_tables()?; - let mut map: HashMap> = HashMap::new(); - for t in pkgs.iter() { - let name = t.get("name").and_then(|i| i.as_str()); - let ver = t.get("version").and_then(|i| i.as_str()); - if let (Some(n), Some(v)) = (name, ver) { - map.entry(n.to_string()).or_default().insert(v.to_string()); - } - } - Some(map) -} - -/// True if a crate is vendored under `/vendor/` (in either the -/// `-/` or bare `/` layout the cargo crawler probes). -/// Vendored crates are patched in place, so they are excluded from redirect -/// verification. -async fn is_vendored(project_root: &Path, name: &str, version: &str) -> bool { - let vendor = project_root.join("vendor"); - for candidate in [vendor.join(format!("{name}-{version}")), vendor.join(name)] { - if tokio::fs::metadata(&candidate) - .await - .map(|m| m.is_dir()) - .unwrap_or(false) - { - return true; - } - } - false -} - -/// The project-relative copy dir for a crate. -fn copy_dir_for(project_root: &Path, name: &str, version: &str) -> PathBuf { - project_root - .join(CARGO_PATCHES_DIR) - .join(format!("{name}-{version}")) -} - -/// A crate `name` / `version` keys the on-disk copy dir -/// (`.socket/cargo-patches/-/`) and the `[patch]` path, so it -/// must be a single safe path segment. A component containing a path separator -/// or `..` would let a tampered manifest PURL escape `.socket/cargo-patches/` -/// and make `apply` copy + write the patched tree (or `rollback` delete a tree) -/// at an arbitrary filesystem location outside the project. Cargo crate names -/// are `[A-Za-z0-9_-]` and versions are semver, so neither can legitimately -/// contain any of these — reject them fail-closed before touching the disk. -fn is_safe_redirect_component(s: &str) -> bool { - !s.is_empty() - && s != "." - && s != ".." - && !s.contains('/') - && !s.contains('\\') - && !s.contains('\0') -} - -/// Materialise a project-local patched copy and wire up the `[patch]` redirect. -/// -/// * `pristine_src` — the pristine registry/vendor source dir (the crawler's -/// `pkg_path`). It is copied, never mutated. -/// * `project_root` — the consumer project (`args.common.cwd`). -/// -/// `dry_run` writes nothing (it verifies against `pristine_src` for an accurate -/// report). `force` is forwarded to [`apply_package_patch`]. -#[allow(clippy::too_many_arguments)] -pub async fn apply_cargo_redirect( - purl: &str, - name: &str, - version: &str, - pristine_src: &Path, - project_root: &Path, - files: &HashMap, - sources: &PatchSources<'_>, - uuid: Option<&str>, - dry_run: bool, - force: bool, -) -> ApplyResult { - // SECURITY: refuse coordinates that would escape `.socket/cargo-patches/`. - // A `..`/separator in `name` or `version` (a tampered manifest PURL) would - // otherwise make `fresh_copy` + the apply pipeline write the patched tree to - // an arbitrary location. Fail-closed before any disk access. - if !is_safe_redirect_component(name) || !is_safe_redirect_component(version) { - return synthesized_result( - purl, - Path::new(""), - Vec::new(), - false, - Some(format!( - "refusing cargo redirect for unsafe coordinates `{name}`/`{version}` \ - (a path separator or `..` would escape .socket/cargo-patches/)" - )), - ); - } - - let copy_dir = copy_dir_for(project_root, name, version); - - // A redirect with no files to patch is meaningless: no-op success, no - // config write. - if files.is_empty() { - return synthesized_result(purl, ©_dir, Vec::new(), true, None); - } - - if dry_run { - // Verify (read-only) against the pristine source — apply_package_patch - // never writes when dry_run — for an accurate "would patch" report, - // without creating the copy or editing config. - let mut result = - apply_package_patch(purl, pristine_src, files, sources, uuid, true, force).await; - result.package_path = copy_dir.display().to_string(); - return result; - } - - // Hot path: already in sync → touch nothing, so cargo's source fingerprint - // stays stable across repeated applies (the guard re-runs apply on most - // "deps changed" builds). - if redirect_in_sync(©_dir, files, project_root, name, version).await { - let verified = files.keys().map(|f| already_patched_verify(f)).collect(); - return synthesized_result(purl, ©_dir, verified, true, None); - } - - // Fresh copy pristine → copy_dir, excluding any `.cargo-checksum.json`. - if let Err(e) = fresh_copy(pristine_src, ©_dir, Some(".cargo-checksum.json")).await { - return synthesized_result( - purl, - ©_dir, - Vec::new(), - false, - Some(format!("failed to copy pristine source: {e}")), - ); - } - - // Delegate to the hardened pipeline, pointed at the copy. - let mut result = apply_package_patch(purl, ©_dir, files, sources, uuid, false, force).await; - result.package_path = copy_dir.display().to_string(); - - if !result.success { - // Don't leave a half-built copy that verify/reconcile would misjudge. - let _ = remove_tree(©_dir).await; - return result; - } - - // A path-dep copy must never carry a checksum sidecar (its presence would - // make dispatch_fixup rewrite it). The fresh copy excluded it; enforce the - // invariant defensively. - let _ = tokio::fs::remove_file(copy_dir.join(".cargo-checksum.json")).await; - debug_assert!( - result.sidecar.is_none(), - "redirect copy must not produce a cargo sidecar" - ); - - // Wire up the [patch.crates-io] entry. This is load-bearing: without it - // cargo won't redirect to the copy, so a failure here fails the apply. - if let Err(e) = cargo_config::ensure_patch_entry(project_root, name, version, false).await { - result.success = false; - result.error = Some(format!("failed to update .cargo/config.toml: {e}")); - return result; - } - // [env] SOCKET_PATCH_ROOT is only needed by the build-time guard; best-effort. - let _ = cargo_config::ensure_env_root(project_root, false).await; - - result -} - -/// Drop the managed `[patch]` entry + patched copy for a cargo PURL. Removes -/// only *patch* state — never the `[env] SOCKET_PATCH_ROOT` setup state (that -/// is owned by `setup` / `setup --remove`), so a `rollback` leaves the guard -/// wiring intact. -pub async fn remove_cargo_redirect( - purl: &str, - project_root: &Path, - dry_run: bool, -) -> Result<(), std::io::Error> { - let (name, version) = parse_cargo_purl(purl).ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("not a cargo purl: {purl}"), - ) - })?; - - // SECURITY: the copy dir is `.socket/cargo-patches/-/` and is - // about to be `remove_tree`d. An unsafe `name`/`version` (`..`/separator) - // would target a tree outside the project for deletion — refuse it. - if !is_safe_redirect_component(name) || !is_safe_redirect_component(version) { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("refusing to remove cargo redirect for unsafe coordinates: {purl}"), - )); - } - - cargo_config::drop_patch_entry(project_root, name, dry_run) - .await - .map_err(std::io::Error::other)?; - - if !dry_run { - let copy_dir = copy_dir_for(project_root, name, version); - let _ = remove_tree(©_dir).await; // ignore NotFound - } - // NOTE: `[env] SOCKET_PATCH_ROOT` is intentionally left in place — it is - // setup state, removed only by `setup --remove`, not by a patch rollback. - Ok(()) -} - -/// Prune socket-owned `[patch]` entries + copy dirs that are no longer in -/// `desired` (patches dropped from the manifest). Returns the removed PURLs. -pub async fn reconcile_cargo_redirects( - project_root: &Path, - desired: &HashSet, - dry_run: bool, -) -> Vec { - let desired_names: HashSet<&str> = desired - .iter() - .filter_map(|p| parse_cargo_purl(p).map(|(n, _)| n)) - .collect(); - - let mut removed: Vec = Vec::new(); - - // (a) Orphan socket-owned [patch.crates-io] entries. - let entries = cargo_config::read_patch_entries(project_root).await; - for (name, info) in &entries { - if info.socket_owned && !desired_names.contains(name.as_str()) { - let _ = cargo_config::drop_patch_entry(project_root, name, dry_run).await; - if let Some(purl) = purl_from_entry_path(info.path.as_deref()) { - if !removed.contains(&purl) { - removed.push(purl); - } - } - } - } - - // (b) Orphan copy dirs not referenced by a desired PURL. - let copies_root = project_root.join(CARGO_PATCHES_DIR); - if let Ok(mut rd) = tokio::fs::read_dir(&copies_root).await { - while let Ok(Some(entry)) = rd.next_entry().await { - if !entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) { - continue; - } - let dir_name = entry.file_name().to_string_lossy().to_string(); - if let Some(purl) = purl_from_dir_name(&dir_name) { - if !desired.contains(&purl) { - if !dry_run { - let _ = remove_tree(&entry.path()).await; - } - if !removed.contains(&purl) { - removed.push(purl); - } - } - } - } - } - - // NOTE: `[env] SOCKET_PATCH_ROOT` is intentionally NOT dropped here — it is - // setup state (owned by `setup` / `setup --remove`), independent of whether - // any redirects currently remain. - - removed -} - -/// Registry-independent verification for `apply --check` (CI / GitHub-App -/// auditing). Reads **only** the manifest, the committed copies, and -/// `.cargo/config.toml` — never the registry, no network, no `pristine_src` — -/// so it works on a fresh clone / airgapped CI where the registry crate isn't -/// extracted but the copies are present. -pub async fn verify_cargo_redirect_state( - project_root: &Path, - manifest: &PatchManifest, - desired: &HashSet, -) -> Result<(), Vec> { - let mut drifts = Vec::new(); - let entries = cargo_config::read_patch_entries(project_root).await; - // Resolved versions from Cargo.lock (None ⇒ no lockfile ⇒ skip the version - // cross-check). Read once, project-local, offline. - let locked = read_locked_versions(project_root).await; - let desired_names: HashSet<&str> = desired - .iter() - .filter_map(|p| parse_cargo_purl(p).map(|(n, _)| n)) - .collect(); - - for purl in desired { - let Some((name, version)) = parse_cargo_purl(purl) else { - continue; - }; - let Some(record) = manifest.patches.get(purl) else { - continue; - }; - // SECURITY: skip coordinates that would resolve the copy dir outside - // `.socket/cargo-patches/` (a tampered manifest); never stat/hash files - // outside the project tree during an audit. Mirrors the apply guard. - if !is_safe_redirect_component(name) || !is_safe_redirect_component(version) { - continue; - } - // Vendored crates are patched in place, not redirected, so they have - // no copy/entry by design — skip them. The crawler stores vendored - // crates under `/vendor/` in either `-/` or bare - // `/` layout; check both. The `vendor/` dir is committed, so this - // stays registry- and network-independent. - if is_vendored(project_root, name, version).await { - continue; - } - - // Cargo.lock cross-check: if the crate resolved to version(s) that do - // NOT include the patched version, cargo's `[patch]` is unused and the - // build links the unpatched crate — a silent-stale hole the copy/entry - // checks below can't see. (A crate absent from the lock is harmless — - // it isn't built — so we only flag a present-but-different resolution.) - // - // Known limitation: when the graph resolves the crate to MULTIPLE - // versions and the patched version is one of them, this passes — but any - // co-resolved *other* version still links the unpatched registry crate - // (cargo's single `[patch]` entry only redirects the matching version). - // We do not flag that, since duplicate-version graphs are common and - // patching only one version is often intentional. - if let Some(versions) = locked.as_ref().and_then(|l| l.get(name)) { - if !versions.contains(version) { - let mut locked_versions: Vec = versions.iter().cloned().collect(); - locked_versions.sort(); - drifts.push(Drift::ResolvedVersionMismatch { - purl: purl.clone(), - patched_version: version.to_string(), - locked_versions, - }); - } - } - - let copy_dir = copy_dir_for(project_root, name, version); - - if tokio::fs::metadata(©_dir).await.is_err() { - drifts.push(Drift::MissingCopy { purl: purl.clone() }); - } else { - for (file_name, info) in &record.files { - let path = copy_dir.join(normalize_file_path(file_name)); - match compute_file_git_sha256(&path).await { - Ok(h) if h == info.after_hash => {} - Ok(h) => drifts.push(Drift::StaleCopy { - purl: purl.clone(), - file: file_name.clone(), - expected: info.after_hash.clone(), - found: Some(h), - }), - Err(_) => drifts.push(Drift::StaleCopy { - purl: purl.clone(), - file: file_name.clone(), - expected: info.after_hash.clone(), - found: None, - }), - } - } - } - - match entries.get(name) { - Some(info) if info.socket_owned => { - // The entry must also point at THIS version's copy. cargo keys - // `[patch]` by name (the version lives in the path), so a - // socket-owned entry left pointing at another version's copy - // (an aborted/partial apply, a bad merge, a hand-edit) silently - // redirects cargo to the wrong/unused copy while the copy-hash - // checks above pass. Mirror the apply hot path (`redirect_in_sync`). - let expected = expected_patch_path(name, version); - if info.path.as_deref() != Some(expected.as_str()) { - drifts.push(Drift::WrongEntryPath { - purl: purl.clone(), - expected, - found: info.path.clone(), - }); - } - } - _ => drifts.push(Drift::MissingEntry { purl: purl.clone() }), - } - } - - for (name, info) in &entries { - if info.socket_owned && !desired_names.contains(name.as_str()) { - drifts.push(Drift::OrphanEntry { name: name.clone() }); - } - } - - if drifts.is_empty() { - Ok(()) - } else { - Err(drifts) - } -} - -// ── helpers ────────────────────────────────────────────────────────────────── - -/// True if the copy exists, every patched file in it already hashes to its -/// `afterHash`, and the config entry points at the expected copy path. -async fn redirect_in_sync( - copy_dir: &Path, - files: &HashMap, - project_root: &Path, - name: &str, - version: &str, -) -> bool { - if tokio::fs::metadata(copy_dir).await.is_err() { - return false; - } - for (file_name, info) in files { - let path = copy_dir.join(normalize_file_path(file_name)); - match compute_file_git_sha256(&path).await { - Ok(h) if h == info.after_hash => {} - _ => return false, - } - } - let entries = cargo_config::read_patch_entries(project_root).await; - match entries.get(name) { - Some(info) => info.path.as_deref() == Some(expected_patch_path(name, version).as_str()), - None => false, - } -} - -fn synthesized_result( - package_key: &str, - copy_dir: &Path, - files_verified: Vec, - success: bool, - error: Option, -) -> ApplyResult { - ApplyResult { - package_key: package_key.to_string(), - package_path: copy_dir.display().to_string(), - success, - files_verified, - files_patched: Vec::new(), - applied_via: HashMap::new(), - error, - sidecar: None, - } -} - -fn already_patched_verify(file: &str) -> VerifyResult { - VerifyResult { - file: file.to_string(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - } -} - -fn purl_from_entry_path(path: Option<&str>) -> Option { - let norm = path?.replace('\\', "/"); - let dir_name = norm.rsplit('/').next()?; - purl_from_dir_name(dir_name) -} - -fn purl_from_dir_name(dir_name: &str) -> Option { - let (name, version) = crate::crawlers::CargoCrawler::parse_dir_name_version(dir_name)?; - Some(build_cargo_purl(&name, &version)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::hash::git_sha256::compute_git_sha256_from_bytes; - use std::collections::HashMap; - - const PRISTINE: &[u8] = b"pub fn cfg() {}\n"; - const PATCHED: &[u8] = b"pub fn cfg() { /* patched */ }\n"; - - fn git_sha(bytes: &[u8]) -> String { - compute_git_sha256_from_bytes(bytes) - } - - /// Build a pristine registry-style crate dir with a checksum sidecar and a - /// blobs dir carrying the patched bytes. Returns (project_root, blobs_dir, - /// pristine_src, files, after_hash). - async fn fixture() -> ( - tempfile::TempDir, - PathBuf, - PathBuf, - HashMap, - String, - ) { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().to_path_buf(); - - // Pristine source dir (simulating an extracted registry crate). - let pristine = root.join("registry/cfg-if-1.0.0"); - tokio::fs::create_dir_all(pristine.join("src")) - .await - .unwrap(); - tokio::fs::write(pristine.join("src/lib.rs"), PRISTINE) - .await - .unwrap(); - tokio::fs::write( - pristine.join("Cargo.toml"), - "[package]\nname=\"cfg-if\"\nversion=\"1.0.0\"\n", - ) - .await - .unwrap(); - tokio::fs::write(pristine.join(".cargo-checksum.json"), "{\"files\":{}}") - .await - .unwrap(); - - let before = git_sha(PRISTINE); - let after = git_sha(PATCHED); - - // Blobs dir with the patched content keyed by afterHash. - let blobs = root.join(".socket/blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - tokio::fs::write(blobs.join(&after), PATCHED).await.unwrap(); - - let mut files = HashMap::new(); - files.insert( - "package/src/lib.rs".to_string(), - PatchFileInfo { - before_hash: before, - after_hash: after.clone(), - }, - ); - - (dir, blobs, pristine, files, after) - } - - #[tokio::test] - async fn test_apply_redirect_happy_path() { - let (dir, blobs, pristine, files, after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - - let result = apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", - "cfg-if", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - assert!(result.success, "apply failed: {:?}", result.error); - assert!( - result.sidecar.is_none(), - "redirect copy must not emit a sidecar" - ); - - // Copy exists with patched bytes and NO checksum sidecar. - let copy = root.join(".socket/cargo-patches/cfg-if-1.0.0"); - let lib = tokio::fs::read(copy.join("src/lib.rs")).await.unwrap(); - assert_eq!(lib, PATCHED); - assert!(!copy.join(".cargo-checksum.json").exists()); - - // Registry pristine is untouched. - let reg = tokio::fs::read(pristine.join("src/lib.rs")).await.unwrap(); - assert_eq!(reg, PRISTINE); - - // Config entry points at the copy. - let entries = cargo_config::read_patch_entries(root).await; - assert_eq!( - entries["cfg-if"].path.as_deref(), - Some(".socket/cargo-patches/cfg-if-1.0.0") - ); - assert_eq!(git_sha(&lib), after); - } - - #[tokio::test] - async fn test_apply_is_idempotent_byte_for_byte() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - let args = ("pkg:cargo/cfg-if@1.0.0", "cfg-if", "1.0.0"); - - apply_cargo_redirect( - args.0, args.1, args.2, &pristine, root, &files, &sources, None, false, false, - ) - .await; - let copy = root.join(".socket/cargo-patches/cfg-if-1.0.0/src/lib.rs"); - let cfg = root.join(".cargo/config.toml"); - let lib1 = tokio::fs::read(©).await.unwrap(); - let cfg1 = tokio::fs::read_to_string(&cfg).await.unwrap(); - - // Second apply must hit the in-sync short-circuit: no rewrite. - let result = apply_cargo_redirect( - args.0, args.1, args.2, &pristine, root, &files, &sources, None, false, false, - ) - .await; - assert!(result.success); - // The synthesized in-sync result reports AlreadyPatched, patches nothing. - assert!(result.files_patched.is_empty()); - let lib2 = tokio::fs::read(©).await.unwrap(); - let cfg2 = tokio::fs::read_to_string(&cfg).await.unwrap(); - assert_eq!(lib1, lib2, "copy bytes must be unchanged on resync"); - assert_eq!(cfg1, cfg2, "config must be unchanged on resync"); - } - - #[tokio::test] - async fn test_drift_triggers_rebuild() { - let (dir, blobs, pristine, files, after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", - "cfg-if", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - - // Corrupt the copy. - let copy = root.join(".socket/cargo-patches/cfg-if-1.0.0/src/lib.rs"); - tokio::fs::write(©, b"corrupted").await.unwrap(); - - // Re-apply repairs it. - let result = apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", - "cfg-if", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - assert!(result.success); - assert_eq!(git_sha(&tokio::fs::read(©).await.unwrap()), after); - } - - #[tokio::test] - async fn test_dry_run_writes_nothing() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - let result = apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", - "cfg-if", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - true, - false, - ) - .await; - assert!(result.success); - assert!(!root.join(".socket/cargo-patches/cfg-if-1.0.0").exists()); - assert!(!root.join(".cargo/config.toml").exists()); - } - - #[tokio::test] - async fn test_partial_failure_rolls_back_copy() { - let (dir, _blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - // Empty blobs dir → the blob read fails mid-apply. - let empty_blobs = root.join(".socket/empty-blobs"); - tokio::fs::create_dir_all(&empty_blobs).await.unwrap(); - let sources = PatchSources::blobs_only(&empty_blobs); - - let result = apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", - "cfg-if", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - assert!(!result.success); - assert!( - !root.join(".socket/cargo-patches/cfg-if-1.0.0").exists(), - "half-built copy must be rolled back" - ); - // No config entry was written. - assert!(cargo_config::read_patch_entries(root).await.is_empty()); - } - - #[tokio::test] - async fn test_remove_drops_entry_and_copy() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", - "cfg-if", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - - // apply wired the [env] root. - assert!(cargo_config::env_root_present(root).await); - - remove_cargo_redirect("pkg:cargo/cfg-if@1.0.0", root, false) - .await - .unwrap(); - assert!(!root.join(".socket/cargo-patches/cfg-if-1.0.0").exists()); - assert!(!cargo_config::read_patch_entries(root) - .await - .contains_key("cfg-if")); - // Rollback removes patch state only — the [env] setup state survives. - assert!( - cargo_config::env_root_present(root).await, - "rollback must NOT remove [env] SOCKET_PATCH_ROOT (setup state)" - ); - } - - #[tokio::test] - async fn test_reconcile_prunes_orphan() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", - "cfg-if", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - - // Desired set no longer contains cfg-if → it's an orphan. - let desired: HashSet = HashSet::new(); - let removed = reconcile_cargo_redirects(root, &desired, false).await; - assert!(removed.contains(&"pkg:cargo/cfg-if@1.0.0".to_string())); - assert!(!root.join(".socket/cargo-patches/cfg-if-1.0.0").exists()); - assert!(cargo_config::read_patch_entries(root).await.is_empty()); - // Even when the last redirect is pruned, [env] (setup state) survives. - assert!( - cargo_config::env_root_present(root).await, - "reconcile must NOT remove [env] SOCKET_PATCH_ROOT (setup state)" - ); - } - - #[tokio::test] - async fn test_reconcile_keeps_desired_and_user_entries() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", - "cfg-if", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - // Add a user-authored entry directly. - let cfg = root.join(".cargo/config.toml"); - let mut body = tokio::fs::read_to_string(&cfg).await.unwrap(); - body.push_str("mine = { git = \"https://example.com/m.git\" }\n"); - // Insert the user entry into the existing [patch.crates-io] table. - let body = body.replace( - "[patch.crates-io]\n", - "[patch.crates-io]\nmine = { git = \"https://example.com/m.git\" }\n", - ); - tokio::fs::write(&cfg, body).await.unwrap(); - - let desired: HashSet = ["pkg:cargo/cfg-if@1.0.0".to_string()].into_iter().collect(); - let removed = reconcile_cargo_redirects(root, &desired, false).await; - assert!(removed.is_empty()); - let entries = cargo_config::read_patch_entries(root).await; - assert!(entries.contains_key("cfg-if")); - assert!(entries.contains_key("mine")); - } - - #[tokio::test] - async fn test_verify_state_drift_kinds() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", - "cfg-if", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - - let mut manifest = PatchManifest::new(); - manifest.patches.insert( - "pkg:cargo/cfg-if@1.0.0".to_string(), - crate::manifest::schema::PatchRecord { - uuid: "u".into(), - exported_at: "t".into(), - files: files.clone(), - vulnerabilities: HashMap::new(), - description: String::new(), - license: String::new(), - tier: String::new(), - }, - ); - let desired: HashSet = ["pkg:cargo/cfg-if@1.0.0".to_string()].into_iter().collect(); - - // Clean → Ok. Registry-independence: delete the pristine source first. - tokio::fs::remove_dir_all(&pristine).await.unwrap(); - assert!(verify_cargo_redirect_state(root, &manifest, &desired) - .await - .is_ok()); - - // Corrupt a file → StaleCopy. - let copy = root.join(".socket/cargo-patches/cfg-if-1.0.0/src/lib.rs"); - tokio::fs::write(©, b"x").await.unwrap(); - let drifts = verify_cargo_redirect_state(root, &manifest, &desired) - .await - .unwrap_err(); - assert!(drifts.iter().any(|d| matches!(d, Drift::StaleCopy { .. }))); - - // Delete the copy → MissingCopy (the config entry is still present). - tokio::fs::remove_dir_all(root.join(".socket/cargo-patches/cfg-if-1.0.0")) - .await - .unwrap(); - let drifts = verify_cargo_redirect_state(root, &manifest, &desired) - .await - .unwrap_err(); - assert!(drifts - .iter() - .any(|d| matches!(d, Drift::MissingCopy { .. }))); - assert!(!drifts - .iter() - .any(|d| matches!(d, Drift::MissingEntry { .. }))); - - // Now drop the config entry too → MissingEntry. - cargo_config::drop_patch_entry(root, "cfg-if", false) - .await - .unwrap(); - let drifts = verify_cargo_redirect_state(root, &manifest, &desired) - .await - .unwrap_err(); - assert!(drifts - .iter() - .any(|d| matches!(d, Drift::MissingEntry { .. }))); - } - - #[tokio::test] - async fn test_verify_flags_resolved_version_mismatch() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", - "cfg-if", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - - let mut manifest = PatchManifest::new(); - manifest.patches.insert( - "pkg:cargo/cfg-if@1.0.0".to_string(), - crate::manifest::schema::PatchRecord { - uuid: "u".into(), - exported_at: "t".into(), - files: files.clone(), - vulnerabilities: HashMap::new(), - description: String::new(), - license: String::new(), - tier: String::new(), - }, - ); - let desired: HashSet = ["pkg:cargo/cfg-if@1.0.0".to_string()].into_iter().collect(); - - // Cargo.lock resolves cfg-if to 1.0.1 — the 1.0.0 patch is unused, so - // cargo would link the UNPATCHED crate → drift, even though the copy is - // byte-correct for the 1.0.0 entry. - tokio::fs::write( - root.join("Cargo.lock"), - "[[package]]\nname = \"cfg-if\"\nversion = \"1.0.1\"\n", - ) - .await - .unwrap(); - let drifts = verify_cargo_redirect_state(root, &manifest, &desired) - .await - .unwrap_err(); - assert!(drifts - .iter() - .any(|d| matches!(d, Drift::ResolvedVersionMismatch { .. }))); - - // Lock resolves the patched version 1.0.0 → no mismatch (clean). - tokio::fs::write( - root.join("Cargo.lock"), - "[[package]]\nname = \"cfg-if\"\nversion = \"1.0.0\"\n", - ) - .await - .unwrap(); - assert!(verify_cargo_redirect_state(root, &manifest, &desired) - .await - .is_ok()); - } - - #[tokio::test] - async fn test_verify_flags_wrong_entry_path() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", - "cfg-if", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - - let mut manifest = PatchManifest::new(); - manifest.patches.insert( - "pkg:cargo/cfg-if@1.0.0".to_string(), - crate::manifest::schema::PatchRecord { - uuid: "u".into(), - exported_at: "t".into(), - files: files.clone(), - vulnerabilities: HashMap::new(), - description: String::new(), - license: String::new(), - tier: String::new(), - }, - ); - let desired: HashSet = ["pkg:cargo/cfg-if@1.0.0".to_string()].into_iter().collect(); - - // Clean → Ok. - assert!(verify_cargo_redirect_state(root, &manifest, &desired) - .await - .is_ok()); - - // Repoint the socket-owned entry at a DIFFERENT version's copy (e.g. a - // stale entry left by a version bump) while the 1.0.0 copy stays byte- - // correct. cargo keys `[patch]` by name, so this silently redirects to - // the wrong copy — verify must flag it, NOT pass as in-sync. (Mirrors the - // apply hot-path `redirect_in_sync` path check.) - cargo_config::ensure_patch_entry(root, "cfg-if", "9.9.9", false) - .await - .unwrap(); - let drifts = verify_cargo_redirect_state(root, &manifest, &desired) - .await - .unwrap_err(); - assert!( - drifts - .iter() - .any(|d| matches!(d, Drift::WrongEntryPath { .. })), - "stale entry path must be flagged as drift: {drifts:?}" - ); - // It exists + is socket-owned, so it must NOT be reported missing. - assert!(!drifts - .iter() - .any(|d| matches!(d, Drift::MissingEntry { .. }))); - } - - #[tokio::test] - async fn test_verify_orphan_entry() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", - "cfg-if", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - - // Empty desired set + empty manifest → the live entry is an orphan. - let manifest = PatchManifest::new(); - let desired: HashSet = HashSet::new(); - let drifts = verify_cargo_redirect_state(root, &manifest, &desired) - .await - .unwrap_err(); - assert!(drifts - .iter() - .any(|d| matches!(d, Drift::OrphanEntry { .. }))); - } - - // ── filesystem-safety: coordinate traversal ────────────────────────── - - /// SECURITY regression: a tampered manifest PURL with `..` in the crate name - /// must NOT let `apply` copy + write the patched tree outside - /// `.socket/cargo-patches/`. Before the guard this returned success and - /// materialised the copy at `/../escape-1.0.0`. - #[tokio::test] - async fn test_apply_rejects_traversal_crate_name() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - let escaped = root.parent().unwrap().join("escape-1.0.0"); - // Make sure a stale copy from a prior run can't mask the assertion. - let _ = remove_tree(&escaped).await; - - let result = apply_cargo_redirect( - "pkg:cargo/../../../escape@1.0.0", - "../../../escape", - "1.0.0", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - - assert!(!result.success, "traversal coordinates must be refused"); - assert!( - result.error.as_deref().unwrap_or("").contains("unsafe"), - "error should explain the refusal: {:?}", - result.error - ); - assert!( - !escaped.exists(), - "no copy may be written outside .socket/cargo-patches/ (found {})", - escaped.display() - ); - // No config entry was written either. - assert!(cargo_config::read_patch_entries(root).await.is_empty()); - let _ = remove_tree(&escaped).await; // belt-and-suspenders cleanup - } - - /// A `version` carrying a separator is equally rejected (keys the copy dir). - #[tokio::test] - async fn test_apply_rejects_traversal_version() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - let result = apply_cargo_redirect( - "pkg:cargo/cfg-if@../../../evil", - "cfg-if", - "../../../evil", - &pristine, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - assert!(!result.success); - assert!(!root.join(".cargo/config.toml").exists()); - } - - /// SECURITY regression: `remove` must refuse unsafe coordinates rather than - /// `remove_tree` a directory outside the project. - #[tokio::test] - async fn test_remove_rejects_traversal() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - // A precious directory a sibling of the project root. - let precious = root.parent().unwrap().join("precious-1.0.0"); - tokio::fs::create_dir_all(&precious).await.unwrap(); - tokio::fs::write(precious.join("keep.txt"), b"keep") - .await - .unwrap(); - - let err = remove_cargo_redirect("pkg:cargo/../../../precious@1.0.0", root, false) - .await - .unwrap_err(); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); - assert!( - precious.exists() && precious.join("keep.txt").exists(), - "remove must not delete a tree outside the project" - ); - tokio::fs::remove_dir_all(&precious).await.unwrap(); - } - - // ── scenario coverage ──────────────────────────────────────────────── - - #[tokio::test] - async fn test_reconcile_dry_run_does_not_mutate() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", "cfg-if", "1.0.0", &pristine, root, &files, &sources, None, - false, false, - ) - .await; - let cfg_before = tokio::fs::read_to_string(root.join(".cargo/config.toml")) - .await - .unwrap(); - - let desired: HashSet = HashSet::new(); - let removed = reconcile_cargo_redirects(root, &desired, true).await; - assert!(removed.contains(&"pkg:cargo/cfg-if@1.0.0".to_string())); - // dry-run must NOT delete the copy or rewrite config. - assert!(root.join(".socket/cargo-patches/cfg-if-1.0.0").exists()); - let cfg_after = tokio::fs::read_to_string(root.join(".cargo/config.toml")) - .await - .unwrap(); - assert_eq!(cfg_before, cfg_after, "dry-run reconcile must not edit config"); - } - - #[tokio::test] - async fn test_version_bump_refreshes_entry() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", "cfg-if", "1.0.0", &pristine, root, &files, &sources, None, - false, false, - ) - .await; - // Apply a NEW version (same crate). Build a fresh pristine for 1.0.1. - let result = apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.1", "cfg-if", "1.0.1", &pristine, root, &files, &sources, None, - false, false, - ) - .await; - assert!(result.success, "{:?}", result.error); - let entries = cargo_config::read_patch_entries(root).await; - assert_eq!( - entries["cfg-if"].path.as_deref(), - Some(".socket/cargo-patches/cfg-if-1.0.1"), - "entry must point at the bumped version" - ); - assert!(root.join(".socket/cargo-patches/cfg-if-1.0.1").exists()); - } - - #[tokio::test] - async fn test_realistic_cargo_lock_with_header() { - let (dir, blobs, pristine, files, _after) = fixture().await; - let root = dir.path(); - let sources = PatchSources::blobs_only(&blobs); - apply_cargo_redirect( - "pkg:cargo/cfg-if@1.0.0", "cfg-if", "1.0.0", &pristine, root, &files, &sources, None, - false, false, - ) - .await; - let mut manifest = PatchManifest::new(); - manifest.patches.insert( - "pkg:cargo/cfg-if@1.0.0".to_string(), - crate::manifest::schema::PatchRecord { - uuid: "u".into(), - exported_at: "t".into(), - files: files.clone(), - vulnerabilities: HashMap::new(), - description: String::new(), - license: String::new(), - tier: String::new(), - }, - ); - let desired: HashSet = ["pkg:cargo/cfg-if@1.0.0".to_string()].into_iter().collect(); - // Realistic lock: version header + source/checksum fields + dup version. - tokio::fs::write( - root.join("Cargo.lock"), - "version = 3\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"abc\"\n\n[[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"def\"\n", - ) - .await - .unwrap(); - // patched 1.0.0 is among resolved versions → clean. - assert!(verify_cargo_redirect_state(root, &manifest, &desired) - .await - .is_ok()); - } - - #[tokio::test] - async fn test_empty_files_is_noop() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let blobs = root.join("blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - let sources = PatchSources::blobs_only(&blobs); - let files = HashMap::new(); - let result = apply_cargo_redirect( - "pkg:cargo/x@1.0.0", - "x", - "1.0.0", - root, - root, - &files, - &sources, - None, - false, - false, - ) - .await; - assert!(result.success); - assert!(!root.join(".cargo/config.toml").exists()); - } -} diff --git a/crates/socket-patch-core/src/patch/copy_tree.rs b/crates/socket-patch-core/src/patch/copy_tree.rs index 8f2b72f..629f945 100644 --- a/crates/socket-patch-core/src/patch/copy_tree.rs +++ b/crates/socket-patch-core/src/patch/copy_tree.rs @@ -1,12 +1,10 @@ -//! Shared tree-copy helpers for the project-local redirect backends — the -//! cargo `[patch]`-redirect ([`crate::patch::cargo_redirect`]) and the Go -//! `replace`-redirect ([`crate::patch::go_redirect`]). Both materialise a -//! project-local **patched copy** of a package by copying its pristine source -//! out of a read-only registry/module cache into a writable dir under -//! `.socket/`, then patching the copy in place. +//! Shared tree-copy helpers for the project-local Go `replace`-redirect backend +//! ([`crate::patch::go_redirect`]). It materialises a project-local **patched +//! copy** of a module by copying its pristine source out of the read-only, +//! checksum-verified module cache into a writable dir under `.socket/`, then +//! patching the copy in place. //! -//! Only compiled when a redirect backend is enabled. -#![cfg(any(feature = "cargo", feature = "golang"))] +//! Only compiled when the Go redirect backend is enabled (gated in `mod.rs`). use std::path::Path; diff --git a/crates/socket-patch-core/src/patch/go_mod_edit.rs b/crates/socket-patch-core/src/patch/go_mod_edit.rs index b1635f7..0eec306 100644 --- a/crates/socket-patch-core/src/patch/go_mod_edit.rs +++ b/crates/socket-patch-core/src/patch/go_mod_edit.rs @@ -1,9 +1,8 @@ //! Read / write `/go.mod` for the project-local Go //! `replace`-redirect backend. //! -//! Mirrors the contract of [`crate::patch::cargo_config`] (the cargo -//! `[patch.crates-io]` analog), but `go.mod` is **not** TOML, so there is no -//! `toml_edit` to lean on. This is a small, line/block-aware editor that +//! `go.mod` is **not** TOML, so there is no `toml_edit` to lean on. This is a +//! small, line/block-aware editor that //! preserves the rest of the file (comments, `require`/`exclude`/`retract` //! directives, the user's own `replace`s) and only touches socket-owned //! `replace` directives. @@ -143,7 +142,7 @@ async fn edit_go_mod( /// sibling file, fsync it, then rename over the target (atomic on the same /// filesystem), so a reader/recovering process only ever sees the complete old /// or the complete new bytes. Mirrors the hardened writers in -/// `patch/cargo_config.rs`, `patch/apply.rs`, and `package_json/update.rs`. +/// `patch/apply.rs` and `package_json/update.rs`. async fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> { let parent = path.parent().unwrap_or_else(|| Path::new(".")); let stem = path @@ -780,7 +779,7 @@ replace ( /// A real write must rename its `.socket-stage-*` sibling over `go.mod` and /// leave nothing behind — a leftover stage file (or, worse, a half-written /// truncated `go.mod`) is exactly the corruption the atomic writer exists to - /// prevent. Mirrors the litter guard in `patch/cargo_config.rs`. + /// prevent. Mirrors the litter guard in `package_json/update.rs`. #[tokio::test] async fn test_ensure_leaves_no_stage_litter() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/patch/go_redirect.rs b/crates/socket-patch-core/src/patch/go_redirect.rs index 2a4aaef..c77b8cf 100644 --- a/crates/socket-patch-core/src/patch/go_redirect.rs +++ b/crates/socket-patch-core/src/patch/go_redirect.rs @@ -1,8 +1,9 @@ //! Project-local Go `replace`-redirect engine (local mode only). //! -//! The Go analog of [`crate::patch::cargo_redirect`]. Instead of patching -//! modules in place in the shared, read-only, checksum-verified module cache -//! (the `--global` path), this materialises a project-local **patched copy** of +//! Unlike cargo (which patches crates in place wherever the crawler finds +//! them), the Go module cache is shared, read-only and checksum-verified, so +//! in-place patching fails `go.sum` verification at build time. Instead, this +//! materialises a project-local **patched copy** of //! each module under `/.socket/go-patches/@/` and points //! the build at it with a `replace` directive in `/go.mod`: //! @@ -517,9 +518,9 @@ async fn ensure_module_go_mod(copy_dir: &Path, module: &str) -> std::io::Result< } /// Atomically commit `content` to `path` via stage + fsync + rename. Mirrors the -/// hardened writers in [`crate::patch::cargo_config`] / -/// [`crate::patch::go_mod_edit`]: a reader/recovering process only ever sees the -/// complete old or complete new bytes, never a truncated intermediate. +/// hardened writer in [`crate::patch::go_mod_edit`]: a reader/recovering process +/// only ever sees the complete old or complete new bytes, never a truncated +/// intermediate. async fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> { let parent = path.parent().unwrap_or_else(|| Path::new(".")); let stem = path diff --git a/crates/socket-patch-core/src/patch/mod.rs b/crates/socket-patch-core/src/patch/mod.rs index 3f75df9..5041302 100644 --- a/crates/socket-patch-core/src/patch/mod.rs +++ b/crates/socket-patch-core/src/patch/mod.rs @@ -1,10 +1,6 @@ pub mod apply; pub mod apply_lock; -#[cfg(feature = "cargo")] -pub mod cargo_config; -#[cfg(feature = "cargo")] -pub mod cargo_redirect; -#[cfg(any(feature = "cargo", feature = "golang"))] +#[cfg(feature = "golang")] pub mod copy_tree; pub mod cow; pub mod diff; From 0f1e05eae3f86a17c1e1d3de7b8006f8f3a77f7e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 09:47:04 -0400 Subject: [PATCH 02/31] ci: drop removed cargo-coexist suite + go-guard-template lint step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The simplification deleted `tests/e2e_cargo_coexist.rs` and the `go_setup/templates/*.tmpl` files, but ci.yml still referenced them: the e2e matrix listed `e2e_cargo_coexist` (no such target → fail) and the lint-ecosystems job `cp`'d the now-absent Go guard templates for gofmt/vet (cp → fail). Remove both; keep `guard_build_integration`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c56e87..64c4068 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,19 +63,6 @@ jobs: php -l composer/socket-patch/bin/socket-patch ( cd composer/socket-patch && composer validate --no-check-publish ) - - name: Go — gofmt + vet the setup-guard templates - run: | - tmp="$(mktemp -d)" - cp crates/socket-patch-core/src/go_setup/templates/guard.go.tmpl "$tmp/guard.go" - cp crates/socket-patch-core/src/go_setup/templates/guard_test.go.tmpl "$tmp/guard_test.go" - unformatted="$(gofmt -l "$tmp")" - if [ -n "$unformatted" ]; then - echo "::error::Go setup-guard templates are not gofmt-clean:" - gofmt -d "$tmp" - exit 1 - fi - ( cd "$tmp" && go mod init socketpatchguardlint >/dev/null && go vet ./... ) - test: strategy: fail-fast: false @@ -456,16 +443,12 @@ jobs: suite: e2e_composer - os: ubuntu-latest suite: e2e_nuget - # Cargo project-local [patch]-redirect backend + fail-closed guard. - # `guard_build_integration` is hermetic (a shell stub + `cargo build - # --offline` against a zero-dep path dep), so it exercises the - # build.rs-panic-aborts-a-real-build seam with no network. - # `e2e_cargo_coexist`'s real-cargo proofs fetch a crate (cached) and - # skip on fetch failure. Both suites are `#[cfg(unix)]`. + # `socket-patch-guard` fail-closed build guard. `guard_build_integration` + # is hermetic (a shell stub + `cargo build --offline` against a zero-dep + # path dep), so it exercises the build.rs-panic-aborts-a-real-build seam + # with no network. `#[cfg(unix)]`. - os: ubuntu-latest suite: guard_build_integration - - os: ubuntu-latest - suite: e2e_cargo_coexist # The live-API smoke suites (e2e_npm, e2e_pypi, e2e_gem, # e2e_scan) are intentionally NOT in the PR matrix — their # `#[ignore]`-gated tests hit the real public proxy at From 26014a182bb5d77001721f91af9c9f6b4a50ab02 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 14:31:23 -0400 Subject: [PATCH 03/31] refactor: remove the now-orphaned socket-patch-guard crate + stale references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With cargo patching in place (no `[patch]`-redirect) and `setup` no longer wiring a build-time guard, the `socket-patch-guard` crate had no consumer: nothing in cli/core references it, `setup` never writes it, and its `.socket/cargo-patches/` + `SOCKET_PATCH_ROOT` machinery no longer exists. Remove it and every dangling reference. - Delete `crates/socket-patch-guard/` (crate, build.rs, README, SAME_TICK_HEAL_RND.md, same_tick_heal_experiment.rs) and drop it from the workspace members + Cargo.lock. - Delete `guard_build_integration.rs`; drop the `guard_build_integration` e2e matrix entry and the guard-template gofmt/vet step from ci.yml; drop the `cargo publish -p socket-patch-guard` step from release.yml. - CHANGELOG: rewrite the `[Unreleased]` cargo/guard entries to describe what actually ships — cargo in-place patching (default feature) and the Go `replace`-redirect backend — and drop the now-false "cargo apply now redirects" Changed bullet. - Drop the dead `SOCKET_PATCH_ROOT`/`SOCKET_PATCH_GUARD` env vars from test scrub lists (no longer read in src; `SOCKET_PATCH_BIN` kept — the gem Bundler plugin still uses it); repoint stale `setup_matrix_cargo` doc-comments to surviving sibling suites; refresh CLI_CONTRACT prose. Builds clean across feature combos; full suite passes (--no-fail-fast). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 11 +- .github/workflows/release.yml | 11 - CHANGELOG.md | 71 ++--- Cargo.lock | 7 - Cargo.toml | 1 - crates/socket-patch-cli/CLI_CONTRACT.md | 8 +- .../tests/guard_build_integration.rs | 282 ------------------ .../tests/setup_contract_gaps.rs | 1 - .../tests/setup_invariants.rs | 3 +- .../tests/setup_matrix_deno.rs | 4 +- .../tests/setup_matrix_gem.rs | 4 +- .../tests/setup_matrix_maven.rs | 4 +- .../tests/setup_matrix_npm.rs | 2 +- .../tests/setup_matrix_nuget.rs | 4 +- .../tests/setup_matrix_pypi.rs | 4 +- crates/socket-patch-guard/Cargo.toml | 19 -- crates/socket-patch-guard/README.md | 61 ---- .../socket-patch-guard/SAME_TICK_HEAL_RND.md | 115 ------- crates/socket-patch-guard/build.rs | 66 ---- crates/socket-patch-guard/src/lib.rs | 240 --------------- crates/socket-patch-guard/src/logic.rs | 158 ---------- .../tests/same_tick_heal_experiment.rs | 201 ------------- 22 files changed, 31 insertions(+), 1246 deletions(-) delete mode 100644 crates/socket-patch-cli/tests/guard_build_integration.rs delete mode 100644 crates/socket-patch-guard/Cargo.toml delete mode 100644 crates/socket-patch-guard/README.md delete mode 100644 crates/socket-patch-guard/SAME_TICK_HEAL_RND.md delete mode 100644 crates/socket-patch-guard/build.rs delete mode 100644 crates/socket-patch-guard/src/lib.rs delete mode 100644 crates/socket-patch-guard/src/logic.rs delete mode 100644 crates/socket-patch-guard/tests/same_tick_heal_experiment.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64c4068..f0a136a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,9 +39,8 @@ jobs: # Lint the out-of-workspace packaging artifacts for the ecosystems whose setup # / CLI-distribution we added: the RubyGems CLI launcher gem + the Bundler - # plugin gem (Ruby), the Composer CLI launcher (PHP), and the generated Go - # setup-guard templates. Ruby, PHP, Composer, and Go are all pre-installed on - # the ubuntu-latest runner. + # plugin gem (Ruby) and the Composer CLI launcher (PHP). Ruby, PHP, and + # Composer are all pre-installed on the ubuntu-latest runner. lint-ecosystems: runs-on: ubuntu-latest steps: @@ -443,12 +442,6 @@ jobs: suite: e2e_composer - os: ubuntu-latest suite: e2e_nuget - # `socket-patch-guard` fail-closed build guard. `guard_build_integration` - # is hermetic (a shell stub + `cargo build --offline` against a zero-dep - # path dep), so it exercises the build.rs-panic-aborts-a-real-build seam - # with no network. `#[cfg(unix)]`. - - os: ubuntu-latest - suite: guard_build_integration # The live-API smoke suites (e2e_npm, e2e_pypi, e2e_gem, # e2e_scan) are intentionally NOT in the PR matrix — their # `#[ignore]`-gated tests hit the real public proxy at diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd67cfa..571062d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -247,17 +247,6 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }} - # socket-patch-guard is a standalone crate (no dependency on core/cli) that - # `socket-patch setup` adds to a user's Cargo.toml as - # `socket-patch-guard = ""`. It MUST be published on every - # release or cargo setup writes an unresolvable dependency and the user's - # `cargo build` fails. Its build.rs is a no-op when SOCKET_PATCH_ROOT is - # unset (the case during publish verification), so this builds cleanly. - - name: Publish socket-patch-guard - run: cargo publish -p socket-patch-guard - env: - CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }} - - name: Wait for crates.io index update run: sleep 30 diff --git a/CHANGELOG.md b/CHANGELOG.md index 20716af..76fc736 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,49 +16,24 @@ in this file — see `.github/workflows/release.yml` (`version` job). ### Added -- **Project-local cargo `[patch]`-redirect backend (local mode).** Patching a - Rust dependency from the registry cache no longer mutates the shared - `$CARGO_HOME` registry in place. Instead `apply` writes a project-local - patched **copy** under `.socket/cargo-patches/-/` and a managed - `[patch.crates-io]` entry (+ `[env] SOCKET_PATCH_ROOT`) into - `.cargo/config.toml`, so patches are project-scoped and the registry stays - pristine for sibling projects. `rollback` cleanly drops the entry + copy - (leaving `setup` state — the guard dependency + `[env]` — intact). - `apply --check` is a new read-only, lock-free, offline auditor that verifies - the committed copies/config match the manifest **and** cross-checks - `Cargo.lock` (flagging a patched dependency that silently resolved to an - unpatched version); it exits non-zero on drift (for CI / GitHub-App use). - Vendored crates (`vendor/`) and `--global` cargo keep the existing in-place - `.cargo-checksum.json` rewrite path unchanged. **`cargo` is now a default - feature** (alongside the always-on npm + PyPI support), so released binaries and - a plain `cargo install socket-patch-cli` patch Rust dependencies and run the - guard out of the box; `golang`/`maven`/`composer`/`nuget`/`deno` remain opt-in. - A binary built `--no-default-features` (no cargo) now fails `apply --check` - closed rather than reporting "in sync", so it can never make the guard pass - vacuously. -- **`socket-patch-guard` crate + `setup` cargo support.** `socket-patch setup` - now also configures Rust projects: it adds a tiny `socket-patch-guard` - dependency (a normal `[dependencies]` entry, not a `[build-dependencies]` one, - so cargo always compiles it and runs its build script) to every workspace - member and writes `[env] SOCKET_PATCH_ROOT`. The guard's build script runs `socket-patch apply --check` - on every relevant `cargo build` and is **fail-closed**: if the committed - patched copies are out of sync with `.socket/manifest.json` (a stale copy, or - a patched dependency that resolved to an unpatched version), the build - **fails** rather than silently compiling stale/unpatched sources — closing the - CI footgun where a one-shot build could ship an unpatched binary. The fix is - run-order-independent (it checks the static committed state, not when the - build script happens to run). It is a single fail-closed mode with no - `warn`/`off` escape: on drift it regenerates the copies then fails the build - with a "re-run" message (the retry is clean), and an unrecoverable state or a - missing `socket-patch` CLI also fails the build. In normal use the guard never - fires, since changing a patch goes through `get`/`apply` (which regenerate the - copies). The user's own `build.rs` is never touched. For CI, run - `socket-patch apply --check --ecosystems cargo` as an explicit pipeline gate. - `setup --check` / `setup --remove` cover the - round-trip. *(A guarded repo requires `socket-patch` on the build machine — - wire it into apps/workspaces you control, not a published library. Pre-GA: - `socket-patch-guard` will be published to crates.io; airgapped users vendor - it.)* +- **Cargo support (`cargo` is now a default feature).** `apply` patches a Rust + dependency **in place** wherever the crawler finds it — the project `vendor/` + directory or the shared `$CARGO_HOME` registry cache — rewriting the crate's + `.cargo-checksum.json` sidecar so `cargo build` accepts the modified files. + `rollback` restores the original bytes from the `beforeHash` blobs, like + npm/PyPI/gem. `cargo` ships on by default (alongside the always-on npm + PyPI + + Ruby gems support), so released binaries and a plain `cargo install + socket-patch-cli` patch Rust dependencies out of the box; + `maven`/`composer`/`nuget`/`deno` remain opt-in. +- **Project-local Go `replace`-redirect backend (`golang`, default feature).** + The Go module cache is shared, read-only and checksum-verified, so in-place + patching would fail `go.sum` at build time. Instead `apply` writes a + project-local patched **copy** under `.socket/go-patches/@/` + and a managed `replace` directive in the project `go.mod`, so the patch is + project-scoped and the cache stays pristine for sibling projects. `rollback` + cleanly drops the `replace` directive + copy. `apply --check` is a read-only, + lock-free, offline auditor that verifies the committed redirects match the + manifest, exiting non-zero on drift (for CI / GitHub-App use). - **Inline OpenVEX generation on `apply` and `scan` via `--vex `.** A single successful `apply`/`scan` can now both patch and emit the OpenVEX 0.2.0 attestation, instead of requiring a separate `socket-patch vex` step. @@ -71,16 +46,6 @@ in this file — see `.github/workflows/release.yml` (`version` job). command exit non-zero even when the apply/scan itself succeeded, surfacing a stable error code in the envelope. -### Changed - -- **Local cargo `apply` now redirects instead of patching in place.** Registry - crates patched by a previous (in-place) version leave a mutated shared - registry + rewritten `.cargo-checksum.json` behind; the new local backend - never touches the registry, so those stay dirty until cargo re-fetches. - `apply` now prints a one-line **warning** when it detects such a crate - (suppressed under `--offline`, so the build-time guard stays quiet) and points - at restoring the pristine copy. No automatic registry cleanup is performed. - ## [3.2.0] — 2026-05-29 A repo-wide correctness, security, and filesystem-safety hardening pass: every diff --git a/Cargo.lock b/Cargo.lock index e8cc2ff..716919a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2451,13 +2451,6 @@ dependencies = [ "wiremock", ] -[[package]] -name = "socket-patch-guard" -version = "3.3.0" -dependencies = [ - "tempfile", -] - [[package]] name = "socket2" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 634a18a..cf94cd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ members = [ "crates/socket-patch-core", "crates/socket-patch-cli", - "crates/socket-patch-guard", ] resolver = "2" diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 4b39e17..cde2d18 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -189,10 +189,10 @@ still show up in VEX). #### Cargo and Go: apply-only, no setup Cargo and Go have **no `setup` hook** — a one-click, auto-repatch-on-build setup isn't possible for -them, so `setup` skips both (it never writes a `socket-patch-guard` dependency, `[env]`, -`internal/socketpatchguard/`, a `[patch]` entry, or a `go.mod` `replace` as a *setup* action). Patch -them with `socket-patch apply` directly (manually or from a per-project install script), and declare -them in `setup.manual` for VEX attestation. +them, so `setup` skips both (it makes no manifest edits for either as a *setup* action; the `go.mod` +`replace` that local-mode `apply` writes is an *apply*-time redirect, not setup state). Patch them +with `socket-patch apply` directly (manually or from a per-project install script), and declare them +in `setup.manual` for VEX attestation. - **cargo** — `apply` patches the crate **in place** wherever the crawler finds it: the project `vendor/` directory or the shared registry cache (`$CARGO_HOME/registry/src/...`). The diff --git a/crates/socket-patch-cli/tests/guard_build_integration.rs b/crates/socket-patch-cli/tests/guard_build_integration.rs deleted file mode 100644 index 5545ebd..0000000 --- a/crates/socket-patch-cli/tests/guard_build_integration.rs +++ /dev/null @@ -1,282 +0,0 @@ -#![cfg(feature = "cargo")] -//! Integration test for `socket-patch-guard`'s build script under the single -//! **fail-closed** model: a real `cargo build` of a consumer that depends on the -//! guard runs `${SOCKET_PATCH_BIN} apply --check`. In sync → the build proceeds. -//! On drift → it heals (`apply`) then FAILS the build (recoverable → "rebuild"; -//! unrecoverable → "could NOT be reconciled"). A missing CLI fails the build. -//! There is no `warn`/`off` escape. -//! -//! Uses a stub `SOCKET_PATCH_BIN` (a shell script) whose `apply --check` result -//! is controlled via env (`INITIAL_CHECK`, and whether the heal `apply` creates -//! a `HEALED_MARKER`). No real `socket-patch` / network. The guard is a zero-dep -//! path dependency, so `cargo build --offline` needs no downloads. -//! -//! These shell out to a real `cargo build`, but — like the crate's other cargo -//! shell-out tests (`e2e_cargo.rs`, `docker_e2e_cargo.rs`) -//! — they run as part of the normal suite and self-skip via `has_command("cargo")` -//! when the toolchain is absent, rather than being `#[ignore]`d (an `#[ignore]`d -//! guard test protects nothing in CI). `#[cfg(unix)]` for the shell stub. - -#![cfg(unix)] - -use std::path::{Path, PathBuf}; -use std::process::Output; - -#[path = "common/mod.rs"] -mod common; - -use common::{cargo_run, has_command}; - -/// Scaffold a consumer that depends on the guard (path dep) + a stub -/// `socket-patch`. The stub records argv to `/invoked.txt`; for -/// `apply --check` it exits 0 if `/healed` exists else `$INITIAL_CHECK` -/// (default 0); a heal `apply` creates `/healed` unless `HEAL_FAILS` is set. -/// Returns (tmp, consumer, cargo_home, stub, sentinel, healed_marker). -fn scaffold() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf, PathBuf, PathBuf) { - let guard_dir = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join("socket-patch-guard"); - assert!(guard_dir.join("Cargo.toml").exists(), "guard crate not found"); - - let tmp = tempfile::tempdir().unwrap(); - let consumer = tmp.path().join("consumer"); - let cargo_home = tmp.path().join("cargo-home"); - std::fs::create_dir_all(consumer.join("src")).unwrap(); - std::fs::create_dir_all(&cargo_home).unwrap(); - - std::fs::write( - consumer.join("Cargo.toml"), - format!( - "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nsocket-patch-guard = {{ path = {:?} }}\n", - guard_dir - ), - ) - .unwrap(); - std::fs::write(consumer.join("src/main.rs"), "fn main() {}\n").unwrap(); - - let sentinel = tmp.path().join("invoked.txt"); - let healed = tmp.path().join("healed"); - let stub = tmp.path().join("stub-socket-patch.sh"); - std::fs::write( - &stub, - format!( - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {sentinel:?}\ncase \"$*\" in\n *--check*)\n if [ -f {healed:?} ]; then exit 0; fi\n exit ${{INITIAL_CHECK:-0}} ;;\n *)\n if [ -z \"$HEAL_FAILS\" ]; then : > {healed:?}; fi\n exit 0 ;;\nesac\n" - ), - ) - .unwrap(); - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap(); - } - - (tmp, consumer, cargo_home, stub, sentinel, healed) -} - -/// Read the stub's recorded invocations (one `$*` line per call), in order. -/// Fails loudly if the stub was never invoked at all. -fn invocations(sentinel: &Path) -> Vec { - std::fs::read_to_string(sentinel) - .expect("guard should have invoked the stub at least once") - .lines() - .map(str::to_string) - .collect() -} - -fn is_check(line: &str) -> bool { - line.contains("--check") -} - -fn is_heal(line: &str) -> bool { - line.contains("apply") && !line.contains("--check") -} - -/// Assert an invocation carries the *full* expected arg set for `root`, not just -/// an incidental `--check`/`apply` substring. `check` selects probe vs heal. -fn assert_full_args(line: &str, root: &str, check: bool) { - for needle in ["apply", "--offline", "--ecosystems", "cargo", "--cwd", root] { - assert!(line.contains(needle), "invocation missing `{needle}`:\n{line}"); - } - assert_eq!( - line.contains("--check"), - check, - "unexpected --check presence (expected check={check}):\n{line}" - ); -} - -fn build(consumer: &Path, cargo_home: &Path, stub: &Path, extra_env: &[(&str, &str)]) -> Output { - // Neutralize the stub's control vars FIRST so an ambient `INITIAL_CHECK` / - // `HEAL_FAILS` in the runner's environment can't silently flip a test's - // expected drift/heal outcome. `cargo_run` applies vars in order and - // later-wins (`Command::env`), so a test's `extra_env` still overrides these - // safe defaults — but a leaked ambient value can no longer reach the stub. - let mut env: Vec<(&str, &str)> = vec![ - ("CARGO_HOME", cargo_home.to_str().unwrap()), - ("SOCKET_PATCH_ROOT", consumer.to_str().unwrap()), - ("SOCKET_PATCH_BIN", stub.to_str().unwrap()), - ("INITIAL_CHECK", "0"), - ("HEAL_FAILS", ""), - ]; - env.extend_from_slice(extra_env); - cargo_run(consumer, &["build", "--offline"], &env) -} - -/// In sync (`apply --check` exits 0) → build succeeds; the guard probed via -/// `apply --check` and did NOT run a heal `apply`. -#[test] -fn guard_in_sync_proceeds_without_heal() { - if !has_command("cargo") { - eprintln!("SKIP: cargo not on PATH"); - return; - } - let (tmp, consumer, cargo_home, stub, sentinel, _healed) = scaffold(); - let out = build(&consumer, &cargo_home, &stub, &[("INITIAL_CHECK", "0")]); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - out.status.success(), - "in-sync build must succeed.\nstderr:\n{stderr}" - ); - // An in-sync build must emit NONE of the fail-closed diagnostics: a guard - // that healed/failed-then-somehow-recovered (or printed a drift warning on a - // clean tree) would be wrong even though the build happened to succeed. - for forbidden in ["regenerated", "could NOT be reconciled", "could not run `apply --check`"] { - assert!( - !stderr.contains(forbidden), - "in-sync build must not emit the `{forbidden}` diagnostic.\nstderr:\n{stderr}" - ); - } - // Exactly one invocation — the read-only probe — and nothing else: an - // in-sync build must probe once and must NOT heal. Counting (not just - // "any heal line") closes the loophole of a duplicate/extra probe slipping - // through, and `assert_full_args` verifies the real `apply --check - // --offline --ecosystems cargo --cwd ` arg set, not a bare substring. - let inv = invocations(&sentinel); - assert_eq!( - inv.len(), - 1, - "in-sync build must probe exactly once with no heal:\n{inv:#?}" - ); - assert!(is_check(&inv[0]), "the sole invocation must be the `apply --check` probe:\n{}", inv[0]); - assert_full_args(&inv[0], consumer.to_str().unwrap(), true); - drop(tmp); -} - -/// Recoverable drift: `apply --check` first fails, the heal `apply` fixes it, so -/// the re-check passes → the build FAILS with the "regenerated / re-run" message -/// (the heal happened; the retry is clean). Proves fail-closed + auto-heal. -#[test] -fn guard_recoverable_drift_heals_then_fails_with_rebuild_message() { - if !has_command("cargo") { - eprintln!("SKIP: cargo not on PATH"); - return; - } - let (tmp, consumer, cargo_home, stub, sentinel, _healed) = scaffold(); - let out = build(&consumer, &cargo_home, &stub, &[("INITIAL_CHECK", "1")]); - assert!(!out.status.success(), "drift must FAIL the build (fail-closed)"); - let stderr = String::from_utf8_lossy(&out.stderr); - // Assert the SPECIFIC recoverable message (a single AND, not a disjunction): - // the heal succeeded and the user is told to re-run. Crucially it must NOT be - // the unrecoverable message — a guard that misclassified a healed state as - // unrecoverable would still fail the build, so checking only "did it fail" - // (or an OR that also accepts the unrecoverable text) would let that pass. - assert!( - stderr.contains("regenerated"), - "recoverable drift should report the patches were regenerated.\nstderr:\n{stderr}" - ); - assert!( - !stderr.contains("could NOT be reconciled"), - "a recovered heal must NOT report the unrecoverable message.\nstderr:\n{stderr}" - ); - // Exact sequence: probe (drift) → heal `apply` → re-probe (now in sync). - // Asserting the ordered triple (not just counts) proves the heal ran - // *between* the two probes, which is the whole recoverable contract. - let inv = invocations(&sentinel); - assert_eq!(inv.len(), 3, "recoverable drift = probe, heal, re-probe (3 calls):\n{inv:#?}"); - assert!(is_check(&inv[0]), "1st call must be the probe:\n{}", inv[0]); - assert!(is_heal(&inv[1]), "2nd call must be the heal `apply`:\n{}", inv[1]); - assert!(is_check(&inv[2]), "3rd call must be the re-probe:\n{}", inv[2]); - let root = consumer.to_str().unwrap(); - assert_full_args(&inv[0], root, true); - assert_full_args(&inv[1], root, false); - assert_full_args(&inv[2], root, true); - drop(tmp); -} - -/// Unrecoverable drift: the heal can't reconcile (re-check still fails) → the -/// build FAILS with the "could NOT be reconciled" message. -#[test] -fn guard_unrecoverable_drift_fails_closed() { - if !has_command("cargo") { - eprintln!("SKIP: cargo not on PATH"); - return; - } - let (tmp, consumer, cargo_home, stub, sentinel, _healed) = scaffold(); - let out = build( - &consumer, - &cargo_home, - &stub, - &[("INITIAL_CHECK", "1"), ("HEAL_FAILS", "1")], - ); - assert!(!out.status.success(), "unrecoverable drift must FAIL the build"); - let stderr = String::from_utf8_lossy(&out.stderr); - // Assert the SPECIFIC unrecoverable message, not a generic substring: cargo's - // "failed to run custom build command for `socket-patch-guard …`" boilerplate - // contains "socket-patch" on ANY build-script failure, so `|| "socket-patch"` - // would pass even if the guard failed for an unrelated reason. - assert!( - stderr.contains("could NOT be reconciled"), - "unrecoverable drift should report it can't reconcile.\nstderr:\n{stderr}" - ); - // ...and emphatically NOT the recoverable "regenerated, re-run" message — a - // guard that healed but still reports success-style text would be wrong. - assert!( - !stderr.contains("regenerated"), - "unrecoverable drift must NOT claim the patches were regenerated.\nstderr:\n{stderr}" - ); - // Prove it reached the unrecoverable classification via the exact - // heal-then-reprobe sequence (probe → heal → re-probe, still drift), not an - // incidental build failure that merely happened to mention socket-patch. - let inv = invocations(&sentinel); - assert_eq!(inv.len(), 3, "unrecoverable drift = probe, heal, re-probe (3 calls):\n{inv:#?}"); - assert!(is_check(&inv[0]), "1st call must be the probe:\n{}", inv[0]); - assert!(is_heal(&inv[1]), "2nd call must be the heal `apply`:\n{}", inv[1]); - assert!(is_check(&inv[2]), "3rd call must be the re-probe:\n{}", inv[2]); - let root = consumer.to_str().unwrap(); - assert_full_args(&inv[0], root, true); - assert_full_args(&inv[1], root, false); - assert_full_args(&inv[2], root, true); - drop(tmp); -} - -/// Missing CLI → the probe can't run → fail-closed (no escape hatch). -#[test] -fn guard_missing_cli_fails_closed() { - if !has_command("cargo") { - eprintln!("SKIP: cargo not on PATH"); - return; - } - let (tmp, consumer, cargo_home, _stub, sentinel, _healed) = scaffold(); - let missing = tmp.path().join("does-not-exist-socket-patch"); - let out = build(&consumer, &cargo_home, &missing, &[]); - assert!(!out.status.success(), "a missing CLI must FAIL the build (fail-closed)"); - let stderr = String::from_utf8_lossy(&out.stderr); - // Deterministic probe-error string only (the `|| "socket-patch"` escape that - // cargo's per-crate failure boilerplate always satisfies is dropped). - assert!( - stderr.contains("could not run `apply --check`"), - "missing CLI should report it can't run the check.\nstderr:\n{stderr}" - ); - // It must be the probe-error path, NOT a heal/drift path: with no runnable - // CLI the guard cannot heal or reconcile anything. - assert!( - !stderr.contains("regenerated") && !stderr.contains("could NOT be reconciled"), - "missing-CLI failure must be the probe-error path, not a heal path.\nstderr:\n{stderr}" - ); - // The real (missing) bin can never have recorded an invocation; the stub - // from scaffold() is a different path and must stay untouched. - assert!( - !sentinel.exists(), - "an unrunnable CLI cannot have recorded any invocation" - ); - drop(tmp); -} diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index d69b87d..a0ea9e0 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -40,7 +40,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_VEX_PRODUCT", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", - "SOCKET_PATCH_ROOT", "SOCKET_PATCH_BIN", "SOCKET_PATCH_DEBUG", ]; diff --git a/crates/socket-patch-cli/tests/setup_invariants.rs b/crates/socket-patch-cli/tests/setup_invariants.rs index c4ea48a..c5ae3a3 100644 --- a/crates/socket-patch-cli/tests/setup_invariants.rs +++ b/crates/socket-patch-cli/tests/setup_invariants.rs @@ -77,8 +77,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_BREAK_LOCK", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", - // Legacy / cargo-backend knobs that also steer setup behaviour. - "SOCKET_PATCH_ROOT", + // Other SOCKET_PATCH_* knobs that could steer setup behaviour. "SOCKET_PATCH_BIN", "SOCKET_PATCH_DEBUG", "SOCKET_PATCH_PROXY_URL", diff --git a/crates/socket-patch-cli/tests/setup_matrix_deno.rs b/crates/socket-patch-cli/tests/setup_matrix_deno.rs index e72da1f..8c8a1a1 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_deno.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_deno.rs @@ -85,7 +85,7 @@ mod host_guard { /// / `SOCKET_MANIFEST_PATH` etc. in the shell or CI could stand in for a /// flag and mask a flag-handling regression (e.g. `--cwd` being ignored, /// or `--check` silently succeeding). Strip the full surface so behaviour - /// reflects the explicit flags alone. Mirrors `setup_matrix_cargo.rs`. + /// reflects the explicit flags alone. Mirrors `setup_matrix_pypi.rs`. const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_CWD", "SOCKET_MANIFEST_PATH", @@ -110,8 +110,6 @@ mod host_guard { "SOCKET_SAVE_ONLY", "SOCKET_ONE_OFF", "SOCKET_ALL_RELEASES", - "SOCKET_PATCH_ROOT", - "SOCKET_PATCH_GUARD", ]; /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. diff --git a/crates/socket-patch-cli/tests/setup_matrix_gem.rs b/crates/socket-patch-cli/tests/setup_matrix_gem.rs index 30e1495..b954437 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_gem.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_gem.rs @@ -77,7 +77,7 @@ mod host_guard { /// this, an ambient `SOCKET_CWD` / `SOCKET_JSON` / `SOCKET_OFFLINE` in /// the shell or CI could satisfy an assertion via the environment rather /// than the flag under test. (Mirrors the scrub used by the - /// `cli_parse_*` and `setup_matrix_cargo` suites.) + /// `cli_parse_*` and `setup_matrix_pypi` suites.) const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_CWD", "SOCKET_MANIFEST_PATH", @@ -102,8 +102,6 @@ mod host_guard { "SOCKET_SAVE_ONLY", "SOCKET_ONE_OFF", "SOCKET_ALL_RELEASES", - "SOCKET_PATCH_ROOT", - "SOCKET_PATCH_GUARD", "SOCKET_PATCH_BIN", ]; diff --git a/crates/socket-patch-cli/tests/setup_matrix_maven.rs b/crates/socket-patch-cli/tests/setup_matrix_maven.rs index 66cf8e3..9447695 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_maven.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_maven.rs @@ -97,7 +97,7 @@ mod host_guard { /// an ambient `SOCKET_CWD` could retarget the run away from our maven /// fixture, and `SOCKET_EXPERIMENTAL_MAVEN` is scrubbed too so an enabled /// gate in the shell/CI can never quietly turn maven into a configurable - /// surface behind the test's back. Mirrors `setup_matrix_cargo.rs` / + /// surface behind the test's back. Mirrors `setup_matrix_pypi.rs` / /// `setup_matrix_deno.rs`. const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_CWD", @@ -123,8 +123,6 @@ mod host_guard { "SOCKET_SAVE_ONLY", "SOCKET_ONE_OFF", "SOCKET_ALL_RELEASES", - "SOCKET_PATCH_ROOT", - "SOCKET_PATCH_GUARD", "SOCKET_EXPERIMENTAL_MAVEN", ]; diff --git a/crates/socket-patch-cli/tests/setup_matrix_npm.rs b/crates/socket-patch-cli/tests/setup_matrix_npm.rs index 3e0b5b7..62e5fe9 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_npm.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_npm.rs @@ -94,7 +94,7 @@ mod host_guard { /// (`--cwd`, `--yes`, `--check`, `--remove`). Without this, an ambient /// `SOCKET_CWD` / `SOCKET_YES` in the shell or CI could satisfy an assertion /// via the environment rather than the flag under test. (Mirrors the scrub - /// used by the `cli_parse_*` and cargo host-guard suites.) + /// used by the `cli_parse_*` and gem host-guard suites.) const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_CWD", "SOCKET_MANIFEST_PATH", diff --git a/crates/socket-patch-cli/tests/setup_matrix_nuget.rs b/crates/socket-patch-cli/tests/setup_matrix_nuget.rs index 79e5af8..ee1ae0f 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_nuget.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_nuget.rs @@ -89,7 +89,7 @@ mod host_guard { /// this, an ambient `SOCKET_CWD` / `SOCKET_JSON` / `SOCKET_OFFLINE` in /// the shell or CI could satisfy an assertion via the environment rather /// than the flag under test. (Mirrors the scrub used by the - /// `cli_parse_*` and `setup_matrix_cargo`/`setup_matrix_gem` suites.) + /// `cli_parse_*` and `setup_matrix_gem` suites.) const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_CWD", "SOCKET_MANIFEST_PATH", @@ -114,8 +114,6 @@ mod host_guard { "SOCKET_SAVE_ONLY", "SOCKET_ONE_OFF", "SOCKET_ALL_RELEASES", - "SOCKET_PATCH_ROOT", - "SOCKET_PATCH_GUARD", "SOCKET_EXPERIMENTAL_NUGET", ]; diff --git a/crates/socket-patch-cli/tests/setup_matrix_pypi.rs b/crates/socket-patch-cli/tests/setup_matrix_pypi.rs index 8d00af6..a83c1de 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_pypi.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_pypi.rs @@ -106,7 +106,7 @@ mod host_guard { /// this, an ambient `SOCKET_CWD` / `SOCKET_JSON` / `SOCKET_OFFLINE` in /// the shell or CI could satisfy an assertion via the environment rather /// than the flag under test. (Mirrors the scrub used by the - /// `cli_parse_*` and `setup_matrix_cargo` suites.) + /// `cli_parse_*` and `setup_matrix_gem` suites.) const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_CWD", "SOCKET_MANIFEST_PATH", @@ -131,8 +131,6 @@ mod host_guard { "SOCKET_SAVE_ONLY", "SOCKET_ONE_OFF", "SOCKET_ALL_RELEASES", - "SOCKET_PATCH_ROOT", - "SOCKET_PATCH_GUARD", ]; /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. diff --git a/crates/socket-patch-guard/Cargo.toml b/crates/socket-patch-guard/Cargo.toml deleted file mode 100644 index e95209e..0000000 --- a/crates/socket-patch-guard/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "socket-patch-guard" -description = "Build-time guard that keeps socket-patch's project-local cargo patches in sync — its build script re-runs `socket-patch apply` whenever the dependency set (Cargo.lock) or patch set (.socket/manifest.json) changes. Add it under [dependencies] and run `socket-patch setup`." -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -readme = "README.md" - -# The crate is intentionally a near-empty library: all of the work happens in -# `build.rs`, which the consumer's build runs because the guard is a normal -# `[dependencies]` entry. Zero runtime dependencies — it links one tiny rlib -# into the consumer's graph. -[dependencies] - -# Test-only: the same-tick heal R&D experiment scaffolds a throwaway cargo -# workspace. Dev-deps are excluded from the published crate. -[dev-dependencies] -tempfile = { workspace = true } diff --git a/crates/socket-patch-guard/README.md b/crates/socket-patch-guard/README.md deleted file mode 100644 index 7f6eb08..0000000 --- a/crates/socket-patch-guard/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# socket-patch-guard - -A tiny build-time guard for [socket-patch](https://github.com/SocketDev/socket-patch)'s -**project-local cargo patch** backend. - -You don't use this crate directly. Run `socket-patch setup` in a Rust project and -it adds `socket-patch-guard` to your `[dependencies]` and writes -`[env] SOCKET_PATCH_ROOT` into `.cargo/config.toml`. From then on, a bare -`cargo build` verifies your committed security patches and **fails the build if -they're out of sync** — so you can never silently ship stale or unpatched code. - -Once wired by `socket-patch setup`, there is exactly **one mode: fail-closed** — -no drift-tolerating `warn` or `off`. (Before setup runs, an unconfigured project -has no `SOCKET_PATCH_ROOT` and is simply not guarded yet — see -[Environment](#environment).) - -- Patches are applied declaratively by committed `[patch.crates-io]` entries + - patched-crate copies under `.socket/cargo-patches/`. In the steady state (the - committed copies match `.socket/manifest.json`) the guard's build script is a - cached no-op and the build proceeds with zero overhead. In normal use the - guard never fires, because changing a patch goes through `socket-patch get` / - `apply`, which regenerate the copies. -- On a relevant change (`Cargo.lock` or `.socket/manifest.json`), the guard runs - `socket-patch apply --check` (read-only). If in sync, the build proceeds. On - drift it runs `socket-patch apply` to regenerate the copies, then **fails the - build** (the current build already compiled the stale copy): - - recoverable (the heal reconciled it) → "regenerated — re-run the build"; the - re-run is clean (it *did* apply the patch); - - unrecoverable (a patched dependency resolved to an unpatched version, or the - patch data is corrupt/missing) → fails with diagnostics to run - `socket-patch apply` and inspect. -- A missing `socket-patch` CLI also **fails the build** — verification is - mandatory. (So a repo wired with the guard requires `socket-patch` to build; - wire it into apps/workspaces you control, not a published library.) - -This is run-order-independent: it checks the static committed state, not whatever -the build script happens to do mid-build. - -## CI - -The guard already fails any `cargo build` on drift. As an explicit, build-free -pipeline gate you can also run: - -```sh -socket-patch apply --check --ecosystems cargo -``` - -Read-only, offline, lock-free; exits non-zero on drift — including a `Cargo.lock` -cross-check that catches a patched dependency silently resolving to an unpatched -version. - -## Environment - -- `SOCKET_PATCH_ROOT` — set by `setup` in `.cargo/config.toml`; the project root - the guard operates on. If unset, the guard warns and does nothing. -- `SOCKET_PATCH_BIN` — override the `socket-patch` binary path (defaults to - `socket-patch` on `PATH`). - -The guard is a normal `[dependencies]` entry (not a `[build-dependencies]` one) -so cargo always compiles it and runs its build script — it links one tiny empty -rlib into your crate. Your own `build.rs` is never touched. diff --git a/crates/socket-patch-guard/SAME_TICK_HEAL_RND.md b/crates/socket-patch-guard/SAME_TICK_HEAL_RND.md deleted file mode 100644 index b52f9cd..0000000 --- a/crates/socket-patch-guard/SAME_TICK_HEAL_RND.md +++ /dev/null @@ -1,115 +0,0 @@ -# R&D: same-tick auto-heal for project-local cargo patches - -**Status:** experiment complete — *positive result*. Not yet shipped (blocked only -on publishing the guard). Production behavior today is the single fail-closed -guard documented in `README.md`. - -## The question - -The shipped guard is **fail-closed with a one-build lag on drift**: if the -committed patched copies under `.socket/cargo-patches/` are stale, this build has -*already* compiled the stale copy by the time the guard's build script runs, so -the guard heals and then *fails* the build — the re-run is clean. - -Can we instead make a manifest change take effect in the **same** `cargo build`, -so a bare `cargo build` never compiles stale sources and never has to fail? - -The original worry (and why we shipped fail-closed first): cargo computes a -crate's fingerprint and *schedules its compile before build scripts run*, and -sibling units have no ordering guarantee — so a guard that heals "somewhere -during the build" can't guarantee the patched copy is recompiled afterwards. - -That worry is about **siblings with no dependency edge**. The spike asks whether a -real dependency edge removes it. - -## The mechanism tested - -Make each patched **copy** carry a normal `[dependencies]` edge on the guard: - -``` -consumer ──▶ c (patched copy) ──▶ g (guard) - └─ build.rs heals c's source from the manifest -``` - -cargo builds a crate's dependencies — *including their build scripts* — before the -crate itself, and evaluates a crate's source freshness when it gets to building -it (after its deps). So `g`'s `build.rs` rewrites `c`'s source **before** cargo -compiles `c`, and cargo then compiles `c` from the healed source. This isn't -fragile sibling timing; it's cargo's most fundamental invariant: *a crate is -built after its dependencies, from its current source.* - -## Result (cargo 1.93.1, macOS — reproducible via `tests/same_tick_heal_experiment.rs`) - -`g/build.rs` reads `value.txt` (stands in for `.socket/manifest.json`) and rewrites -`c/src/lib.rs` to `pub fn v() -> u32 { }`; `consumer` prints `c::v()`. - -| Step | On-disk `c` before build | `value.txt` | One `cargo build` prints | Recompiled `c`? | -|------|--------------------------|-------------|--------------------------|-----------------| -| #1 | `0` (stale) | `111` | **111** (not `0`) | yes | -| #2 | `111` | `111` | `111` | **no** (cached) | -| #3 | `111` | `222` | **222** | yes | - -**Same-tick heal works**, and it is *free in steady state*: when the manifest is -unchanged the guard's build script is a cached no-op (`Finished in 0.00s`) and the -copy is not recompiled. A manifest change recompiles only the affected copy, in -one build. - -## Why this is reasonably robust - -The earlier "fingerprint is computed before build scripts" concern does not apply -here, because there is a dependency edge: cargo *must* finish `g` (build script -included) before it builds `c`, and it reads `c`'s source at that later point. -Relying on "cargo recompiles a crate whose source changed, after its deps" is -about as safe a cargo assumption as exists. The spike confirms it empirically; it -is the natural behavior, not an exploit of an internal detail. - -## Costs and caveats (what productionizing would require) - -1. **The guard becomes a linked dependency of every patched copy.** The guard - lib is linked into each copy's dependency graph, so it must be buildable for - whatever *target* the consumer compiles for. For ordinary hosted targets a - `std` guard links fine even into `#![no_std]` copies (verified: a crate's - `#![no_std]` governs only its own prelude/std use, not what its dependencies - may use). Only if socket-patch must patch crates compiled for a bare-metal / - `no_std` *target* (where the `std` crate can't be built at all — and the copy's - own std-using deps would fail regardless) would the guard itself need to be - `#![no_std]` + no-alloc. -2. **Publish-gated.** A copy's `Cargo.toml` must reference the guard *portably* - (`socket-patch-guard = "x.y"`), not by `path` — path refs don't survive a fresh - clone on another machine. So this can ship to real users **only after the guard - is on crates.io**. Pre-publish, injecting an unresolvable guard dep into copies - would break every build, which is exactly why production `apply_cargo_redirect` - must **not** inject it yet. -3. **`apply` would inject the edge.** `apply_cargo_redirect` would add - `[dependencies] socket-patch-guard = "x.y"` to each generated copy's - `Cargo.toml`. This was deliberately left out of the shipped code. -4. **The guard would heal-and-proceed (not fail).** In this model the build script - heals and returns `Ok`; the same-tick recompile makes "proceed" correct. The - fail-closed guard on the user's *own* crates and the `socket-patch apply --check - --ecosystems cargo` CI gate remain as backstops in the (unlikely) event a future - cargo ever broke the dependency-ordered-freshness invariant. -5. **Manifest-only re-trigger — a regression vs. the shipped guard.** Heal-and- - proceed re-fires only on `cargo:rerun-if-changed` of the manifest / `Cargo.lock`, - so a copy that drifts *without* a manifest change (a bad merge, a partial - checkout, a hand-edit of `.socket/cargo-patches/`) is a cached no-op for the - build script — it compiles and ships the stale copy silently on a local build. - The shipped fail-closed guard does **not** have this gap: its `apply --check` - re-hashes every copy file against the manifest on every build rather than - trusting `rerun-if-changed`. So heal-and-proceed is "no fail, no lag" only for - *manifest-driven* changes; productionizing it would need the guard to also - `rerun-if-changed` each copy's files (and content-verify, since an mtime touch - alone is insufficient). - -## Recommendation - -Productionize **after the guard is published**. The same-tick heal is empirically -validated and rests on a fundamental cargo invariant, and it delivers the ideal -the user asked for: a bare `cargo build` applies the patch with **no fail and no -lag for manifest-driven changes**, at zero steady-state cost (modulo caveat #5 — -manifest-independent copy drift would need extra `rerun-if-changed` coverage). The -only deployment blocker is publishing the guard so copies can reference it -portably. - -Until then, ship the single fail-closed guard (heal-then-fail-once on drift, -fail-closed on a missing CLI or unrecoverable state). The reproducible experiment -lives in `tests/same_tick_heal_experiment.rs` (`#[ignore]`, runs a real cargo). diff --git a/crates/socket-patch-guard/build.rs b/crates/socket-patch-guard/build.rs deleted file mode 100644 index 1b8f18e..0000000 --- a/crates/socket-patch-guard/build.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Build-time guard (single fail-closed mode). Runs `socket-patch apply --check` -//! to verify the committed cargo patches match `.socket/manifest.json`. In sync -//! → the build proceeds. On drift → it heals (`apply`) and then FAILS this build -//! (the current build already compiled the stale copy), so a `cargo build` can -//! never silently use stale/unpatched sources; the re-run is clean. If the heal -//! can't reconcile (a patched dep resolved to an unpatched version, or the data -//! is corrupt/missing) or the CLI can't be run, it fails-closed with diagnostics. -//! There is no drift-tolerating `warn`/`off` mode (an unconfigured project with -//! no `SOCKET_PATCH_ROOT` is simply not guarded yet — see `plan`). -//! -//! A build script cannot depend on the crate it builds, so the pure decision -//! logic is `include!`d from `src/logic.rs` (the same file `lib.rs` exposes as a -//! module for unit tests). This file holds only the I/O + side effects. - -include!("src/logic.rs"); - -use std::process::Command; - -/// Run `apply --check` and classify the result. `detail` captures the command's -/// output (used in the unrecoverable-drift message). -fn probe(bin: &str, root: &str) -> (Probe, String) { - match Command::new(bin).args(check_args(root)).output() { - Ok(out) if out.status.success() => (Probe::InSync, String::new()), - Ok(out) => { - let mut detail = String::from_utf8_lossy(&out.stdout).to_string(); - detail.push_str(&String::from_utf8_lossy(&out.stderr)); - (Probe::Drift, detail) - } - Err(e) => (Probe::ProbeError(e.to_string()), String::new()), - } -} - -fn main() { - let root = std::env::var("SOCKET_PATCH_ROOT").ok(); - let bin = std::env::var("SOCKET_PATCH_BIN").ok(); - - let (root, bin) = match plan(root.as_deref(), bin.as_deref()) { - Plan::SkipRootUnset => { - // Re-run if the var appears later (e.g. after `socket-patch setup`). - println!("cargo:rerun-if-env-changed=SOCKET_PATCH_ROOT"); - println!( - "cargo:warning=socket-patch: SOCKET_PATCH_ROOT is unset; \ - run `socket-patch setup` to enable the cargo patch guard" - ); - return; - } - Plan::Run { root, bin } => (root, bin), - }; - - for key in rerun_keys(&root) { - println!("{key}"); - } - - let (probe1, _) = probe(&bin, &root); - match decide_initial(&probe1) { - Action::Proceed => {} - Action::Fail(msg) => panic!("{msg}"), - Action::Heal => { - // Heal so the re-run is clean, then re-probe to distinguish a - // recoverable stale copy from an unrecoverable state. - let _ = Command::new(&bin).args(apply_args(&root)).status(); - let (reprobe, detail) = probe(&bin, &root); - panic!("{}", fail_message_after_heal(&reprobe, &detail)); - } - } -} diff --git a/crates/socket-patch-guard/src/lib.rs b/crates/socket-patch-guard/src/lib.rs deleted file mode 100644 index cfec2d5..0000000 --- a/crates/socket-patch-guard/src/lib.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! `socket-patch-guard` — a tiny build-time guard crate. -//! -//! Add it under your crate's `[dependencies]` (via `socket-patch setup`) and -//! cargo will compile it and run its [`build script`](../build.rs) on every -//! `build` / `test` / `check` / `install`. The build script is a cached no-op -//! until the dependency set (`Cargo.lock`) or patch set -//! (`.socket/manifest.json`) changes, at which point it re-runs -//! `socket-patch apply --offline --ecosystems cargo` to regenerate the -//! project-local patched-crate copies under `.socket/cargo-patches/`. -//! -//! The library itself is intentionally empty — it exists only so the build -//! script runs in the consumer's graph. The decision logic lives in -//! [`logic`] (shared with `build.rs` via `include!`) so it can be unit-tested. - -mod logic; -pub use logic::*; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn plan_skips_when_root_unset_or_empty() { - assert_eq!(plan(None, None), Plan::SkipRootUnset); - assert_eq!(plan(Some(""), Some("x")), Plan::SkipRootUnset); - } - - #[test] - fn plan_runs_with_default_bin() { - assert_eq!( - plan(Some("/proj"), None), - Plan::Run { - root: "/proj".to_string(), - bin: "socket-patch".to_string() - } - ); - // Empty bin falls back to the default too. - assert_eq!( - plan(Some("/proj"), Some("")), - Plan::Run { - root: "/proj".to_string(), - bin: "socket-patch".to_string() - } - ); - } - - #[test] - fn plan_honours_explicit_bin() { - assert_eq!( - plan(Some("/proj"), Some("/usr/local/bin/socket-patch")), - Plan::Run { - root: "/proj".to_string(), - bin: "/usr/local/bin/socket-patch".to_string() - } - ); - } - - #[test] - fn rerun_keys_name_lockfile_and_manifest() { - let keys = rerun_keys("/proj"); - assert!(keys - .iter() - .any(|k| k == "cargo:rerun-if-env-changed=SOCKET_PATCH_ROOT")); - assert!(keys - .iter() - .any(|k| k == "cargo:rerun-if-changed=/proj/Cargo.lock")); - assert!(keys - .iter() - .any(|k| k == "cargo:rerun-if-changed=/proj/.socket/manifest.json")); - } - - /// The heal rewrites `cargo-patches/`, so the guard must NOT watch its own - /// output (that would re-run on every build); and it watches the resolved - /// `Cargo.lock`, not `Cargo.toml`. Pins these against an over-eager edit. - #[test] - fn rerun_keys_watch_inputs_not_outputs() { - let keys = rerun_keys("/proj"); - assert!( - !keys.iter().any(|k| k.contains("cargo-patches")), - "must not watch the heal's own output dir (would loop): {keys:?}" - ); - assert!( - !keys.iter().any(|k| k.ends_with("/Cargo.toml")), - "watches the resolved lockfile, not the manifest: {keys:?}" - ); - } - - #[test] - fn rerun_keys_also_watches_bin_env_and_has_no_extras() { - // The guard reads SOCKET_PATCH_BIN too, so a change to it must re-run the - // probe. The original test only `any`-checked 3 of the 4 keys, so dropping - // this one would have slipped through — pin it explicitly + pin the count. - let keys = rerun_keys("/proj"); - assert!( - keys.iter() - .any(|k| k == "cargo:rerun-if-env-changed=SOCKET_PATCH_BIN"), - "{keys:?}" - ); - assert_eq!(keys.len(), 4, "unexpected rerun key set: {keys:?}"); - } - - #[test] - fn check_is_read_only_and_apply_heals() { - // The single safety-critical difference between the probe and the heal is - // `--check` (read-only audit) vs no `--check` (mutating regenerate). Pin - // that the probe carries it and the heal does NOT — swapping them would - // either never heal or mutate during the read-only verify. - assert!(check_args("/proj").iter().any(|a| a == "--check")); - assert!(!apply_args("/proj").iter().any(|a| a == "--check")); - // Both must stay cargo-scoped and offline regardless. - for args in [check_args("/proj"), apply_args("/proj")] { - assert!(args.iter().any(|a| a == "--offline"), "{args:?}"); - assert!(args.windows(2).any(|w| w == ["--ecosystems", "cargo"]), "{args:?}"); - assert!(args.windows(2).any(|w| w == ["--cwd", "/proj"]), "{args:?}"); - } - } - - #[test] - fn apply_args_are_offline_cargo_scoped() { - assert_eq!( - apply_args("/proj"), - vec![ - "apply", - "--offline", - "--ecosystems", - "cargo", - "--cwd", - "/proj" - ] - ); - } - - #[test] - fn check_args_are_readonly_offline_cargo_scoped() { - assert_eq!( - check_args("/proj"), - vec![ - "apply", - "--check", - "--offline", - "--ecosystems", - "cargo", - "--cwd", - "/proj" - ] - ); - } - - /// The probe and heal must differ by EXACTLY `--check` — same ecosystem - /// scope, offline flag, and cwd. Complements `check_is_read_only_and_apply_heals` - /// (which checks presence) by pinning that nothing else diverges. - #[test] - fn probe_and_heal_differ_only_by_check() { - let probe_without_check: Vec = check_args("/proj") - .into_iter() - .filter(|a| a != "--check") - .collect(); - assert_eq!(probe_without_check, apply_args("/proj")); - } - - // ── single fail-closed mode: decide_initial ────────────────────── - #[test] - fn decide_initial_in_sync_proceeds() { - assert_eq!(decide_initial(&Probe::InSync), Action::Proceed); - } - - #[test] - fn decide_initial_drift_heals() { - assert_eq!(decide_initial(&Probe::Drift), Action::Heal); - } - - #[test] - fn decide_initial_probe_error_fails_closed() { - // A missing/unspawnable CLI fails the build — no escape hatch. - assert!(matches!( - decide_initial(&Probe::ProbeError("no such file".to_string())), - Action::Fail(_) - )); - } - - // ── after-heal messaging (the build always fails here) ──────────── - #[test] - fn after_heal_in_sync_says_regenerated_and_rerun() { - let m = fail_message_after_heal(&Probe::InSync, ""); - assert!(m.contains("regenerated") && m.to_lowercase().contains("re-run"), "{m}"); - } - - #[test] - fn after_heal_still_drift_is_unrecoverable_and_includes_detail() { - let m = fail_message_after_heal(&Probe::Drift, "resolved version 1.0.1"); - assert!(m.contains("could NOT be reconciled"), "{m}"); - assert!(m.contains("resolved version 1.0.1"), "detail must be surfaced: {m}"); - } - - #[test] - fn after_heal_probe_error_reports_cli() { - let m = fail_message_after_heal(&Probe::ProbeError("boom".to_string()), ""); - assert!(m.contains("could not run") && m.contains("boom"), "{m}"); - } - - #[test] - fn probe_error_message_is_consistent_initial_and_after_heal() { - // A CLI that can't run must produce the SAME diagnostic whether it fails - // the initial probe or the re-probe after a heal — both route through the - // one helper. Guards against the two messages drifting apart. - let initial = match decide_initial(&Probe::ProbeError("zap".to_string())) { - Action::Fail(m) => m, - other => panic!("probe error must fail-closed, got {other:?}"), - }; - let after_heal = fail_message_after_heal(&Probe::ProbeError("zap".to_string()), ""); - assert_eq!(initial, after_heal); - } - - #[test] - fn after_heal_drift_omits_detail_when_blank() { - // A blank / whitespace-only detail must not produce a dangling "detail:" - // line with nothing after it. - let m = fail_message_after_heal(&Probe::Drift, " \n "); - assert!(m.contains("could NOT be reconciled"), "{m}"); - assert!(!m.contains("detail:"), "blank detail must be dropped: {m}"); - } - - #[test] - fn after_heal_in_sync_ignores_detail() { - // The "regenerated, re-run" path describes a successful heal; probe output - // (relevant only to the unrecoverable Drift case) must not leak into it. - let m = fail_message_after_heal(&Probe::InSync, "stale copy of foo@1.2.3"); - assert!(!m.contains("stale copy of foo@1.2.3"), "{m}"); - } - - #[test] - fn after_heal_drift_trims_surrounding_whitespace_from_detail() { - // Non-blank detail is surfaced on its own line, trimmed — no trailing - // blank after "detail:" and no leading indentation from the CLI output. - let m = fail_message_after_heal(&Probe::Drift, " cargo: drift on serde \n"); - assert!(m.contains("\n detail: cargo: drift on serde"), "{m}"); - assert!(!m.contains("detail: "), "leading whitespace must be trimmed: {m}"); - assert!(!m.ends_with(' ') && !m.ends_with('\n'), "trailing whitespace: {m:?}"); - } -} diff --git a/crates/socket-patch-guard/src/logic.rs b/crates/socket-patch-guard/src/logic.rs deleted file mode 100644 index 6abfec6..0000000 --- a/crates/socket-patch-guard/src/logic.rs +++ /dev/null @@ -1,158 +0,0 @@ -// Pure decision logic for the guard's build script. -// -// This file is the single source of truth for *what* the guard does. It is -// both compiled as a module of the library (`mod logic;` in `lib.rs`, so the -// functions are unit-tested) and `include!`d verbatim by `build.rs` (a build -// script cannot depend on the very crate it builds, so sharing happens via -// `include!` rather than a normal import). Inner (`//!`) doc comments are -// deliberately avoided here because `include!` pastes this file mid-`build.rs`, -// where inner docs are illegal. Keep it free of any I/O so it stays trivially -// testable; `build.rs` performs the side effects. -// -// The guard has exactly ONE mode: fail-closed. It verifies the committed cargo -// patches match the manifest (`apply --check`); on drift it tries to heal -// (`apply`), then fails the build (the current build already compiled the stale -// copy) — so a build never silently uses stale/unpatched sources. There is no -// drift-tolerating `warn`/`off` mode: an unrecoverable state or a missing CLI -// fails the build (an unconfigured project with no root is simply not guarded). - -/// What the guard should do, computed purely from environment values. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Plan { - /// `SOCKET_PATCH_ROOT` is unset/empty → warn and do nothing this build. - SkipRootUnset, - /// Operate on `` using ``. - Run { root: String, bin: String }, -} - -/// Compute the plan from the `SOCKET_PATCH_ROOT` / `SOCKET_PATCH_BIN` values. -/// An unset *or empty* root skips; an unset/empty bin defaults to -/// `socket-patch` (resolved from `PATH`). -pub fn plan(root: Option<&str>, bin: Option<&str>) -> Plan { - match root { - Some(r) if !r.is_empty() => Plan::Run { - root: r.to_string(), - bin: match bin { - Some(b) if !b.is_empty() => b.to_string(), - _ => "socket-patch".to_string(), - }, - }, - _ => Plan::SkipRootUnset, - } -} - -/// The `cargo:` directives that make this build script re-run only when the -/// dependency set (`Cargo.lock`) or patch set (`.socket/manifest.json`) under -/// `root` changes (plus the two env vars the guard reads). -pub fn rerun_keys(root: &str) -> Vec { - vec![ - "cargo:rerun-if-env-changed=SOCKET_PATCH_ROOT".to_string(), - "cargo:rerun-if-env-changed=SOCKET_PATCH_BIN".to_string(), - format!("cargo:rerun-if-changed={root}/Cargo.lock"), - format!("cargo:rerun-if-changed={root}/.socket/manifest.json"), - ] -} - -/// Args for the read-only drift probe: `apply --check ...`. Exit 0 = the -/// committed patched copies match the manifest (cargo is compiling correct -/// patches); non-zero = drift (stale copy, or a patch that silently fell back -/// to an unpatched version). Read-only, lock-free, offline. -pub fn check_args(root: &str) -> Vec { - vec![ - "apply".to_string(), - "--check".to_string(), - "--offline".to_string(), - "--ecosystems".to_string(), - "cargo".to_string(), - "--cwd".to_string(), - root.to_string(), - ] -} - -/// Args for the heal: a real `apply` that regenerates the copies to match the -/// manifest. `--offline`: cargo already downloaded the sources during -/// resolution, and the patch artifacts are committed under `.socket/`. -pub fn apply_args(root: &str) -> Vec { - vec![ - "apply".to_string(), - "--offline".to_string(), - "--ecosystems".to_string(), - "cargo".to_string(), - "--cwd".to_string(), - root.to_string(), - ] -} - -/// Outcome of running the read-only `apply --check` probe. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Probe { - /// `apply --check` exited 0: committed patches are in sync — cargo compiled - /// correct, patched sources this build. - InSync, - /// `apply --check` exited non-zero: the committed copies cargo is compiling - /// are stale, or a patched dependency resolved to an unpatched version. - Drift, - /// The probe couldn't run at all (e.g. the binary isn't on `PATH`); carries - /// the OS error text. - ProbeError(String), -} - -/// What `build.rs` should do after the initial probe. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Action { - /// Build proceeds (correct patches were compiled). - Proceed, - /// Run `apply` to heal, then re-probe (see [`fail_message_after_heal`]). - Heal, - /// Panic — fail the build (fail-closed). - Fail(String), -} - -/// Decide from the initial `apply --check`: in sync → proceed; drift → heal; -/// the probe couldn't run → fail-closed (the CLI is required). -pub fn decide_initial(probe: &Probe) -> Action { - match probe { - Probe::InSync => Action::Proceed, - Probe::Drift => Action::Heal, - Probe::ProbeError(err) => Action::Fail(probe_error_message(err)), - } -} - -/// The panic message after a heal + re-probe. The build always fails here (the -/// current build already compiled the stale copy); the message differs by -/// whether the heal reconciled the state: -/// * re-probe in sync → the heal worked → "regenerated, re-run the build"; -/// * re-probe still drift → unrecoverable (e.g. a patched dep resolved to an -/// unpatched version, or corrupt/missing data) → tell the user to inspect; -/// * re-probe errored → the CLI stopped working mid-heal. -pub fn fail_message_after_heal(reprobe: &Probe, detail: &str) -> String { - match reprobe { - Probe::InSync => "socket-patch: cargo patches were out of date and have been \ - regenerated under .socket/cargo-patches/ to match .socket/manifest.json. \ - Re-run the build to compile against the up-to-date patches (this build was \ - failed to avoid using stale patches)." - .to_string(), - Probe::Drift => { - let mut msg = "socket-patch: cargo patches are out of sync and could NOT be \ - reconciled by `apply` — a patched dependency may have resolved to a version \ - the manifest does not patch, or the patch data/manifest is corrupt or \ - missing. Run `socket-patch apply --ecosystems cargo` and inspect." - .to_string(); - let detail = detail.trim(); - if !detail.is_empty() { - msg.push_str("\n detail: "); - msg.push_str(detail); - } - msg - } - Probe::ProbeError(err) => probe_error_message(err), - } -} - -fn probe_error_message(err: &str) -> String { - format!( - "socket-patch: could not run `apply --check` ({err}); the socket-patch CLI is \ - required to verify cargo patches are in sync. Install it or set SOCKET_PATCH_BIN \ - to its path." - ) -} diff --git a/crates/socket-patch-guard/tests/same_tick_heal_experiment.rs b/crates/socket-patch-guard/tests/same_tick_heal_experiment.rs deleted file mode 100644 index 1809b7a..0000000 --- a/crates/socket-patch-guard/tests/same_tick_heal_experiment.rs +++ /dev/null @@ -1,201 +0,0 @@ -//! R&D artifact (NOT shipped behavior): empirically verifies the *same-tick -//! auto-heal* mechanism for the project-local cargo patch backend. -//! -//! Question: if a patched **copy** has a normal dependency on the guard, and the -//! guard's `build.rs` rewrites the copy's source (the "heal"), does cargo compile -//! the *healed* source in the **same** `cargo build` — or only on the next one? -//! -//! This scaffolds a minimal 3-crate workspace that models the mechanism without -//! any `socket-patch` / network involvement: -//! * `g` stands in for `socket-patch-guard`; its `build.rs` reads `value.txt` -//! (the "manifest") and rewrites `c/src/lib.rs` (the "heal"), then proceeds. -//! * `c` stands in for a patched copy; it has `[dependencies] g`, so cargo runs -//! `g`'s build script *before* compiling `c`. -//! * `consumer` depends on `c` and prints `c::v()`. -//! -//! Empirical result (cargo 1.93.1, macOS): build #1 prints the value `g` wrote -//! (`111`) — NOT the `0` that was on disk — proving cargo compiled the healed -//! source same-tick. Changing `value.txt` and building once flips the printed -//! value in a single build. With no change, `c` is a cached no-op (no recompile), -//! so steady-state builds carry zero overhead. See `SAME_TICK_HEAL_RND.md`. -//! -//! `#[ignore]`d because it shells out to a real `cargo`. `#[cfg(unix)]` only to -//! keep path/permission handling simple; the mechanism is not platform-specific. - -#![cfg(unix)] - -use std::path::Path; -use std::process::Command; - -fn has_cargo() -> bool { - Command::new("cargo") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} - -fn write(path: &Path, contents: &str) { - std::fs::write(path, contents).unwrap(); -} - -fn read(path: &Path) -> String { - std::fs::read_to_string(path).unwrap() -} - -/// The body `g`'s build.rs derives for a given manifest value. Mirrors the -/// `format!` in the inline build script so the test's expectation is computed -/// independently of whatever happens to be on disk (not copied from output). -fn healed_body(value: &str) -> String { - format!("pub fn v() -> u32 {{ {value} }}\n") -} - -/// Build the consumer; return (stdout of the run binary, stderr of `cargo build`). -fn build_and_run(ws: &Path) -> (String, String) { - let build = Command::new("cargo") - .args(["build", "-p", "consumer"]) - .current_dir(ws) - .output() - .expect("cargo build"); - assert!( - build.status.success(), - "cargo build failed:\n{}", - String::from_utf8_lossy(&build.stderr) - ); - let run = Command::new(ws.join("target/debug/consumer")) - .output() - .expect("run consumer"); - ( - String::from_utf8_lossy(&run.stdout).trim().to_string(), - String::from_utf8_lossy(&build.stderr).to_string(), - ) -} - -#[test] -#[ignore = "R&D spike; shells out to a real cargo"] -fn copy_dep_on_guard_heals_same_tick() { - if !has_cargo() { - eprintln!("SKIP: cargo not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let ws = tmp.path(); - for d in ["g/src", "c/src", "consumer/src"] { - std::fs::create_dir_all(ws.join(d)).unwrap(); - } - - write( - &ws.join("Cargo.toml"), - "[workspace]\nmembers = [\"g\", \"c\", \"consumer\"]\nresolver = \"2\"\n", - ); - // The "manifest": the value the heal should propagate into the copy. - write(&ws.join("value.txt"), "111\n"); - - write( - &ws.join("g/Cargo.toml"), - "[package]\nname = \"g\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", - ); - write(&ws.join("g/src/lib.rs"), ""); - // The guard's heal: rewrite the copy's source from the manifest, idempotently, - // then proceed. `rerun-if-changed=value.txt` makes it a cached no-op when the - // manifest is unchanged. - write( - &ws.join("g/build.rs"), - r#"use std::io::Write; -fn main() { - let v = std::fs::read_to_string("../value.txt").unwrap().trim().to_string(); - let body = format!("pub fn v() -> u32 {{ {v} }}\n"); - let target = "../c/src/lib.rs"; - if std::fs::read_to_string(target).unwrap_or_default() != body { - std::fs::File::create(target).unwrap().write_all(body.as_bytes()).unwrap(); - } - println!("cargo:rerun-if-changed=../value.txt"); -} -"#, - ); - - // The patched copy depends on the guard (normal dep) → cargo builds the guard - // (runs its build script) before compiling the copy. - write( - &ws.join("c/Cargo.toml"), - "[package]\nname = \"c\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ng = { path = \"../g\" }\n", - ); - // Deliberately STALE on disk: if cargo compiled this verbatim, the consumer - // would print 0. The heal rewrites it before compilation. - let copy_src = ws.join("c/src/lib.rs"); - write(©_src, "pub fn v() -> u32 { 0 }\n"); - // Baseline guard: the discriminator only works if the source genuinely - // starts stale (== 0) and DIFFERS from the value the heal will write. - // Otherwise build #1 could print 111 with no heal at all. - assert_eq!( - read(©_src), - "pub fn v() -> u32 { 0 }\n", - "precondition: copy source must start STALE (0)" - ); - assert_ne!( - read(©_src), - healed_body("111"), - "precondition: stale source must differ from the healed body" - ); - - write( - &ws.join("consumer/Cargo.toml"), - "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nc = { path = \"../c\" }\n", - ); - write( - &ws.join("consumer/src/main.rs"), - "fn main() { println!(\"{}\", c::v()); }\n", - ); - - // Build #1: on-disk copy says 0; the heal writes 111. Same-tick ⇒ prints 111. - let (out, stderr) = build_and_run(ws); - assert_eq!(out, "111", "same-tick heal failed: copy compiled the STALE source"); - // The "111" must come from compiling the healed source IN THIS BUILD — a fresh - // workspace has no prior artifacts, so both the guard and the copy must compile - // from scratch here. If either is silently cached, the same-tick claim is unproven. - assert!( - stderr.contains("Compiling g "), - "fresh build #1 must compile the guard:\n{stderr}" - ); - assert!( - stderr.contains("Compiling c "), - "fresh build #1 must compile the copy (not a cached artifact):\n{stderr}" - ); - // The heal must have physically rewritten the stale source to the healed body. - assert_eq!( - read(©_src), - healed_body("111"), - "heal did not rewrite the copy source on disk" - ); - - // Steady state: nothing changed ⇒ the copy must NOT recompile (zero overhead). - let (out, stderr) = build_and_run(ws); - assert_eq!(out, "111"); - assert!( - !stderr.contains("Compiling c "), - "unchanged build should be cached, but recompiled the copy:\n{stderr}" - ); - // The cached no-op must leave the healed source intact (not revert to stale). - assert_eq!( - read(©_src), - healed_body("111"), - "steady-state build must leave the healed source intact" - ); - - // Change the "manifest"; ONE build must flip the value same-tick. - write(&ws.join("value.txt"), "222\n"); - // Sanity: at this point the on-disk copy still reflects the OLD value, so a - // "222" result can only come from this single build re-healing + recompiling. - assert_eq!(read(©_src), healed_body("111"), "copy should still hold old value pre-build"); - let (out, stderr) = build_and_run(ws); - assert_eq!(out, "222", "manifest change did not take effect in a single build"); - assert!( - stderr.contains("Compiling c "), - "a manifest change must recompile the copy:\n{stderr}" - ); - assert_eq!( - read(©_src), - healed_body("222"), - "manifest change must re-heal the copy source on disk" - ); -} From 724378c8aa742376eb458d32c0be10fb86b9a688 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 19:50:13 -0400 Subject: [PATCH 04/31] refactor(go): parameterize replace-redirect engine by owner/base for the vendor backend - go_mod_edit: ReplaceOwner enum (GoPatches|Vendor), GO_VENDOR_DIR, detect_owner over both prefixes; ensure_replace_entry takes the copy base (cross-owner upsert = vendor takeover), drop_replace_entry is owner-filtered - go_redirect: thread base_rel through apply/remove/in_sync/copy_dir_for; reconcile + verify are GoPatches-scoped and skip vendor-owned modules - new patch/path_safety.rs: shared multi/single-segment + canonical-uuid guards - utils/fs.rs: crate-wide atomic_write_bytes (stage+fsync+rename); go editors delegate - ungate copy_tree (vendor backends for unconditional ecosystems need it) Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/commands/apply.rs | 1 + .../socket-patch-cli/src/commands/rollback.rs | 19 +- .../src/patch/go_mod_edit.rs | 348 +++++++++++------- .../src/patch/go_redirect.rs | 196 +++++----- crates/socket-patch-core/src/patch/mod.rs | 4 +- .../src/patch/path_safety.rs | 116 ++++++ crates/socket-patch-core/src/utils/fs.rs | 55 ++- spikes/uv/README.md | 54 +++ spikes/uv/direct-path-wheel/README.md | 3 + spikes/uv/direct-path-wheel/pyproject.toml | 8 + spikes/uv/direct-path-wheel/uv.lock | 22 ++ spikes/uv/direct-registry/README.md | 2 + spikes/uv/direct-registry/pyproject.toml | 5 + spikes/uv/direct-registry/uv.lock | 23 ++ spikes/uv/override-transitive/README.md | 3 + spikes/uv/override-transitive/pyproject.toml | 11 + spikes/uv/override-transitive/uv.lock | 37 ++ spikes/uv/transitive-promoted/README.md | 3 + spikes/uv/transitive-promoted/pyproject.toml | 8 + spikes/uv/transitive-promoted/uv.lock | 38 ++ spikes/uv/transitive-registry/README.md | 1 + spikes/uv/transitive-registry/pyproject.toml | 5 + spikes/uv/transitive-registry/uv.lock | 35 ++ 23 files changed, 781 insertions(+), 216 deletions(-) create mode 100644 crates/socket-patch-core/src/patch/path_safety.rs create mode 100644 spikes/uv/README.md create mode 100644 spikes/uv/direct-path-wheel/README.md create mode 100644 spikes/uv/direct-path-wheel/pyproject.toml create mode 100644 spikes/uv/direct-path-wheel/uv.lock create mode 100644 spikes/uv/direct-registry/README.md create mode 100644 spikes/uv/direct-registry/pyproject.toml create mode 100644 spikes/uv/direct-registry/uv.lock create mode 100644 spikes/uv/override-transitive/README.md create mode 100644 spikes/uv/override-transitive/pyproject.toml create mode 100644 spikes/uv/override-transitive/uv.lock create mode 100644 spikes/uv/transitive-promoted/README.md create mode 100644 spikes/uv/transitive-promoted/pyproject.toml create mode 100644 spikes/uv/transitive-promoted/uv.lock create mode 100644 spikes/uv/transitive-registry/README.md create mode 100644 spikes/uv/transitive-registry/pyproject.toml create mode 100644 spikes/uv/transitive-registry/uv.lock diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 92157c4..ec8c5ea 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -149,6 +149,7 @@ async fn try_local_go_apply( version, pkg_path, &common.cwd, + socket_patch_core::patch::go_mod_edit::GO_PATCHES_DIR, &patch.files, sources, Some(&patch.uuid), diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 3684834..308bbf9 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -93,6 +93,7 @@ async fn try_rollback_local_go( patch: &PatchRecord, common: &GlobalArgs, ) -> Option { + use socket_patch_core::patch::go_mod_edit::{ReplaceOwner, GO_PATCHES_DIR}; use socket_patch_core::patch::go_redirect::remove_go_redirect; if !is_local_go(purl, common) { return None; @@ -105,7 +106,15 @@ async fn try_rollback_local_go( files_rolled_back: patch.files.keys().cloned().collect(), error: None, }; - if let Err(e) = remove_go_redirect(purl, &common.cwd, common.dry_run).await { + if let Err(e) = remove_go_redirect( + purl, + &common.cwd, + GO_PATCHES_DIR, + ReplaceOwner::GoPatches, + common.dry_run, + ) + .await + { result.success = false; result.files_rolled_back.clear(); result.error = Some(e.to_string()); @@ -1070,7 +1079,7 @@ mod tests { #[tokio::test] async fn try_rollback_local_go_drops_redirect_and_copy() { use socket_patch_core::patch::go_mod_edit::{ - ensure_replace_entry, read_replace_entries, + ensure_replace_entry, read_replace_entries, GO_PATCHES_DIR, }; const MODULE: &str = "github.com/foo/bar"; @@ -1088,7 +1097,7 @@ mod tests { ) .await .unwrap(); - let changed = ensure_replace_entry(root, MODULE, VERSION, false) + let changed = ensure_replace_entry(root, MODULE, VERSION, GO_PATCHES_DIR, false) .await .unwrap(); assert!(changed, "fixture must install a socket-owned replace"); @@ -1104,7 +1113,7 @@ mod tests { assert!(read_replace_entries(root) .await .iter() - .any(|e| e.module == MODULE && e.socket_owned)); + .any(|e| e.module == MODULE && e.socket_owned())); let patch = record_with_file("uuid-go", "errors.go", "go_before"); let common = crate::args::GlobalArgs { @@ -1129,7 +1138,7 @@ mod tests { read_replace_entries(root) .await .iter() - .all(|e| !(e.module == MODULE && e.socket_owned)), + .all(|e| !(e.module == MODULE && e.socket_owned())), "socket-owned replace directive must be dropped" ); // ...the require directive (user-authored) survives... diff --git a/crates/socket-patch-core/src/patch/go_mod_edit.rs b/crates/socket-patch-core/src/patch/go_mod_edit.rs index 0eec306..50b6cbd 100644 --- a/crates/socket-patch-core/src/patch/go_mod_edit.rs +++ b/crates/socket-patch-core/src/patch/go_mod_edit.rs @@ -9,10 +9,19 @@ //! //! ## Ownership model (no sidecar manifest) //! A `replace` directive is *socket-owned* iff its right-hand side is a -//! filesystem path under `.socket/go-patches/`. A module-to-module replacement -//! (`=> example.com/fork v1.2.3`) or a path pointing anywhere else is -//! user-authored and is never modified or removed. This is the entire -//! ownership signal; there is no `managed.json`. +//! filesystem path under one of the two socket-managed prefixes: +//! `.socket/go-patches/` (the `apply` redirect backend, [`ReplaceOwner::GoPatches`]) +//! or `.socket/vendor/golang/` (the `vendor` backend, [`ReplaceOwner::Vendor`]). +//! A module-to-module replacement (`=> example.com/fork v1.2.3`) or a path +//! pointing anywhere else is user-authored and is never modified or removed. +//! The path prefix is the entire ownership signal; there is no `managed.json`. +//! +//! At most one socket-owned `replace` exists per module: `ensure_replace_entry` +//! rewrites an existing socket-owned line of EITHER owner in place (this +//! cross-owner upsert is how `vendor` takes over an `apply` redirect), while +//! `drop_replace_entry` removes only the requested owner's directives (so +//! `apply`'s reconcile can never prune a vendored module and vice versa). +//! Policy about *when* an owner may take over lives in the callers. //! //! ## Why `replace` (validated empirically — see project memory) //! A local-path `replace` target is **not** `go.sum` content-verified, so @@ -26,17 +35,64 @@ use std::path::{Path, PathBuf}; use tokio::fs; -/// Project-relative directory holding patched module copies. A `replace` whose -/// target path is under this prefix is how socket ownership is recognised. +/// Project-relative directory holding `apply`'s patched module copies. A +/// `replace` whose target path is under this prefix is owned by +/// [`ReplaceOwner::GoPatches`]. pub const GO_PATCHES_DIR: &str = ".socket/go-patches"; -/// The expected (project-root-relative) `replace` target path for a module -/// copy. Always `./`-prefixed and forward-slashed: Go treats a replacement -/// target as a *filesystem path* only when it begins with `./`, `../`, or `/` -/// (otherwise it is parsed as a module path), and accepts forward slashes on -/// every platform. +/// Project-relative directory holding `vendor`'s committed module copies +/// (`//@`). A `replace` whose +/// target path is under this prefix is owned by [`ReplaceOwner::Vendor`]. +pub const GO_VENDOR_DIR: &str = ".socket/vendor/golang"; + +/// Which socket-managed backend owns a `replace` directive, classified by the +/// directive's target-path prefix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplaceOwner { + /// `apply`'s machine-local redirect copies under `.socket/go-patches/`. + GoPatches, + /// `vendor`'s committed copies under `.socket/vendor/golang//`. + Vendor, +} + +impl ReplaceOwner { + /// The classifying path prefix (no trailing slash). + pub fn prefix(self) -> &'static str { + match self { + Self::GoPatches => GO_PATCHES_DIR, + Self::Vendor => GO_VENDOR_DIR, + } + } +} + +/// Classify a `replace` target path: which socket backend owns it, or `None` +/// for a user-authored path. The two prefixes don't overlap, but `Vendor` is +/// tested first to keep the intent explicit (`.socket/vendor/golang/` is more +/// specific than a hypothetical future `.socket/` catch-all). +pub fn detect_owner(path: &str) -> Option { + for owner in [ReplaceOwner::Vendor, ReplaceOwner::GoPatches] { + let norm = path.replace('\\', "/"); + let norm = norm.strip_prefix("./").unwrap_or(&norm); + let prefix = format!("{}/", owner.prefix()); + if norm.starts_with(&prefix) || norm.contains(&format!("/{prefix}")) { + return Some(owner); + } + } + None +} + +/// The (project-root-relative) `replace` target path for a copy that lives at +/// `/@`. Always `./`-prefixed and forward-slashed: +/// Go treats a replacement target as a *filesystem path* only when it begins +/// with `./`, `../`, or `/` (otherwise it is parsed as a module path), and +/// accepts forward slashes on every platform. +pub fn replace_target_path(base_rel: &str, module: &str, version: &str) -> String { + format!("./{base_rel}/{module}@{version}") +} + +/// The expected `replace` target for an `apply` (go-patches) redirect copy. pub fn expected_replace_path(module: &str, version: &str) -> String { - format!("./{GO_PATCHES_DIR}/{module}@{version}") + replace_target_path(GO_PATCHES_DIR, module, version) } /// One parsed `replace` directive. @@ -49,8 +105,15 @@ pub struct ReplaceEntry { /// Right-hand-side path, iff the replacement is a filesystem path /// (`None` for a module-to-module `=> mod ver` replacement). pub path: Option, - /// True iff `path` is under `.socket/go-patches/`. - pub socket_owned: bool, + /// Which socket backend owns this directive (`None` = user-authored). + pub owner: Option, +} + +impl ReplaceEntry { + /// True iff the directive is socket-owned (either backend). + pub fn socket_owned(&self) -> bool { + self.owner.is_some() + } } // ── public async API ───────────────────────────────────────────────────────── @@ -73,31 +136,42 @@ pub async fn read_required_versions(project_root: &Path) -> Option => ./.socket/go-patches/@`. -/// Idempotent. Returns whether the file changed. Errors (without writing) if a -/// `go.mod` is absent, or if a *user-authored* `replace` already pins the same -/// `module`+`version` (a duplicate would make `go.mod` invalid). +/// Upsert a socket-owned `replace => .//@`, +/// where `base_rel` is the project-relative copy base (e.g. [`GO_PATCHES_DIR`], +/// or `/` for vendor). Idempotent. An existing +/// socket-owned line for `module` — of EITHER owner — is rewritten in place +/// (the cross-owner case is `vendor` taking over an `apply` redirect). Returns +/// whether the file changed. Errors (without writing) if `go.mod` is absent, +/// or if a *user-authored* `replace` already pins the same `module`+`version` +/// (a duplicate would make `go.mod` invalid). pub async fn ensure_replace_entry( project_root: &Path, module: &str, version: &str, + base_rel: &str, dry_run: bool, ) -> Result { edit_go_mod(project_root, dry_run, |c| { - upsert_replace_entry(c, module, version) + upsert_replace_entry(c, module, version, base_rel) }) .await } -/// Remove the *socket-owned* `replace` directive(s) for `module` (pruning an -/// emptied `replace ( … )` block). A user-authored or absent entry is a no-op. -/// Returns whether the file changed. +/// Remove the `replace` directive(s) for `module` owned by `owner` (pruning an +/// emptied `replace ( … )` block). A user-authored entry, the OTHER owner's +/// entry, or an absent entry is a no-op — so `apply`'s reconcile can never +/// drop a vendored module's directive and vice versa. Returns whether the file +/// changed. pub async fn drop_replace_entry( project_root: &Path, module: &str, + owner: ReplaceOwner, dry_run: bool, ) -> Result { - edit_go_mod(project_root, dry_run, |c| remove_replace_entry(c, module)).await + edit_go_mod(project_root, dry_run, |c| { + remove_replace_entry(c, module, owner) + }) + .await } // ── file resolution + read/write ────────────────────────────────────────────── @@ -135,54 +209,11 @@ async fn edit_go_mod( /// /// A `go.mod` is a *user-owned* file that **defines the module** and carries /// the user's own `require`/`exclude`/`retract`/`replace` directives and -/// comments alongside our socket `replace`. A bare `fs::write` truncates the -/// target before writing, so a crash, power loss, or `ENOSPC` mid-write would -/// leave `go.mod` truncated or empty — a corrupted manifest that no longer -/// builds, when we only meant to add or refresh one line. Instead we stage a -/// sibling file, fsync it, then rename over the target (atomic on the same -/// filesystem), so a reader/recovering process only ever sees the complete old -/// or the complete new bytes. Mirrors the hardened writers in -/// `patch/apply.rs` and `package_json/update.rs`. +/// comments alongside our socket `replace` — a torn write would corrupt a +/// manifest that no longer builds, when we only meant to add or refresh one +/// line. Delegates to the crate-wide hardened writer. async fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let stem = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "go.mod".to_string()); - let stage = parent.join(format!(".socket-stage-{}-{}", stem, uuid::Uuid::new_v4())); - - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&stage) - .await?; - - use tokio::io::AsyncWriteExt; - if let Err(e) = file.write_all(content).await { - let _ = fs::remove_file(&stage).await; - return Err(e); - } - if let Err(e) = file.sync_all().await { - let _ = fs::remove_file(&stage).await; - return Err(e); - } - drop(file); - - if let Err(e) = fs::rename(&stage, path).await { - let _ = fs::remove_file(&stage).await; - return Err(e); - } - - // The rename only updated the parent directory entry; fsync the directory - // so the rename itself survives a crash. Best-effort, Unix only. - #[cfg(unix)] - { - if let Ok(dir) = fs::File::open(parent).await { - let _ = dir.sync_all().await; - } - } - - Ok(()) + crate::utils::fs::atomic_write_bytes(path, content).await } // ── parsing ──────────────────────────────────────────────────────────────── @@ -207,14 +238,6 @@ fn rhs_is_path(tok: &str) -> bool { || (tok.len() >= 2 && tok.as_bytes()[1] == b':') // C:\… } -/// True if a `replace` target path lies under `.socket/go-patches/`. -fn path_is_socket_owned(path: &str) -> bool { - let norm = path.replace('\\', "/"); - let norm = norm.strip_prefix("./").unwrap_or(&norm); - let prefix = format!("{GO_PATCHES_DIR}/"); - norm.starts_with(&prefix) || norm.contains(&format!("/{prefix}")) -} - /// Parse the `module path => target [version]` body of a replace directive /// (the part after the `replace` keyword, or a line inside a `replace ( … )` /// block). Returns `None` if there is no `=>` (not a replace body). @@ -225,18 +248,18 @@ fn parse_replace_body(body: &str) -> Option { let module = (*lhs.first()?).to_string(); let version = lhs.get(1).map(|s| s.to_string()); let first_rhs = rhs.first()?; - let (path, socket_owned) = if rhs_is_path(first_rhs) { + let (path, owner) = if rhs_is_path(first_rhs) { let p = (*first_rhs).to_string(); - let owned = path_is_socket_owned(&p); - (Some(p), owned) + let owner = detect_owner(&p); + (Some(p), owner) } else { - (None, false) // module-to-module replacement + (None, None) // module-to-module replacement }; Some(ReplaceEntry { module, version, path, - socket_owned, + owner, }) } @@ -332,13 +355,14 @@ fn directive_block_open(line: &str, keyword: &str) -> Option { // ── pure transforms ────────────────────────────────────────────────────────── -/// Upsert a socket-owned `replace module version => ./…@version`. +/// Upsert a socket-owned `replace module version => .//…@version`. fn upsert_replace_entry( content: &str, module: &str, version: &str, + base_rel: &str, ) -> Result, String> { - let want_path = expected_replace_path(module, version); + let want_path = replace_target_path(base_rel, module, version); let want_line = format!("replace {module} {version} => {want_path}"); let mut lines: Vec = content.lines().map(str::to_string).collect(); @@ -414,8 +438,11 @@ fn inspect_existing( if e.module != module { return Ok(()); } - if e.socket_owned { - // Our entry (any version): refresh it. Prefer the first one found. + if e.socket_owned() { + // A socket-owned entry (any version, EITHER owner): refresh it in + // place. The cross-owner rewrite is the takeover mechanism — a single + // atomic go.mod write repoints e.g. a go-patches redirect at the + // vendor copy with no remove+add window. if socket_line.is_none() { *socket_line = Some(line_idx); } @@ -438,9 +465,13 @@ fn inspect_existing( Ok(()) } -/// Remove socket-owned `replace` directive(s) for `module`, pruning an emptied -/// `replace ( … )` block. -fn remove_replace_entry(content: &str, module: &str) -> Result, String> { +/// Remove `owner`'s `replace` directive(s) for `module`, pruning an emptied +/// `replace ( … )` block. The other owner's directives are left untouched. +fn remove_replace_entry( + content: &str, + module: &str, + owner: ReplaceOwner, +) -> Result, String> { let lines: Vec<&str> = content.lines().collect(); let mut keep = vec![true; lines.len()]; @@ -465,7 +496,7 @@ fn remove_replace_entry(content: &str, module: &str) -> Result, S if !inner.is_empty() { members_total += 1; if let Some(e) = parse_replace_body(inner) { - if e.module == module && e.socket_owned { + if e.module == module && e.owner == Some(owner) { keep[j] = false; members_removed += 1; changed = true; @@ -487,7 +518,7 @@ fn remove_replace_entry(content: &str, module: &str) -> Result, S } if let Some(body) = line.strip_prefix("replace ") { if let Some(e) = parse_replace_body(body) { - if e.module == module && e.socket_owned { + if e.module == module && e.owner == Some(owner) { keep[i] = false; changed = true; } @@ -524,13 +555,21 @@ mod tests { // ── path ownership ─────────────────────────────────────────────── #[test] - fn test_is_socket_owned() { - assert!(path_is_socket_owned("./.socket/go-patches/github.com/x/y@v1.0.0")); - assert!(path_is_socket_owned(".socket/go-patches/x@v1.0.0")); - assert!(path_is_socket_owned("sub/.socket/go-patches/x@v1.0.0")); - assert!(!path_is_socket_owned("../fork")); - assert!(!path_is_socket_owned("./vendor/x")); - assert!(!path_is_socket_owned("/abs/.socketX/go-patches/x")); + fn test_detect_owner() { + use ReplaceOwner::*; + assert_eq!(detect_owner("./.socket/go-patches/github.com/x/y@v1.0.0"), Some(GoPatches)); + assert_eq!(detect_owner(".socket/go-patches/x@v1.0.0"), Some(GoPatches)); + assert_eq!(detect_owner("sub/.socket/go-patches/x@v1.0.0"), Some(GoPatches)); + assert_eq!( + detect_owner("./.socket/vendor/golang/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/github.com/x/y@v1.0.0"), + Some(Vendor) + ); + assert_eq!(detect_owner(".socket/vendor/golang/u/x@v1.0.0"), Some(Vendor)); + assert_eq!(detect_owner("../fork"), None); + assert_eq!(detect_owner("./vendor/x"), None); + assert_eq!(detect_owner("/abs/.socketX/go-patches/x"), None); + // The npm/composer vendor dirs are NOT golang-owned replace targets. + assert_eq!(detect_owner(".socket/vendor/npm/u/x.tgz"), None); } #[test] @@ -573,13 +612,13 @@ replace ( let entries = parse_replace_entries(gomod); assert_eq!(entries.len(), 3); let bar = entries.iter().find(|e| e.module == "github.com/foo/bar").unwrap(); - assert!(bar.socket_owned); + assert!(bar.socket_owned()); assert_eq!(bar.version.as_deref(), Some("v1.4.2")); let baz = entries.iter().find(|e| e.module == "example.com/baz").unwrap(); - assert!(!baz.socket_owned); + assert!(!baz.socket_owned()); assert_eq!(baz.path.as_deref(), Some("../local-baz")); let qux = entries.iter().find(|e| e.module == "example.com/qux").unwrap(); - assert!(!qux.socket_owned); + assert!(!qux.socket_owned()); assert_eq!(qux.path, None, "module-to-module replacement has no path"); let req = parse_required_versions(gomod); @@ -598,7 +637,7 @@ replace ( #[test] fn test_upsert_appends_single_line() { let gomod = "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n"; - let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2") + let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR) .unwrap() .unwrap(); assert!(out.contains( @@ -608,7 +647,7 @@ replace ( assert!(out.contains("require github.com/foo/bar v1.4.2")); assert!(out.ends_with('\n')); // Idempotent. - assert!(upsert_replace_entry(&out, "github.com/foo/bar", "v1.4.2") + assert!(upsert_replace_entry(&out, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR) .unwrap() .is_none()); } @@ -616,7 +655,7 @@ replace ( #[test] fn test_upsert_refreshes_socket_owned_version_bump_single_line() { let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n"; - let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.5.0") + let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.5.0", GO_PATCHES_DIR) .unwrap() .unwrap(); assert!(out.contains( @@ -630,7 +669,7 @@ replace ( #[test] fn test_upsert_refreshes_socket_owned_inside_block() { let gomod = "module m\n\nreplace (\n\tgithub.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n)\n"; - let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.5.0") + let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.5.0", GO_PATCHES_DIR) .unwrap() .unwrap(); // Still a block member (indented, no `replace ` keyword), version bumped. @@ -641,14 +680,14 @@ replace ( #[test] fn test_upsert_refuses_user_authored_same_version() { let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ../fork\n"; - assert!(upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2").is_err()); + assert!(upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR).is_err()); } #[test] fn test_upsert_allows_user_replace_at_different_version() { // User pins a DIFFERENT version → no conflict; ours is added alongside. let gomod = "module m\n\nreplace github.com/foo/bar v1.0.0 => ../fork\n"; - let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2") + let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR) .unwrap() .unwrap(); assert!(out.contains("replace github.com/foo/bar v1.0.0 => ../fork")); @@ -658,7 +697,7 @@ replace ( #[test] fn test_upsert_refuses_versionless_user_catchall() { let gomod = "module m\n\nreplace github.com/foo/bar => ../fork\n"; - assert!(upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2").is_err()); + assert!(upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR).is_err()); } #[test] @@ -666,7 +705,7 @@ replace ( // A user's version-less catch-all for a DIFFERENT module must not block // (or be touched by) our replace for github.com/foo/bar. let gomod = "module m\n\nreplace example.com/other => ../other-fork\n"; - let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2") + let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR) .unwrap() .unwrap(); assert!(out.contains("replace example.com/other => ../other-fork"), "user catch-all preserved"); @@ -674,15 +713,74 @@ replace ( "replace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2" )); let entries = parse_replace_entries(&out); - assert!(entries.iter().any(|e| e.module == "example.com/other" && !e.socket_owned)); - assert!(entries.iter().any(|e| e.module == "github.com/foo/bar" && e.socket_owned)); + assert!(entries.iter().any(|e| e.module == "example.com/other" && !e.socket_owned())); + assert!(entries.iter().any(|e| e.module == "github.com/foo/bar" && e.socket_owned())); + } + + // ── cross-owner takeover + owner filtering ─────────────────────── + const VENDOR_BASE: &str = + ".socket/vendor/golang/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + /// Vendor takes over an apply (go-patches) redirect: the SAME socket-owned + /// line is rewritten in place to the vendor path — never a remove+add pair, + /// and never a second directive for the module. + #[test] + fn test_upsert_cross_owner_takeover() { + let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n"; + let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", VENDOR_BASE) + .unwrap() + .unwrap(); + assert!(!out.contains("go-patches"), "old owner's path gone"); + assert!(out.contains(&format!( + "replace github.com/foo/bar v1.4.2 => ./{VENDOR_BASE}/github.com/foo/bar@v1.4.2" + ))); + let entries = parse_replace_entries(&out); + assert_eq!( + entries.iter().filter(|e| e.module == "github.com/foo/bar").count(), + 1, + "exactly one directive for the module" + ); + assert_eq!(entries[0].owner, Some(ReplaceOwner::Vendor)); + } + + /// Dropping one owner's directive leaves the other owner's (and the + /// user's) directives untouched — reconcile non-interference. + #[test] + fn test_remove_is_owner_filtered() { + let gomod = format!( + "module m\n\n\ + replace github.com/a/a v1.0.0 => ./.socket/go-patches/github.com/a/a@v1.0.0\n\ + replace github.com/b/b v2.0.0 => ./{VENDOR_BASE}/github.com/b/b@v2.0.0\n\ + replace github.com/c/c v3.0.0 => ../fork\n" + ); + // GoPatches drop must not touch the vendor directive… + assert!( + remove_replace_entry(&gomod, "github.com/b/b", ReplaceOwner::GoPatches) + .unwrap() + .is_none(), + "go-patches drop of a vendor-owned module is a no-op" + ); + // …and the vendor drop must not touch the go-patches directive. + assert!( + remove_replace_entry(&gomod, "github.com/a/a", ReplaceOwner::Vendor) + .unwrap() + .is_none(), + "vendor drop of a go-patches-owned module is a no-op" + ); + // Matching owner removes exactly its own line. + let out = remove_replace_entry(&gomod, "github.com/b/b", ReplaceOwner::Vendor) + .unwrap() + .unwrap(); + assert!(!out.contains("github.com/b/b")); + assert!(out.contains("go-patches/github.com/a/a@v1.0.0")); + assert!(out.contains("replace github.com/c/c v3.0.0 => ../fork")); } // ── remove ─────────────────────────────────────────────────────── #[test] fn test_remove_single_line() { let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n"; - let out = remove_replace_entry(gomod, "github.com/foo/bar") + let out = remove_replace_entry(gomod, "github.com/foo/bar", ReplaceOwner::GoPatches) .unwrap() .unwrap(); assert!(!out.contains("go-patches")); @@ -692,7 +790,7 @@ replace ( #[test] fn test_remove_block_member_prunes_empty_block() { let gomod = "module m\n\nreplace (\n\tgithub.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n)\n"; - let out = remove_replace_entry(gomod, "github.com/foo/bar") + let out = remove_replace_entry(gomod, "github.com/foo/bar", ReplaceOwner::GoPatches) .unwrap() .unwrap(); assert!(!out.contains("go-patches")); @@ -702,7 +800,7 @@ replace ( #[test] fn test_remove_block_keeps_other_members() { let gomod = "module m\n\nreplace (\n\tgithub.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n\texample.com/baz v2.0.0 => ../local-baz\n)\n"; - let out = remove_replace_entry(gomod, "github.com/foo/bar") + let out = remove_replace_entry(gomod, "github.com/foo/bar", ReplaceOwner::GoPatches) .unwrap() .unwrap(); assert!(!out.contains("go-patches")); @@ -713,12 +811,12 @@ replace ( #[test] fn test_remove_leaves_user_replace() { let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ../fork\n"; - assert!(remove_replace_entry(gomod, "github.com/foo/bar").unwrap().is_none()); + assert!(remove_replace_entry(gomod, "github.com/foo/bar", ReplaceOwner::GoPatches).unwrap().is_none()); } #[test] fn test_remove_absent_is_noop() { - assert!(remove_replace_entry("module m\n\ngo 1.21\n", "github.com/foo/bar") + assert!(remove_replace_entry("module m\n\ngo 1.21\n", "github.com/foo/bar", ReplaceOwner::GoPatches) .unwrap() .is_none()); } @@ -734,12 +832,12 @@ replace ( .await .unwrap(); - assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", false) + assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR, false) .await .unwrap()); let entries = read_replace_entries(dir.path()).await; let bar = entries.iter().find(|e| e.module == "github.com/foo/bar").unwrap(); - assert!(bar.socket_owned); + assert!(bar.socket_owned()); assert_eq!( bar.path.as_deref(), Some("./.socket/go-patches/github.com/foo/bar@v1.4.2") @@ -749,11 +847,11 @@ replace ( assert_eq!(req.get("github.com/foo/bar").map(String::as_str), Some("v1.4.2")); // Idempotent on disk. - assert!(!ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", false) + assert!(!ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR, false) .await .unwrap()); // Drop. - assert!(drop_replace_entry(dir.path(), "github.com/foo/bar", false) + assert!(drop_replace_entry(dir.path(), "github.com/foo/bar", ReplaceOwner::GoPatches, false) .await .unwrap()); assert!(read_replace_entries(dir.path()).await.is_empty()); @@ -764,7 +862,7 @@ replace ( let dir = tempfile::tempdir().unwrap(); let body = "module m\n\ngo 1.21\n"; fs::write(dir.path().join("go.mod"), body).await.unwrap(); - let changed = ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", true) + let changed = ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR, true) .await .unwrap(); assert!(changed, "dry-run reports the change it would make"); @@ -790,7 +888,7 @@ replace ( .await .unwrap(); - assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", false) + assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR, false) .await .unwrap()); @@ -817,7 +915,7 @@ replace ( let original = "module example.com/app\n\ngo 1.21\n\n// keep me\nrequire github.com/foo/bar v1.4.2\n\nreplace example.com/other v2.0.0 => ../other-fork\n"; fs::write(dir.path().join("go.mod"), original).await.unwrap(); - assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", false) + assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR, false) .await .unwrap()); @@ -837,7 +935,7 @@ replace ( #[tokio::test] async fn test_ensure_missing_go_mod_errors() { let dir = tempfile::tempdir().unwrap(); - assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", false) + assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR, false) .await .is_err()); } diff --git a/crates/socket-patch-core/src/patch/go_redirect.rs b/crates/socket-patch-core/src/patch/go_redirect.rs index c77b8cf..8b50bf0 100644 --- a/crates/socket-patch-core/src/patch/go_redirect.rs +++ b/crates/socket-patch-core/src/patch/go_redirect.rs @@ -34,8 +34,10 @@ use crate::utils::purl::{build_golang_purl, parse_golang_purl}; use super::copy_tree::{fresh_copy, remove_tree}; use super::go_mod_edit::{ - self, expected_replace_path, read_replace_entries, read_required_versions, GO_PATCHES_DIR, + self, expected_replace_path, read_replace_entries, read_required_versions, replace_target_path, + ReplaceOwner, GO_PATCHES_DIR, }; +use super::path_safety; /// A discrepancy between the committed redirect artifacts and the manifest, /// reported by [`verify_go_redirect_state`]. @@ -117,51 +119,29 @@ impl std::fmt::Display for Drift { } } -/// The project-relative copy dir for a module. `module` carries the real -/// (decoded) module path with `/`-separators, so the on-disk layout mirrors the -/// module cache (`github.com/foo/bar@v1.4.2`). -fn copy_dir_for(project_root: &Path, module: &str, version: &str) -> PathBuf { +/// The project-relative copy dir for a module under `base_rel` (the copy base: +/// [`GO_PATCHES_DIR`] for apply, `/` for vendor). +/// `module` carries the real (decoded) module path with `/`-separators, so the +/// on-disk layout mirrors the module cache (`github.com/foo/bar@v1.4.2`). +fn copy_dir_for(project_root: &Path, base_rel: &str, module: &str, version: &str) -> PathBuf { project_root - .join(GO_PATCHES_DIR) + .join(base_rel) .join(format!("{module}@{version}")) } /// SECURITY: the `module`+`version` key the on-disk copy dir -/// (`.socket/go-patches/@/`) and the `replace` target path, so -/// a tampered manifest PURL must not be able to make them escape -/// `.socket/go-patches/`. A `..`/`.` segment, an absolute path, or a backslash/ -/// NUL would otherwise let `apply` copy + write the patched tree (or `rollback` -/// delete a tree) at an arbitrary filesystem location outside the project. +/// (`/@/`) and the `replace` target path, so a +/// tampered manifest PURL must not be able to make them escape the copy base. +/// A `..`/`.` segment, an absolute path, or a backslash/NUL would otherwise let +/// `apply` copy + write the patched tree (or `rollback` delete a tree) at an +/// arbitrary filesystem location outside the project. /// /// Unlike a cargo crate name, a Go module path legitimately contains `/` -/// separators (`github.com/foo/bar`), so we validate it **per segment** rather -/// than rejecting all separators. A real Go module path never contains a `..`/ -/// `.` segment or a backslash, so fail-closed rejection is safe. -fn is_safe_redirect_module(module: &str) -> bool { - if module.is_empty() || module.starts_with('/') || module.contains('\\') || module.contains('\0') - { - return false; - } - module - .split('/') - .all(|seg| !seg.is_empty() && seg != "." && seg != "..") -} - -/// A Go module version (e.g. `v1.4.2`, `v0.0.0-2006…-abcdef`) is a single path -/// segment — no separators, no `..`. Mirrors the cargo redirect guard. -fn is_safe_redirect_version(version: &str) -> bool { - !version.is_empty() - && version != "." - && version != ".." - && !version.contains('/') - && !version.contains('\\') - && !version.contains('\0') -} - -/// True iff both coordinates are safe to key an on-disk copy dir / `replace` -/// path. Reject fail-closed before any disk access. -fn are_safe_redirect_coords(module: &str, version: &str) -> bool { - is_safe_redirect_module(module) && is_safe_redirect_version(version) +/// separators (`github.com/foo/bar`), so it is validated **per segment** +/// (see [`path_safety::is_safe_multi_segment`]); a version is a single +/// segment. Reject fail-closed before any disk access. +pub(crate) fn are_safe_redirect_coords(module: &str, version: &str) -> bool { + path_safety::is_safe_multi_segment(module) && path_safety::is_safe_single_segment(version) } /// Materialise a project-local patched copy and wire up the `replace` redirect. @@ -170,6 +150,8 @@ fn are_safe_redirect_coords(module: &str, version: &str) -> bool { /// `pkg_path`, case-encoded on disk). It is copied, never mutated. /// * `module` / `version` — the **decoded** module path + version (from the /// PURL); they key both the copy dir and the `replace` directive. +/// * `base_rel` — the project-relative copy base ([`GO_PATCHES_DIR`] for +/// apply's redirect, `/` for the vendor backend). #[allow(clippy::too_many_arguments)] pub async fn apply_go_redirect( purl: &str, @@ -177,13 +159,14 @@ pub async fn apply_go_redirect( version: &str, pristine_src: &Path, project_root: &Path, + base_rel: &str, files: &HashMap, sources: &PatchSources<'_>, uuid: Option<&str>, dry_run: bool, force: bool, ) -> ApplyResult { - // SECURITY: refuse coordinates that would escape `.socket/go-patches/`. + // SECURITY: refuse coordinates that would escape the copy base. // A `..`/separator-laden `module`/`version` (a tampered manifest PURL) would // otherwise make `fresh_copy` + the apply pipeline write the patched tree to // an arbitrary location. Fail-closed before any disk access. @@ -195,12 +178,12 @@ pub async fn apply_go_redirect( false, Some(format!( "refusing go redirect for unsafe coordinates `{module}`/`{version}` \ - (a `..` segment, absolute path, or separator would escape .socket/go-patches/)" + (a `..` segment, absolute path, or separator would escape {base_rel}/)" )), ); } - let copy_dir = copy_dir_for(project_root, module, version); + let copy_dir = copy_dir_for(project_root, base_rel, module, version); // A redirect with no files to patch is meaningless: no-op success, no // go.mod edit. @@ -221,7 +204,7 @@ pub async fn apply_go_redirect( // Hot path: already in sync → touch nothing, so the build's source // fingerprint stays stable across repeated applies (the guard re-runs apply // on most "deps changed" builds). - if redirect_in_sync(©_dir, files, project_root, module, version).await { + if redirect_in_sync(©_dir, files, project_root, module, version, base_rel).await { let verified = files.keys().map(|f| already_patched_verify(f)).collect(); return synthesized_result(purl, ©_dir, verified, true, None); } @@ -267,7 +250,9 @@ pub async fn apply_go_redirect( // Wire up the `replace` directive. Load-bearing: without it the build won't // redirect to the copy, so a failure here fails the apply. - if let Err(e) = go_mod_edit::ensure_replace_entry(project_root, module, version, false).await { + if let Err(e) = + go_mod_edit::ensure_replace_entry(project_root, module, version, base_rel, false).await + { result.success = false; result.error = Some(format!("failed to update go.mod: {e}")); return result; @@ -276,10 +261,13 @@ pub async fn apply_go_redirect( result } -/// Drop the managed `replace` directive + patched copy for a golang PURL. +/// Drop `owner`'s managed `replace` directive + the patched copy under +/// `base_rel` for a golang PURL. pub async fn remove_go_redirect( purl: &str, project_root: &Path, + base_rel: &str, + owner: ReplaceOwner, dry_run: bool, ) -> Result<(), std::io::Error> { let (module, version) = parse_golang_purl(purl).ok_or_else(|| { @@ -289,8 +277,8 @@ pub async fn remove_go_redirect( ) })?; - // SECURITY: the copy dir is `.socket/go-patches/@/` and is - // about to be `remove_tree`d. Unsafe coordinates (`..` segment / separator / + // SECURITY: the copy dir is `/@/` and is about + // to be `remove_tree`d. Unsafe coordinates (`..` segment / separator / // absolute) would target a tree outside the project for deletion — refuse. if !are_safe_redirect_coords(module, version) { return Err(std::io::Error::new( @@ -299,19 +287,21 @@ pub async fn remove_go_redirect( )); } - go_mod_edit::drop_replace_entry(project_root, module, dry_run) + go_mod_edit::drop_replace_entry(project_root, module, owner, dry_run) .await .map_err(std::io::Error::other)?; if !dry_run { - let copy_dir = copy_dir_for(project_root, module, version); + let copy_dir = copy_dir_for(project_root, base_rel, module, version); let _ = remove_tree(©_dir).await; // ignore NotFound } Ok(()) } -/// Prune socket-owned `replace` directives + copy dirs no longer in `desired` -/// (patches dropped from the manifest). Returns the removed PURLs. +/// Prune **go-patches-owned** `replace` directives + copy dirs no longer in +/// `desired` (patches dropped from the manifest). Returns the removed PURLs. +/// Vendor-owned directives and `.socket/vendor/` copies are never touched — +/// they are reconciled by the vendor command against its own state. pub async fn reconcile_go_redirects( project_root: &Path, desired: &HashSet, @@ -324,10 +314,18 @@ pub async fn reconcile_go_redirects( let mut removed: Vec = Vec::new(); - // (a) Orphan socket-owned `replace` directives (module no longer patched). + // (a) Orphan go-patches-owned `replace` directives (module no longer patched). for entry in read_replace_entries(project_root).await { - if entry.socket_owned && !desired_modules.contains(entry.module.as_str()) { - let _ = go_mod_edit::drop_replace_entry(project_root, &entry.module, dry_run).await; + if entry.owner == Some(ReplaceOwner::GoPatches) + && !desired_modules.contains(entry.module.as_str()) + { + let _ = go_mod_edit::drop_replace_entry( + project_root, + &entry.module, + ReplaceOwner::GoPatches, + dry_run, + ) + .await; if let Some(v) = &entry.version { let purl = build_golang_purl(&entry.module, v); if !removed.contains(&purl) { @@ -395,6 +393,17 @@ pub async fn verify_go_redirect_state( continue; } + // A vendor-owned `replace` outranks the go-patches redirect: the module + // is managed by `socket-patch vendor`, so this audit must not demand a + // go-patches copy/directive for it (that would report MissingCopy/ + // WrongReplacePath drift for every vendored module). + if entries + .iter() + .any(|e| e.module == module && e.owner == Some(ReplaceOwner::Vendor)) + { + continue; + } + // go.mod `require` cross-check: if the graph resolves this module to a // version that is NOT the patched one, the version-pinned `replace` is // unused and the build links the unpatched module — a silent-stale hole @@ -411,7 +420,7 @@ pub async fn verify_go_redirect_state( } } - let copy_dir = copy_dir_for(project_root, module, version); + let copy_dir = copy_dir_for(project_root, GO_PATCHES_DIR, module, version); if tokio::fs::metadata(©_dir).await.is_err() { drifts.push(Drift::MissingCopy { purl: purl.clone() }); } else { @@ -442,7 +451,7 @@ pub async fn verify_go_redirect_state( let expected = expected_replace_path(module, version); let socket = entries .iter() - .find(|e| e.module == module && e.socket_owned); + .find(|e| e.module == module && e.owner == Some(ReplaceOwner::GoPatches)); match socket { Some(e) if e.path.as_deref() == Some(expected.as_str()) && e.version.as_deref() == Some(version) => {} @@ -456,7 +465,9 @@ pub async fn verify_go_redirect_state( } for entry in &entries { - if entry.socket_owned && !desired_modules.contains(entry.module.as_str()) { + if entry.owner == Some(ReplaceOwner::GoPatches) + && !desired_modules.contains(entry.module.as_str()) + { drifts.push(Drift::OrphanReplace { module: entry.module.clone(), }); @@ -480,6 +491,7 @@ async fn redirect_in_sync( project_root: &Path, module: &str, version: &str, + base_rel: &str, ) -> bool { if tokio::fs::metadata(copy_dir).await.is_err() { return false; @@ -491,10 +503,10 @@ async fn redirect_in_sync( _ => return false, } } - let expected = expected_replace_path(module, version); + let expected = replace_target_path(base_rel, module, version); read_replace_entries(project_root).await.iter().any(|e| { e.module == module - && e.socket_owned + && e.socket_owned() && e.path.as_deref() == Some(expected.as_str()) && e.version.as_deref() == Some(version) }) @@ -732,7 +744,8 @@ mod tests { let sources = PatchSources::blobs_only(&blobs); let result = apply_go_redirect( - PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false, + PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, + false, false, ) .await; assert!(result.success, "apply failed: {:?}", result.error); @@ -751,7 +764,7 @@ mod tests { // go.mod replace points at the copy. let entries = read_replace_entries(root).await; let e = entries.iter().find(|e| e.module == MODULE).unwrap(); - assert!(e.socket_owned); + assert!(e.socket_owned()); assert_eq!( e.path.as_deref(), Some("./.socket/go-patches/github.com/foo/bar@v1.4.2") @@ -764,14 +777,14 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; let copy = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2/bar.go"); let gomod = root.join("go.mod"); let body1 = tokio::fs::read(©).await.unwrap(); let mod1 = tokio::fs::read_to_string(&gomod).await.unwrap(); - let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; assert!(result.success); assert!(result.files_patched.is_empty(), "in-sync resync patches nothing"); assert_eq!(tokio::fs::read(©).await.unwrap(), body1, "copy unchanged"); @@ -783,12 +796,12 @@ mod tests { let (dir, blobs, pristine, files, after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; let copy = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2/bar.go"); tokio::fs::write(©, b"corrupted").await.unwrap(); - let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; assert!(result.success); assert_eq!(git_sha(&tokio::fs::read(©).await.unwrap()), after); } @@ -799,7 +812,7 @@ mod tests { let root = dir.path(); let pristine_gomod = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); let sources = PatchSources::blobs_only(&blobs); - let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, true, false).await; + let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, true, false).await; assert!(result.success); assert!(!root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists()); // go.mod unchanged (no replace added). @@ -814,7 +827,7 @@ mod tests { tokio::fs::create_dir_all(&empty).await.unwrap(); let sources = PatchSources::blobs_only(&empty); - let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; assert!(!result.success); assert!( !root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists(), @@ -832,7 +845,7 @@ mod tests { tokio::fs::remove_file(pristine.join("go.mod")).await.unwrap(); let sources = PatchSources::blobs_only(&blobs); - let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; assert!(result.success, "apply failed: {:?}", result.error); let synthesized = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2/go.mod"); assert_eq!( @@ -854,7 +867,8 @@ mod tests { let sources = PatchSources::blobs_only(&blobs); let result = apply_go_redirect( - PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false, + PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, + false, false, ) .await; assert!(result.success, "apply failed: {:?}", result.error); @@ -881,9 +895,11 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; - remove_go_redirect(PURL, root, false).await.unwrap(); + remove_go_redirect(PURL, root, GO_PATCHES_DIR, ReplaceOwner::GoPatches, false) + .await + .unwrap(); assert!(!root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists()); assert!(read_replace_entries(root).await.is_empty()); // The require directive (not socket-owned) survives. @@ -898,7 +914,7 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; let desired: HashSet = HashSet::new(); let removed = reconcile_go_redirects(root, &desired, false).await; @@ -912,7 +928,7 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; // Add a user-authored replace. let mut body = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); body.push_str("replace example.com/other v1.0.0 => ../other-fork\n"); @@ -922,8 +938,8 @@ mod tests { let removed = reconcile_go_redirects(root, &desired, false).await; assert!(removed.is_empty()); let entries = read_replace_entries(root).await; - assert!(entries.iter().any(|e| e.module == MODULE && e.socket_owned)); - assert!(entries.iter().any(|e| e.module == "example.com/other" && !e.socket_owned)); + assert!(entries.iter().any(|e| e.module == MODULE && e.socket_owned())); + assert!(entries.iter().any(|e| e.module == "example.com/other" && !e.socket_owned())); } #[tokio::test] @@ -931,7 +947,7 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; let manifest = manifest_with(&files); let desired: HashSet = [PURL.to_string()].into_iter().collect(); @@ -958,9 +974,11 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; // Drop the directive but keep the copy. - go_mod_edit::drop_replace_entry(root, MODULE, false).await.unwrap(); + go_mod_edit::drop_replace_entry(root, MODULE, ReplaceOwner::GoPatches, false) + .await + .unwrap(); let manifest = manifest_with(&files); let desired: HashSet = [PURL.to_string()].into_iter().collect(); @@ -973,7 +991,7 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; let manifest = manifest_with(&files); let desired: HashSet = [PURL.to_string()].into_iter().collect(); @@ -982,7 +1000,9 @@ mod tests { // Repin the socket-owned replace at a DIFFERENT version while the copy // stays byte-correct. Go keys replace by module+version, so this // silently links the unpatched module — verify must flag it. - go_mod_edit::ensure_replace_entry(root, MODULE, "v9.9.9", false).await.unwrap(); + go_mod_edit::ensure_replace_entry(root, MODULE, "v9.9.9", GO_PATCHES_DIR, false) + .await + .unwrap(); // ensure_replace refreshed our entry to v9.9.9; the v1.4.2 copy is now orphaned by directive. let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); assert!( @@ -996,7 +1016,7 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; let manifest = manifest_with(&files); let desired: HashSet = [PURL.to_string()].into_iter().collect(); @@ -1018,7 +1038,7 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; // Empty desired + empty manifest → the live directive is an orphan. let manifest = PatchManifest::new(); @@ -1036,7 +1056,7 @@ mod tests { tokio::fs::create_dir_all(&blobs).await.unwrap(); let sources = PatchSources::blobs_only(&blobs); let files = HashMap::new(); - let result = apply_go_redirect(PURL, MODULE, VERSION, root, root, &files, &sources, None, false, false).await; + let result = apply_go_redirect(PURL, MODULE, VERSION, root, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; assert!(result.success); assert!(read_replace_entries(root).await.is_empty()); } @@ -1086,6 +1106,7 @@ mod tests { "v1.0.0", &pristine, root, + GO_PATCHES_DIR, &files, &sources, None, @@ -1124,6 +1145,7 @@ mod tests { "../../../evil", &pristine, root, + GO_PATCHES_DIR, &files, &sources, None, @@ -1151,7 +1173,13 @@ mod tests { tokio::fs::create_dir_all(&precious).await.unwrap(); tokio::fs::write(precious.join("keep.txt"), b"keep").await.unwrap(); - let err = remove_go_redirect("pkg:golang/../../../precious@v1.0.0", root, false) + let err = remove_go_redirect( + "pkg:golang/../../../precious@v1.0.0", + root, + GO_PATCHES_DIR, + ReplaceOwner::GoPatches, + false, + ) .await .unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); diff --git a/crates/socket-patch-core/src/patch/mod.rs b/crates/socket-patch-core/src/patch/mod.rs index 5041302..176adaa 100644 --- a/crates/socket-patch-core/src/patch/mod.rs +++ b/crates/socket-patch-core/src/patch/mod.rs @@ -1,6 +1,7 @@ pub mod apply; pub mod apply_lock; -#[cfg(feature = "golang")] +// Ungated: the vendor backends (npm/pypi/gem are unconditional) stage their +// patched copies with `fresh_copy`/`remove_tree`, not just the golang redirect. pub mod copy_tree; pub mod cow; pub mod diff; @@ -10,5 +11,6 @@ pub mod go_mod_edit; #[cfg(feature = "golang")] pub mod go_redirect; pub mod package; +pub(crate) mod path_safety; pub mod rollback; pub mod sidecars; diff --git a/crates/socket-patch-core/src/patch/path_safety.rs b/crates/socket-patch-core/src/patch/path_safety.rs new file mode 100644 index 0000000..a9e5ba1 --- /dev/null +++ b/crates/socket-patch-core/src/patch/path_safety.rs @@ -0,0 +1,116 @@ +//! Coordinate-safety guards for paths derived from untrusted manifest data. +//! +//! Package names, versions, Go module paths, and patch UUIDs from +//! `.socket/manifest.json` / `.socket/vendor/state.json` key on-disk copy +//! directories (`.socket/go-patches/…`, `.socket/vendor/…`) and the +//! lockfile/config entries that point at them. Those files are committed and +//! tamper-able, so every coordinate must be validated **fail-closed before any +//! disk access**: a `..`/`.` segment, an absolute path, a backslash, or a NUL +//! would otherwise let a poisoned manifest copy, write, or delete a tree at an +//! arbitrary filesystem location outside the project. + +/// A single path segment (cargo crate name, version string, gem name, …): +/// no separators, not `.`/`..`, no backslash/NUL, non-empty. +pub(crate) fn is_safe_single_segment(s: &str) -> bool { + !s.is_empty() + && s != "." + && s != ".." + && !s.contains('/') + && !s.contains('\\') + && !s.contains('\0') +} + +/// A multi-segment relative path (Go module path `github.com/foo/bar`, npm +/// scoped name `@scope/name`, composer `vendor/name`): `/`-separated segments, +/// each non-empty and not `.`/`..`; no leading `/`, no backslash, no NUL. +pub(crate) fn is_safe_multi_segment(s: &str) -> bool { + if s.is_empty() || s.starts_with('/') || s.contains('\\') || s.contains('\0') { + return false; + } + s.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..") +} + +/// The canonical lowercase hyphenated UUID grammar +/// (`9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f`). Patch UUIDs key a dedicated +/// `.socket/vendor///` path level, so anything that is not exactly +/// this shape (36 chars, hex + hyphens in the fixed positions) is rejected — +/// uppercase included, since the dir name must match the lockfile string +/// byte-for-byte on case-sensitive filesystems. +pub(crate) fn is_canonical_uuid(s: &str) -> bool { + let b = s.as_bytes(); + if b.len() != 36 { + return false; + } + for (i, &c) in b.iter().enumerate() { + match i { + 8 | 13 | 18 | 23 => { + if c != b'-' { + return false; + } + } + _ => { + if !c.is_ascii_hexdigit() || c.is_ascii_uppercase() { + return false; + } + } + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_segment_accepts_names_and_versions() { + assert!(is_safe_single_segment("serde")); + assert!(is_safe_single_segment("left-pad")); + assert!(is_safe_single_segment("1.0.200")); + assert!(is_safe_single_segment("v2.0.0-20210101000000-abcdef123456")); + } + + #[test] + fn single_segment_rejects_traversal_and_separators() { + assert!(!is_safe_single_segment("")); + assert!(!is_safe_single_segment(".")); + assert!(!is_safe_single_segment("..")); + assert!(!is_safe_single_segment("a/b")); + assert!(!is_safe_single_segment("a\\b")); + assert!(!is_safe_single_segment("a\0b")); + } + + #[test] + fn multi_segment_accepts_module_and_scoped_names() { + assert!(is_safe_multi_segment("github.com/foo/bar")); + assert!(is_safe_multi_segment("github.com/foo/bar/v2")); + assert!(is_safe_multi_segment("gopkg.in/inf.v0")); + assert!(is_safe_multi_segment("@scope/name")); + assert!(is_safe_multi_segment("monolog/monolog")); + } + + #[test] + fn multi_segment_rejects_traversal() { + assert!(!is_safe_multi_segment("")); + assert!(!is_safe_multi_segment("/abs/path")); + assert!(!is_safe_multi_segment("../../../etc")); + assert!(!is_safe_multi_segment("github.com/../../../etc")); + assert!(!is_safe_multi_segment("github.com//bar")); + assert!(!is_safe_multi_segment("foo/./bar")); + assert!(!is_safe_multi_segment("foo\\bar")); + assert!(!is_safe_multi_segment("foo\0bar")); + } + + #[test] + fn uuid_grammar_is_exact() { + assert!(is_canonical_uuid("9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f")); + // Wrong length / shape / case / traversal payloads. + assert!(!is_canonical_uuid("")); + assert!(!is_canonical_uuid("9f6b2c4e1d3a4f6b8c2d7e5a9b1c3d5f")); // no hyphens + assert!(!is_canonical_uuid("9F6B2C4E-1D3A-4F6B-8C2D-7E5A9B1C3D5F")); // uppercase + assert!(!is_canonical_uuid("9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5")); // 35 chars + assert!(!is_canonical_uuid("9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5ff")); // 37 chars + assert!(!is_canonical_uuid("../../../etc/passwd/aaaaaaaaaaaaaaaa")); + assert!(!is_canonical_uuid("9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d/f")); + } +} diff --git a/crates/socket-patch-core/src/utils/fs.rs b/crates/socket-patch-core/src/utils/fs.rs index 08da9bf..a33a3c6 100644 --- a/crates/socket-patch-core/src/utils/fs.rs +++ b/crates/socket-patch-core/src/utils/fs.rs @@ -1,4 +1,5 @@ -//! Filesystem helpers shared by the ecosystem crawlers. +//! Filesystem helpers shared by the ecosystem crawlers, plus the +//! crate-wide atomic file writer ([`atomic_write_bytes`]). //! //! Each crawler walks one or more package directories and decides //! whether each entry is a candidate package. The two operations that @@ -81,6 +82,58 @@ pub async fn entry_file_type(entry: &DirEntry) -> Option { entry.file_type().await.ok() } +/// Atomically commit `content` to `path` via stage + fsync + rename. +/// +/// The single shared implementation of the hardened-writer pattern used for +/// every user-owned file socket-patch edits (`go.mod`, `package.json`, +/// `pyproject.toml`, lockfiles, `.socket/vendor/state.json`, …). A bare +/// `fs::write` truncates the target before writing, so a crash, power loss, or +/// `ENOSPC` mid-write would leave the file torn or empty. Instead we stage a +/// sibling file, fsync it, then rename over the target (atomic on the same +/// filesystem), so a reader or recovering process only ever sees the complete +/// old or the complete new bytes. +pub async fn atomic_write_bytes(path: &Path, content: &[u8]) -> std::io::Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let stem = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "file".to_string()); + let stage = parent.join(format!(".socket-stage-{}-{}", stem, uuid::Uuid::new_v4())); + + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&stage) + .await?; + + use tokio::io::AsyncWriteExt; + if let Err(e) = file.write_all(content).await { + let _ = tokio::fs::remove_file(&stage).await; + return Err(e); + } + if let Err(e) = file.sync_all().await { + let _ = tokio::fs::remove_file(&stage).await; + return Err(e); + } + drop(file); + + if let Err(e) = tokio::fs::rename(&stage, path).await { + let _ = tokio::fs::remove_file(&stage).await; + return Err(e); + } + + // The rename only updated the parent directory entry; fsync the directory + // so the rename itself survives a crash. Best-effort, Unix only. + #[cfg(unix)] + { + if let Ok(dir) = tokio::fs::File::open(parent).await { + let _ = dir.sync_all().await; + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/spikes/uv/README.md b/spikes/uv/README.md new file mode 100644 index 0000000..21b521d --- /dev/null +++ b/spikes/uv/README.md @@ -0,0 +1,54 @@ +# uv vendored-wheel spike fixtures + +Generated 2026-06-09 with **uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin)**, CPython 3.14.3 (macOS arm64). +De-risking spike for `socket-patch vendor`: vendoring a patched wheel at +`.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl` +and rewriting `pyproject.toml` + `uv.lock` so the project consumes it. + +Each directory is a `pyproject.toml` + `uv.lock` pair, all uv-generated (never hand-written), +except where noted. All locks: `version = 1`, `revision = 3`. + +## Fixture pairs + +| dir | shows | +| --- | --- | +| `direct-registry/` | BEFORE: direct dep `six==1.16.0` from PyPI. requires-dist keeps `specifier = "==1.16.0"`; six `source = { registry = ... }` with `sdist` + url/size/upload-time wheels. | +| `direct-path-wheel/` | AFTER: `[tool.uv.sources] six = { path = ... }`. six becomes `source = { path = "" }`; wheels element is `{ filename, hash }` ONLY (no url/size/upload-time/path); `sdist` line dropped; `version` retained; requires-dist becomes `{ name = "six", path = "" }` — **specifier is dropped, not kept alongside**. | +| `transitive-registry/` | BEFORE: direct dep `python-dateutil==2.8.2`; six is transitive and resolves to 1.17.0 from registry. | +| `transitive-promoted/` | AFTER: six promoted into `[project] dependencies` (`"six==1.16.0"`) + sources path entry. Root `dependencies` gains `{ name = "six" }`; requires-dist gains `{ name = "six", path = ... }` (dateutil entry keeps its specifier); six entry switches to path source, pinned down 1.17.0 → 1.16.0. | +| `override-transitive/` | ALTERNATIVE (no promotion): `[tool.uv] override-dependencies = ["six==1.16.0"]` + sources entry, six NOT in project.dependencies. Lock gains `[manifest]` with `overrides = [{ name = "six", path = ... }]` (path replaces specifier there too); six entry is the same path shape; requires-dist untouched. Installs from the vendored wheel; byte-stable under plain `uv sync`. | + +## Key behaviors observed (claim numbers from the spike) + +1. Path-wheel lock shape: see `direct-path-wheel/uv.lock` lines for six. +2. Surgical text edit of the registry lock reproduced the uv-generated path lock + **byte-identically**; `uv lock --check` exit 0, `uv sync --locked` installs from the + vendored wheel, plain `uv sync` leaves the lock byte-identical (sha256 stable). +3. Fresh checkout (only pyproject/uv.lock/.socket), fresh `UV_CACHE_DIR`, + `uv sync --frozen --offline` → installs the patched wheel; marker visible in + site-packages `six.py`. (Wheel was repacked with a marker + RECORD fixed; lock hash + refreshed via `uv lock --upgrade-package six` — see surprise below.) +4. Tamper: valid-zip content change → `uv sync --frozen` fails + "Hash mismatch ... Expected: sha256: Computed: sha256:", exit 1. + A raw byte-flip fails earlier with "deflate decompression error: invalid distances set". +5. Promotion (transitive → direct + source) works; `uv sync --locked` ok; plain sync byte-stable. +6. Lock-only edit (path source written ONLY into six's `[[package]]`, pyproject untouched): + `uv lock --check`, `uv sync --locked`, `--frozen`, plain `uv sync`, even plain `uv lock` + ALL pass and preserve it — but `uv lock --upgrade`/`--upgrade-package six` silently + reverts to registry six 1.17.0. +7. `[tool.uv.sources]` entry for a package not in any direct declaration: **silently + ignored** (exit 0, no warning), whether the package is transitive or absent entirely. +8. Sources DO apply to `override-dependencies` (see `override-transitive/`). +9. Silent-revert risk is real: registry pyproject + path lock → plain `uv sync` re-resolves + and rewrites the lock back to registry source, exit 0, no warning, registry wheel + installed. `uv lock --check` on that combo DOES fail ("The lockfile at `uv.lock` needs + to be updated, but `--check` was provided."). +10. Single-project locks have NO `[manifest]` section (no `members` key); `[manifest]` + appears only to carry resolver inputs (e.g. `overrides`). Virtual root: + `source = { virtual = "." }`; packaged (build-system) root: `source = { editable = "." }`. + +## Surprise / implementation hazard + +Replacing the vendored wheel's bytes at an unchanged path does NOT refresh the lock hash: +plain `uv lock` keeps the stale hash (lock validation never re-hashes files). Use +`uv lock --upgrade-package `, delete+regenerate, or write the new sha256 surgically. diff --git a/spikes/uv/direct-path-wheel/README.md b/spikes/uv/direct-path-wheel/README.md new file mode 100644 index 0000000..1b338b8 --- /dev/null +++ b/spikes/uv/direct-path-wheel/README.md @@ -0,0 +1,3 @@ +uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin). AFTER pair: [tool.uv.sources] six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }. +six: source = { path = "" }; wheels = [{ filename, hash }] only (no url/size/upload-time); no sdist; version retained. +requires-dist = [{ name = "six", path = "" }] — specifier DROPPED. Lock uses the pristine wheel hash sha256:8abb2f1d... . diff --git a/spikes/uv/direct-path-wheel/pyproject.toml b/spikes/uv/direct-path-wheel/pyproject.toml new file mode 100644 index 0000000..b6e2e59 --- /dev/null +++ b/spikes/uv/direct-path-wheel/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["six==1.16.0"] + +[tool.uv.sources] +six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } diff --git a/spikes/uv/direct-path-wheel/uv.lock b/spikes/uv/direct-path-wheel/uv.lock new file mode 100644 index 0000000..0aac493 --- /dev/null +++ b/spikes/uv/direct-path-wheel/uv.lock @@ -0,0 +1,22 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "six" }, +] + +[package.metadata] +requires-dist = [{ name = "six", path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }] + +[[package]] +name = "six" +version = "1.16.0" +source = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } +wheels = [ + { filename = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" }, +] diff --git a/spikes/uv/direct-registry/README.md b/spikes/uv/direct-registry/README.md new file mode 100644 index 0000000..0a699ee --- /dev/null +++ b/spikes/uv/direct-registry/README.md @@ -0,0 +1,2 @@ +uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin). BEFORE pair: virtual project, direct dep six==1.16.0 from PyPI registry. +requires-dist = [{ name = "six", specifier = "==1.16.0" }]; six source = { registry = "https://pypi.org/simple" } with sdist + url/size/upload-time wheel entries. diff --git a/spikes/uv/direct-registry/pyproject.toml b/spikes/uv/direct-registry/pyproject.toml new file mode 100644 index 0000000..74444fb --- /dev/null +++ b/spikes/uv/direct-registry/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["six==1.16.0"] diff --git a/spikes/uv/direct-registry/uv.lock b/spikes/uv/direct-registry/uv.lock new file mode 100644 index 0000000..745dac3 --- /dev/null +++ b/spikes/uv/direct-registry/uv.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "six" }, +] + +[package.metadata] +requires-dist = [{ name = "six", specifier = "==1.16.0" }] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, +] diff --git a/spikes/uv/override-transitive/README.md b/spikes/uv/override-transitive/README.md new file mode 100644 index 0000000..ac20e15 --- /dev/null +++ b/spikes/uv/override-transitive/README.md @@ -0,0 +1,3 @@ +uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin). ALTERNATIVE pair (claim 8): [tool.uv] override-dependencies = ["six==1.16.0"] + sources path entry; six NOT in project.dependencies. +Lock gains [manifest] overrides = [{ name = "six", path = "" }]; six [[package]] uses path source; requires-dist untouched. +Installs from vendored wheel; plain sync byte-stable. diff --git a/spikes/uv/override-transitive/pyproject.toml b/spikes/uv/override-transitive/pyproject.toml new file mode 100644 index 0000000..370c5da --- /dev/null +++ b/spikes/uv/override-transitive/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["python-dateutil==2.8.2"] + +[tool.uv] +override-dependencies = ["six==1.16.0"] + +[tool.uv.sources] +six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } diff --git a/spikes/uv/override-transitive/uv.lock b/spikes/uv/override-transitive/uv.lock new file mode 100644 index 0000000..ac73dbf --- /dev/null +++ b/spikes/uv/override-transitive/uv.lock @@ -0,0 +1,37 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[manifest] +overrides = [{ name = "six", path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }] + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "python-dateutil" }, +] + +[package.metadata] +requires-dist = [{ name = "python-dateutil", specifier = "==2.8.2" }] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324, upload-time = "2021-07-14T08:19:19.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702, upload-time = "2021-07-14T08:19:18.161Z" }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } +wheels = [ + { filename = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" }, +] diff --git a/spikes/uv/transitive-promoted/README.md b/spikes/uv/transitive-promoted/README.md new file mode 100644 index 0000000..c89e69c --- /dev/null +++ b/spikes/uv/transitive-promoted/README.md @@ -0,0 +1,3 @@ +uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin). AFTER pair: six promoted to [project] dependencies ("six==1.16.0") + [tool.uv.sources] path entry. +Root dependencies = [{ name = "python-dateutil" }, { name = "six" }]; requires-dist = [{ name = "python-dateutil", specifier = "==2.8.2" }, { name = "six", path = "" }]. +six pinned 1.17.0 -> 1.16.0, path source. uv sync --locked installs from vendored wheel; plain sync byte-stable. diff --git a/spikes/uv/transitive-promoted/pyproject.toml b/spikes/uv/transitive-promoted/pyproject.toml new file mode 100644 index 0000000..e4b574e --- /dev/null +++ b/spikes/uv/transitive-promoted/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["python-dateutil==2.8.2", "six==1.16.0"] + +[tool.uv.sources] +six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } diff --git a/spikes/uv/transitive-promoted/uv.lock b/spikes/uv/transitive-promoted/uv.lock new file mode 100644 index 0000000..294bfa5 --- /dev/null +++ b/spikes/uv/transitive-promoted/uv.lock @@ -0,0 +1,38 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "python-dateutil" }, + { name = "six" }, +] + +[package.metadata] +requires-dist = [ + { name = "python-dateutil", specifier = "==2.8.2" }, + { name = "six", path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }, +] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324, upload-time = "2021-07-14T08:19:19.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702, upload-time = "2021-07-14T08:19:18.161Z" }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } +wheels = [ + { filename = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" }, +] diff --git a/spikes/uv/transitive-registry/README.md b/spikes/uv/transitive-registry/README.md new file mode 100644 index 0000000..fca93be --- /dev/null +++ b/spikes/uv/transitive-registry/README.md @@ -0,0 +1 @@ +uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin). BEFORE pair: direct dep python-dateutil==2.8.2; six transitive, resolved to 1.17.0 from registry. diff --git a/spikes/uv/transitive-registry/pyproject.toml b/spikes/uv/transitive-registry/pyproject.toml new file mode 100644 index 0000000..0e38349 --- /dev/null +++ b/spikes/uv/transitive-registry/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["python-dateutil==2.8.2"] diff --git a/spikes/uv/transitive-registry/uv.lock b/spikes/uv/transitive-registry/uv.lock new file mode 100644 index 0000000..ce6c3c3 --- /dev/null +++ b/spikes/uv/transitive-registry/uv.lock @@ -0,0 +1,35 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "python-dateutil" }, +] + +[package.metadata] +requires-dist = [{ name = "python-dateutil", specifier = "==2.8.2" }] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324, upload-time = "2021-07-14T08:19:19.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702, upload-time = "2021-07-14T08:19:18.161Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] From 17fd546ac5b1a0f366d61f76af8d330491ae65bc Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 19:51:59 -0400 Subject: [PATCH 05/31] docs(vendor): record phase-0 spike findings + uv lock-shape fixtures All 7 package managers verified GO with real tools. Load-bearing facts: - npm: lock-only file: tarball rewrite passes npm ci; integrity MUST be recomputed (stale integrity + warm cache silently installs unpatched) - cargo: source+checksum lock surgery is byte-stable; --locked --offline fresh-checkout proof passed; path deps build without --cap-lints - golang: uuid path level fine; ./ prefix mandatory - uv: surgical pyproject+uv.lock edits byte-match uv's own output; tool.uv.sources applies to override-dependencies (transitive channel) - pip/uv pip: bare paths resolve against CWD (root-only constraint); markers on path lines evaluate correctly (carry over, don't refuse) - composer: lock-only dist=path surgery confirmed incl. --network none - bundler: Gemfile+lock pair edit regenerates byte-identically Co-Authored-By: Claude Fable 5 --- spikes/PHASE0-FINDINGS.txt | 160 +++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 spikes/PHASE0-FINDINGS.txt diff --git a/spikes/PHASE0-FINDINGS.txt b/spikes/PHASE0-FINDINGS.txt new file mode 100644 index 0000000..8e1a7e9 --- /dev/null +++ b/spikes/PHASE0-FINDINGS.txt @@ -0,0 +1,160 @@ +=== cargo (cargo 1.93.1 (083ac5135 2025-12-15)) === + [CONFIRMED] 1. config [patch] + untouched Cargo.lock: cargo build --locked fails + Exit 101. Verbatim: 'error: cannot update the lock file /private/tmp/socket-vendor-spike.luzrbh/app/Cargo.lock because --locked was passed to prevent this' + 'help: to generate the lock file without accessing the network, remove the --locked flag and use --offline instead.' Note the error is generic (never mentions the patch), so user-facing diagnostics must explain it. + [CONFIRMED] 2. Surgical lock edit (delete source+checksum lines from cfg-if [[package]] entry only) makes cargo + After removing exactly the 'source = ...' and 'checksum = ...' lines, cargo build --locked compiled 'cfg-if v1.0.4 (...app/.socket/vendor/cargo/9f6b2c4e-.../cfg-if-1.0.4)' and the binary printed 'patched marker = 1'. Lock format: version = 4. CAVEAT: first attempt failed compile because path deps do NOT get --cap-lints allow, so cfg-if's own #![deny(missing_docs)] fired on the + [CONFIRMED] 3. Fresh checkout (Cargo.toml, Cargo.lock, .cargo/config.toml, src/, .socket/ only) + empty CARGO_HO + CARGO_HOME= cargo build --locked --offline succeeded (exit 0), compiled cfg-if from the vendored .socket path, binary printed 'patched marker = 1'. The empty CARGO_HOME stayed completely empty afterward — zero registry/network access. Committable-guarantee proven for cargo. + [REFUTED] 4. cargo rewrites Cargo.lock after a successful build + Byte-identical (cmp clean) after both 'cargo build --locked' and plain 'cargo build'. Cargo accepts the surgically edited entry (name+version, no source = path/patched package) as canonical and never re-adds source/checksum or reformats — the edited lock is stable across builds run from inside the project. + [CONFIRMED] 5. Equal-version path patch: no [[patch.unused]] in Cargo.lock and patch takes effect + grep -c 'patch.unused' Cargo.lock = 0; vendored path appears in the Compiling line and SOCKET_PATCHED resolves. With the surgical edit, the path patch becomes the lock's sole provider of cfg-if 1.0.4; cargo records nothing extra in the lock for an equal-version [patch] from config.toml. + [PARTIAL] 6. Version drift (vendored Cargo.toml bumped to 1.0.999, [patch] kept): produces [[patch.unused]] or + No [[patch.unused]] in any mode. With --locked: fails closed, exit 101, same generic 'cannot update the lock file ... because --locked was passed' error (good guardrail, bad diagnostic). WITHOUT --locked: NO error — cargo silently re-locks: 'Updating cfg-if v1.0.4 (...vendor path...) -> v1.0.999', rewrites the lock entry to version = "1.0.999", and builds the vendored 1.0.999 ( + [CONFIRMED] 7. Relative path in .cargo/config.toml [patch] resolves against project root, verified building from + cd app/src && cargo build --locked succeeded, compiling cfg-if from /private/tmp/.../app/.socket/... ; cargo metadata run from src/ shows cfg-if manifest_path under the app root. Cargo resolves config-relative paths against the directory containing the .cargo/ dir of the config file, not the cwd. IMPORTANT LIMIT (probed): config discovery walks UP from cwd, so 'cd /tmp && cargo + [CONFIRMED] 8. Fresh cargo 1.93 lock has version = 4 and dependencies arrays reference cfg-if as plain name + Pristine lock generated by cargo 1.93.1: 'version = 4' on line 3; app's dependencies array is exactly ["cfg-if"] — no version or source suffix (v4 locks only add suffixes when multiple versions/sources need disambiguation). Consequence: the surgical edit needs to touch ONLY the [[package]] entry's source/checksum lines; no dependencies-array rewriting needed in the single-versi + SURPRISES: + - cargo 1.93's ~/.cargo/registry/src//-/ dirs contain NO .cargo-checksum.json (only .cargo-ok and .cargo_vcs_info.json) — the planned 'delete .cargo-checksum.json' step was a no-op; don't assume that file exists when sourcing from registry/src. + - Path dependencies are built WITHOUT --cap-lints allow (registry deps get it), so the crate's own #![deny(...)] lints fire on patched code — the un-doc'd appended const broke the build with 'error: missing documentation for a constant'. Vendored patches must be lint-clean against each crate's own deny lints, or the buil + - Silent-unpatch vector: invoking cargo from OUTSIDE the project (cwd above the .cargo/ dir, e.g. --manifest-path from elsewhere) skips config discovery entirely; an unlocked build then silently re-adds source+checksum to Cargo.lock and compiles the unpatched registry crate with no warning. + - Unlocked builds silently absorb vendored-version drift (re-lock 1.0.4 -> 1.0.999, no [[patch.unused]], no warning) as long as the semver requirement still matches; only --locked catches it, and with a generic error that never mentions the patch. + - The surgically edited lock is fully stable: cargo treats the source-less entry as the path-patched package and never rewrites/canonicalizes it on later builds (locked or unlocked, from inside the project). + REC: The design is viable for cargo: vendored copy under .socket/vendor/cargo//-/ + [patch.crates-io] in .cargo/config.toml + a surgical Cargo.lock edit (delete only the source and checksum lines of the patched [[package]] entry) yields a committable, byte-stable lock and a proven fresh-checkout offline-from-Socket build (empty CARGO_HOME untouched). Implement with these guards: (1) keep the vendored Cargo.toml version byte-identical to the locked version — any drift is silently re-locked on unlocked builds; (2) ensure patch hunks + FIXTURES: /tmp/socket-vendor-spike.luzrbh (primary app + Cargo.lock.pristine/.edited snapshots); /tmp/socket-vendor-fresh.LcVAth (fresh-checkout copy, vendored version drifted to 1.0.999); /tmp/socket-vendor-cargohome.0MFucL (empty CARGO_HOME used for offline proof) + +=== golang (go (go1.26.3 darwin/arm64); GOPROXY default https://proxy.go) === + [CONFIRMED] 1. go build succeeds with the patched func; uuid path level causes no issues + replace github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 => ./.socket/vendor/golang/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/github.com/google/shlex@v0.0.0-20191202100458-e7afc7fbc510 in /tmp/socket-vendor-spike.DB8QiH/go.mod. go build exit 0; binary ran and printed '[a b c d] true' (true = shlex.SocketPatched(), the func added only to the vendored copy). Uuid segment and + [CONFIRMED] 2. Fresh-offline: only go.mod+go.sum+main.go+.socket/, empty GOMODCACHE, GOPROXY=off builds + Copied exactly those 4 paths to /tmp/socket-vendor-fresh.1DlNsA; GOMODCACHE= GOPROXY=off go build → exit 0, ran patched output. Stronger than claimed: after the build the empty GOMODCACHE contained NOTHING (find showed only the bare dir) — directory-replaced modules bypass the module cache and sumdb entirely; no download, no go.sum check for the replaced module. + [CONFIRMED] 3. go.sum still contains the original module lines — harmless? + Both original lines (h1: and /go.mod) remained; offline build exit 0 and 'go mod verify' → 'all modules verified' exit 0 with them present. Also proved the entry is unnecessary: deleted go.sum entirely → offline build still exit 0 and go.sum was NOT recreated (only dep is directory-replaced). Stale lines are inert; safe to leave committed. + [CONFIRMED] 4. go mod tidy with GOPROXY=off in fresh dir: keeps replace? prunes go.sum? builds after? + GOMODCACHE= GOPROXY=off go mod tidy → exit 0 (no network needed). Replace line kept verbatim in go.mod; go.sum TRUNCATED to a 0-byte file (kept, not deleted) — the original lines are pruned because the replaced module needs no sum. Subsequent offline build exit 0, patched output. So users running tidy won't break the vendoring, but an empty go.sum file will sit in the re + [PARTIAL] 5. A second replace for the SAME module in go.mod is rejected by go + Only same-LHS + DIFFERENT-RHS is rejected, and only at build (not parse): 'go: conflicting replacements for github.com/google/shlex@v0.0.0-20191202100458-e7afc7fbc510:' then both paths listed, exit 1. Two SURPRISE acceptances: (a) byte-identical duplicate replace lines → exit 0, both lines stay in go.mod (go never dedupes/rejects; a vendor tool must self-dedupe before appending + [CONFIRMED] 6. The replace path needs './' prefix + Without './' go rejects at go.mod PARSE time, exit 1, but the message depends on the path. With the proposed layout (dir name ends in '@'): 'go: errors parsing go.mod:\ngo.mod:7: replacement module must match format ''path version'', not ''path@version''' — go treats the RHS as a module path and chokes on the @, a confusing error. With an @-free dir name the canonical + SURPRISES: + - go silently accepts byte-identical duplicate replace lines (exit 0, both kept) — the vendor tool cannot rely on go to catch double-application; it must parse and dedupe existing replace directives itself. Conflicting-RHS duplicates fail only at build/load time, not when editing go.mod. + - A pre-existing user versionless 'replace mod => path' is silently shadowed by the tool's versioned replace (versioned LHS wins, no warning) — vendor add/remove must detect and surface existing replaces for the same module. + - go mod tidy truncates go.sum to a 0-byte file (does not delete it) when all deps are directory-replaced; tidy is fully offline-safe here. + - Directory-replaced modules write nothing to GOMODCACHE at all — the offline guarantee is total for the replaced module (no download, no sumdb, no cache). + - The '@' suffix in the vendored dir name changes the missing-'./' failure mode to a misleading parse error ('must match format path version, not path@version') instead of the canonical directory-path error; with './' prefix the @ is harmless. + - Untested caveat: go mod init wrote 'go 1.26.3' into go.mod; a fresh checkout on an older toolchain with GOPROXY=off may fail trying to fetch a newer toolchain (GOTOOLCHAIN) — orthogonal to vendoring but affects the 'offline fresh checkout' guarantee. + REC: The Go design is viable as specified: a versioned directory replace pointing at ./.socket/vendor/golang//@/ gives a fully offline, cache-independent, go.sum-free patched build that survives go mod tidy. Implementation requirements surfaced by the spike: (1) always emit the RHS with a literal './' prefix (a bare '.socket/...' path fails at parse, with an extra-confusing error because of the '@' in the dir name); (2) the tool must own replace-directive idempotency — parse go.mod and update-or-skip rather than append, since + FIXTURES: /tmp/socket-vendor-spike.DB8QiH (primary app; fresh-checkout copy at /tmp/socket-vendor-fresh.1DlNsA, tidy/dup/precedence/no-prefix variants at /tmp/socket-vendor-{tidy,dup,dup2,dup3,prec,nopfx,nopfx2}.*) + +=== uv (uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin), CPyt) === + [CONFIRMED] 1. [tool.uv.sources] path-wheel entry produces a stable, capturable lock shape + uv lock exit 0. six [[package]] VERBATIM: name="six" / version="1.16.0" / source = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } / wheels = [ { filename = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d..." } ]. Wheels element keys are ONLY filename+hash (no url/path/size/upload-time); sdist line dropped; versio + [CONFIRMED] 2. Surgical text edit of the registry lock is viable (byte-match + accepted by uv) + Text surgery on direct-registry uv.lock (replace requires-dist line + six source/sdist/wheels block) reproduced the uv-generated path-wheel lock BYTE-IDENTICALLY (sha256 860d8fd3... both). With sources-bearing pyproject: uv lock --check exit 0; uv sync --locked exit 0 and installed '+ six==1.16.0 (from file:///...whl)'; plain uv sync left the lock byte-identical (same sha256 be + [CONFIRMED] 3. Fresh checkout + empty cache + uv sync --frozen --offline installs the patched vendored wheel + Patched wheel (marker prepended to six.py, RECORD line rewritten with urlsafe-b64 sha256 + new size, rezipped). Copy containing ONLY pyproject/uv.lock/.socket, UV_CACHE_DIR=fresh mktemp: uv sync --frozen --offline exit 0, 'Installed 1 package ... + six==1.16.0 (from file:///...9f6b.../six-1.16.0-py2.py3-none-any.whl)'; .venv python imports six 1.16.0; site-packages six.py line + [CONFIRMED] 4. Tampered vendored wheel is detected by uv sync --frozen + Two modes. (a) Valid-zip content tamper (lock hash stale): exit 1, 'Hash mismatch for `six @ file:///...whl` Expected: sha256:5ddd5223... Computed: sha256:7aec6932...'. (b) Raw byte-flip at offset 100: fails BEFORE hash check with 'Failed to extract archive: six-1.16.0-py2.py3-none-any.whl ... deflate decompression error: invalid distances set', exit 1. Either way install fai + [CONFIRMED] 5. Transitive promotion (add six==1.16.0 to project.dependencies + sources) works and is stable + uv lock: 'Updated six v1.17.0 -> v1.16.0'. Root dependencies VERBATIM: [ { name = "python-dateutil" }, { name = "six" } ]. requires-dist VERBATIM: [ { name = "python-dateutil", specifier = "==2.8.2" }, { name = "six", path = ".socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl" } ]. python-dateutil's own dependencies = [{ name = "six" }] is satisfied by the path source. + [CONFIRMED] 6. Lock-only transitive edit (hand-edit six [[package]] to path source, pyproject untouched) + PASSES everything that doesn't re-resolve six: uv lock --check exit 0, uv sync --locked exit 0 (installs from vendored wheel), uv sync --frozen exit 0, plain uv sync byte-stable, even plain uv lock preserves the path source. BUT uv lock --upgrade and --upgrade-package six silently revert ('Updated six v1.16.0 -> v1.17.0', registry source) with exit 0. So lock-only vendoring of + [REFUTED] 7. [tool.uv.sources] entry for an undeclared package: warning or hard error? + NEITHER — silently ignored, exit 0, zero diagnostics. Tested both: (a) six transitive-only (via python-dateutil): lock keeps six at registry 1.17.0, sources path entry has NO effect; (b) six absent from graph entirely (dependencies = []): lock contains only proj, no error. Implementation implication: a sources entry alone never vendors anything and uv won't tell you; must pair + [CONFIRMED] 8. tool.uv.sources applies to [tool.uv] override-dependencies + YES — this is the no-promotion path for transitive deps. override-dependencies = ["six==1.16.0"] + sources path entry, six NOT in project.dependencies: lock gains [manifest] section VERBATIM: overrides = [{ name = "six", path = ".socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl" }] (path replaces specifier there too); six [[package]] gets the same path source + filenam + [CONFIRMED] 9. Silent-revert risk: registry pyproject + path-source lock, plain uv sync + Risk is REAL. pyproject (no sources) + path lock: plain uv sync exit 0, NO warning, re-resolves and REWRITES the lock back to registry source (sha 860d8fd3 -> c7844e44, source = { registry = ... }), installs the REGISTRY wheel ('+ six==1.16.0' without 'from file://'). Patched code silently replaced by upstream. Mitigation exists: uv lock --check on that combo exits 1 with 'The + [CONFIRMED] 10. [manifest]/members shape of a single-project lock + Single-project locks (virtual AND packaged/hatchling) contain NO [manifest] section and no members key at all. [manifest] materializes only to persist resolver inputs, e.g. overrides (claim 8). Root package source: { virtual = "." } without build-system, { editable = "." } with one. Implementation should not expect/require [manifest] in single-project locks. + SURPRISES: + - Plain `uv lock` does NOT re-hash a path wheel whose bytes changed at an unchanged path — it kept the stale registry hash after we patched the wheel (lock validation never re-reads the file). Must run `uv lock --upgrade-package `, delete+regenerate, or surgically write the new sha256 (verified equivalent since --c + - Lock-only transitive vendoring (claim 6) is far more durable than expected: plain `uv lock` AND plain `uv sync` both preserve a hand-edited path source with zero pyproject support — only --upgrade/--upgrade-package silently destroys it. + - An unmatched [tool.uv.sources] entry is 100% silent (no warning) in uv 0.11.19 — easy to ship a vendor edit that does nothing if the package isn't directly declared or overridden. + - override-dependencies + sources is a clean transitive-vendoring channel that avoids polluting [project] dependencies and records itself in [manifest].overrides — arguably the best fit for socket-patch's transitive case, but note overrides apply tree-wide (force six==1.16.0 everywhere). + - requires-dist/overrides DROP the version specifier when a path source is applied (path replaces specifier); reverting a vendored dep must restore '==X.Y.Z' from socket-patch's own records, not from the lock. + - A raw byte-flip tamper fails as a zip 'deflate decompression error', not a hash mismatch — tooling that greps for 'Hash mismatch' to classify tamper will miss corrupt-archive cases. + REC: GO for uv. The committable guarantee holds: fresh checkout (pyproject + uv.lock + .socket only) with empty UV_CACHE_DIR installs the patched wheel via `uv sync --frozen --offline` for the vendored dep while other deps come from the registry, and the lock hash fails closed on tamper. Implementation design pinned by this spike: (1) edits must be PAIRED — write the [tool.uv.sources] path entry into pyproject.toml AND the lock entry, because a path lock without the pyproject entry is silently reverted by plain `uv sync` (claim 9), and a sources ent + FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/uv + +=== pip (python3.14 + pip 26.0 + uv 0.11.19 (wheel-rebuild vendor spi) === + [CONFIRMED] 1. pip install of rebuilt wheel into fresh venv; pip check; marker; clean uninstall + Rebuilt six-1.16.0-py2.py3-none-any.whl (members: LICENSE, METADATA, WHEEL, top_level.txt, six.py + regenerated RECORD; excluded __pycache__/six.cpython-314.pyc, INSTALLER, RECORD, REQUESTED; direct_url.json absent for registry installs). pip install --no-index exit 0 'Successfully installed six-1.16.0'; pip check 'No broken requirements found.'; installed six.py ends with '# S + [CONFIRMED] 2. uv pip install accepts the rebuilt wheel (strict RECORD validation) + uv venv + uv pip install --no-index : exit 0, 'Installed 1 package ... + six==1.16.0 (from file:///...9f6b2c4e.../six-1.16.0-py2.py3-none-any.whl)', ZERO warnings, marker present, import ok. Caveat from negative controls: uv 0.11.19's 'strict' RECORD check = RECORD must EXIST and parse (absent RECORD fails: 'failed to open file .../six-1.16.0.dist-info/RECORD: No such fi + [PARTIAL] 3. Bare relative path line in requirements.txt: works from project root; resolution base from anothe + ANSWER: both pip and uv resolve bare relative paths against the CURRENT WORKING DIRECTORY, NOT the requirements-file directory. From project root: pip exit 0, uv exit 0, both install from the vendor wheel. From a different cwd with -r /abs/path/requirements.txt: pip exit 1 — 'WARNING: Requirement ./.socket/...whl looks like a filename, but the file does not exist' then 'ERROR: + [CONFIRMED] 4. Path line + --hash=sha256:: accepted outside hash-mode; installs under --require-hashes; wro + Outside hash-mode (no --require-hashes): pip exit 0, uv exit 0 — accepted. With --require-hashes + correct hash: pip exit 0, uv exit 0. Wrong hash + --require-hashes: pip exit 1 'ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE ... Expected sha256 0000... Got f75f...'; uv exit 1 'Hash mismatch for six @ file:///... Expected: sha256:0000... Computed: sha2 + [CONFIRMED] 5. Trailing comment '...--hash=... # socket-patch vendor: six==1.16.0' accepted by both + Line './.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl --hash=sha256:f75f... # socket-patch vendor: six==1.16.0' installed under --require-hashes: pip exit 0, uv exit 0. Comment (whitespace + '#') stripped by both parsers; hash still enforced. + [CONFIRMED] 6. Mixed requirements (python-dateutil==2.8.2 + bare six wheel path): six from vendor wheel, dateuti + pip: downloaded only python_dateutil-2.8.2 from PyPI, six 'Processing ./.socket/...whl' (local); dateutil's six>=1.5 dep satisfied by the vendored wheel (no PyPI six download); pip check clean; marker True; dateutil.parser works. uv: 'Resolved 2 packages', '+ six==1.16.0 (from file:///...9f6b2c4e.../six-1.16.0-py2.py3-none-any.whl)'; marker True; dateutil works. Local path pin + [CONFIRMED] 7. pycowsay console-script regeneration from rebuilt wheel + Installed pycowsay 0.0.0.2 RECORD confirmed to contain '../../../bin/pycowsay' (console script, matched+excluded via entry_points.txt [console_scripts]). Rebuilt wheel (no bin entry, entry_points.txt retained) installed into fresh venvs: pip regenerated venv/bin/pycowsay (-rwxr-xr-x, 209B shebang wrapper) and it runs (exit 0); uv likewise regenerated bin/pycowsay and it runs; p + [REFUTED] 8. A bare PATH line cannot carry '; marker' (parse error justifies refusal) + NO parse error from either tool. './.socket/.../six-1.16.0-py2.py3-none-any.whl ; python_version >= "3.8"' installed: pip 26.0 exit 0, uv 0.11.19 exit 0. The marker is genuinely EVALUATED, not swallowed: with false marker '; python_version < "3"' pip prints 'Ignoring six: markers python_version < "3" don't match your environment' (exit 0, six NOT installed) and uv resolves 0 pa + SURPRISES: + - LOAD-BEARING: bare relative paths in requirements files resolve against the invoking process's CWD in BOTH pip 26.0 and uv 0.11.19 — never against the requirements-file directory. 'pip install -r /abs/req.txt' from outside the project breaks vendoring (verbatim errors in claim 3). The committable guarantee holds only f + - Claim 8 is the opposite of expected: '; marker' on a bare path line parses AND evaluates correctly in both tools (false marker skips the install). The planned refusal cannot be justified by a parse error on current toolchains. + - Neither pip 26.0 nor uv 0.11.19 verifies RECORD per-file sha256 (or completeness) at install time — a wheel with tampered six.py and stale RECORD installed cleanly in both. RECORD must merely exist (absent RECORD hard-fails both). So the regenerated RECORD's correctness matters for uninstall bookkeeping and auditabilit + - pycowsay's RECORD carries a second '../' entry that is NOT a console script: '../../../share/man/man6/pycowsay.6' (wheel .data/data payload). Console-scripts exclusion alone is insufficient; in this spike it was dropped only because splitext('pycowsay.6') accidentally collided with the script name 'pycowsay'. Rebuild l + - pip enables hash-checking implicitly whenever any requirement carries --hash (wrong hash fails even without --require-hashes); uv behaves the same. Adding --hash to the vendor line therefore hardens every install, not just --require-hashes workflows. + - The wheel rebuild is byte-for-byte deterministic across runs (identical sha256), so the emitted --hash pin is stable — regenerating the wheel from the same patched tree never churns requirements.txt. + - direct_url.json did not exist for a normal registry install (only INSTALLER/REQUESTED/RECORD/__pycache__ needed excluding); keep it in the exclude list anyway for path/URL-installed origins. + REC: The PyPI vendor design is viable with three adjustments. (1) Write the requirements line exactly as tested — './.socket/vendor/pypi// --hash=sha256: # socket-patch vendor: ==' — the trailing comment and hash are safe in both tools and the deterministic rebuild keeps the hash stable; but document/enforce that installs must run from the project root, since both pip and uv resolve the path against CWD, not the requirements file (claim 3 is the redesign-grade constraint). (2) In the rebuilder, handle ALL out-of-tree (' + FIXTURES: /tmp/socket-vendor-spike.WburO5 + +=== composer (docker run --rm -v :/app -w /app composer:2 (Composer 2) === + [CONFIRMED] 1. rm -rf vendor; composer install from lock alone installs the PATCHED copy + Exit 0. Output: 'Installing psr/log (3.0.2): Mirroring from .socket/vendor/composer/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/psr/log@3.0.2'. Marker '// SOCKET-PATCH-MARKER 9f6b2c4e' present at line 2 of vendor/psr/log/src/LoggerInterface.php. composer.json untouched (still requires psr/log 3.0.2 from packagist); only the lock packages[] entry was rewritten to dist {type:path,url: 3.0.2 f16e1d5)', rewrites the lock entry back to zip dist + git so + [CONFIRMED] 8. Lock records for mixed-case package names + Two fixtures. (a) require 'Psr/Log' in composer.json: composer 2 hard-errors before resolution — 'require.Psr/Log is invalid, it should not contain uppercase characters. Please use psr/log instead.' — so modern locks generated by composer always carry lowercase names for the require side. (b) Hand-written lock with name 'Psr/Log' + lowercase require: install succeeds but emits + SURPRISES: + - composer status reports 'No local changes' for the patched path-mirrored package — it will never surface the patch as drift, but also gives no integrity signal that the vendored copy is intact. + - --prefer-source silently falls back to the path dist with zero warning when source is absent; no flag combination forced a failure. + - Offline install emits a new non-fatal Composer 2.10 notice ('Filter list data could not be fetched ... ignored per policy.ignore-unreachable') — feature output filtering/tests should tolerate it. + - installed.json drops a null dist.reference key entirely but preserves a dummy string verbatim — null/omitted are the cleanest choices. + - Mixed-case lock names install to a case-preserved vendor path (vendor/Psr/Log) and trip the lock-freshness warning against a lowercase require — lock-name matching in our tool must be case-insensitive while preserving the original casing. + - composer update psr/log reports the revert as 'Upgrading psr/log (3.0.2 => 3.0.2 f16e1d5)' — same version, reference-only change — and silently discards the patch; this is the main UX hazard of the design. + REC: Design is viable for Composer; ship it. Lock-only surgery (dist→{type:path,url:relative .socket path,reference:null}, del source, add transport-options{symlink:false}) gives a committable, offline-reproducible, real-copy install with no composer.json edits and no content-hash invalidation; composer 2.10.1 confirmed every claim. Required implementation details: (1) always set transport-options.symlink:false or you may get a symlink instead of a copy; (2) use reference:null or omit it; (3) match lock package names case-insensitively but preserve + FIXTURES: /tmp/socket-vendor-spike.QhIIEk (main; lock backup at /tmp/socket-vendor-lock-backup.json), /tmp/socket-vendor-fresh.uoqZ0T (offline fresh-checkout proof), /tmp/socket-vendor-case.ZhhAFa + /tmp/socket-vendor-case2.eMmzdF (mixed-case fixtures) + +=== bundler (docker ruby:3.3 (bundler 2.5.22, ruby 3.3.11, aarch64-linux)) === + [CONFIRMED] 1. Pair edit + fresh container: bundle install succeeds and Rack::SOCKET_PATCHED prints true + Gemfile `gem "rack", "3.2.6", path: ".socket/vendor/gem/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/rack-3.2.6"` + hand-written lock: `bundle install` exit 0 ('Bundle complete! 1 Gemfile dependency, 2 gems now installed'), `bundle exec ruby -e 'require "rack"; puts Rack::SOCKET_PATCHED'` prints `true`. No registry fetch occurred (no 'Fetching gem metadata' line). Vendored dir = copy o + [CONFIRMED] 2. Post-install Gemfile.lock byte-identical to hand-written lock + cmp: BYTE-IDENTICAL. Stronger: deleted the lock and ran `bundle lock` from the pair-edited Gemfile alone — regenerated lock also BYTE-IDENTICAL. Canonical form the implementation must emit: (1) `PATH\n remote: .socket/vendor/gem//rack-3.2.6\n specs:\n rack (3.2.6)` — relative path, no trailing slash, no leading ./ — placed BEFORE GEM; (2) GEM section retained with `r + [CONFIRMED] 3. BUNDLE_FROZEN=true bundle install with the pair edit succeeds + docker -e BUNDLE_FROZEN=true: install exit 0, oracle prints `true`. Frozen mode accepts the hand-written PATH lock because Gemfile and lock agree, so the pair edit is CI/deploy-safe. + [CONFIRMED] 4. Lock-only edit: normal install silently unpatches; frozen install errors + Original Gemfile (`gem "rack", "~> 3.1"`) + PATH-edited lock. NORMAL: exit 0, re-resolves ('Fetching gem metadata... Fetching rack 3.2.6 / Installing rack 3.2.6'), lockfile rewritten back to pure GEM form (PATH section gone, DEPENDENCIES back to `rack (~> 3.1)`), runtime oracle prints UNPATCHED — silent unpatch confirmed; editing the Gemfile is mandatory. FROZEN: exit 16, lock + [CONFIRMED] 5. Stub gemspec from specifications/ works as the path-source gemspec + rack-3.2.6.gemspec stub renamed to rack.gemspec inside the vendored dir works. Warnings: NONE — bundle install stderr empty, and `bundle exec ruby -w -e 'require "rack"'` empty stderr too. Notes: stub's `s.files` lists only rdoc files (CHANGELOG/CONTRIBUTING/README) — irrelevant for path sources since the load path comes from `s.require_paths`; stub carries `s.installed_by_vers + [PARTIAL] 6. Native-ext (json C ext) behavior with a path-vendored copy + Bundler NEVER attempts to build native extensions for path-sourced gems — even though the stub gemspec declares `s.extensions = ["ext/json/ext/generator/extconf.rb", "ext/json/ext/parser/extconf.rb"]` (and even with a second explicit s.extensions added). (a) With prebuilt lib/json/ext/{generator,parser}.so copied along from the registry install: install exit 0 (no 'with native + [CONFIRMED] 7. Offline: --network none + only committed files (Gemfile, Gemfile.lock, .socket/, .bundle/config) + Fresh dir containing exactly Gemfile, Gemfile.lock, .socket/, .bundle/config (BUNDLE_PATH: vendor/bundle); docker --network none: install exit 0, oracle prints true, lock byte-untouched. No registry contact attempted (GEM specs empty + complete lock → bundler skips the metadata fetch entirely). Caveat: this Gemfile has no other registry deps; projects with other gems will still + SURPRISES: + - The official ruby docker image sets BUNDLE_APP_CONFIG=/usr/local/bundle, so `bundle config set --local path vendor/bundle` writes config INSIDE the container, not the project — the spike writes .bundle/config by hand and runs containers with -e BUNDLE_APP_CONFIG=/app/.bundle. Any docker-based test harness (and users ru + - Bundler skips native-extension builds entirely for path: sources even when the gemspec declares s.extensions — and a missing .so does NOT fail bundle install; it fails at first require, and on ruby 3.3 the bundled default `json` gem masks it as a confusing NameError (constant from a newer json API missing in the old st + - The hand-written lock was byte-identical even when regenerated from scratch via `bundle lock`, including the empty `GEM specs:` stanza, `(= 3.2.6)!` dependency form, and 3-space BUNDLED WITH indent — emission can be deterministic string surgery, no bundler invocation needed. + - Registry installs leave build litter inside the gem dir itself (ext/**/Makefile) which a naive copy inherits into the committed vendor tree; compiled-ext bookkeeping (gem.build_complete, mkmf.log) lives separately under vendor/bundle/.../extensions// and is NOT needed by the path source. + - Fully-offline install needed zero registry contact: with all gems path-sourced and the lock complete, bundler never fetches the rubygems index despite `source "https://rubygems.org"` in the Gemfile. + - Tooling self-correction: macOS BSD grep does not support \| alternation in BRE — an early grep 'proved' the json stub gemspec had no s.extensions; it does. Conclusions above were re-verified after fixing this. + REC: The Gemfile+Gemfile.lock pair edit is a sound design for bundler vendoring; no redesign risk found for pure-ruby gems. Implementation requirements proven by the spike: (1) ALWAYS edit both files — a lock-only edit is a silent unpatch on the next plain `bundle install` (frozen/CI catches it with exit 16, dev machines do not); (2) emit exactly bundler's canonical lock form captured in claim 2 (PATH before GEM, relative remote path, exact-pin `name (= ver)!` dependency, preserve PLATFORMS/BUNDLED WITH); since hand-written and bundler-regenerated l + FIXTURES: /tmp/socket-vendor-gem.Q9hupt (main pair-edit fixture; also /tmp/socket-vendor-gem-fresh.u27hcX offline, /tmp/socket-vendor-gem-lockonly.Yu4LLQ claim 4, /tmp/socket-vendor-gem-native.bEKz7m json C-ext, /tmp/socket-vendor-gem-relock.* lock regen) + From b153f71be4e80c8b41e07ebfc3a17f991c46af70 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 19:58:08 -0400 Subject: [PATCH 06/31] =?UTF-8?q?feat(vendor):=20core=20module=20=E2=80=94?= =?UTF-8?q?=20path=20convention,=20state=20ledger,=20marker,=20backend=20c?= =?UTF-8?q?ontract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vendor/path.rs: .socket/vendor/// layout, lockfile-string recovery parser (the documented external-tool rule), leaf<->purl round-trip, fail-closed uuid-dir sweep - vendor/state.rs: committed state.json ledger (verbatim original lockfile fragments for revert), atomic sorted deterministic writes, empty-state pruning, socket-patch.vendor.json marker writer - vendor/mod.rs: VendorOutcome/RevertOutcome backend contract, is_vendorable, is_purl_vendored; per-backend submodule stubs - deps: zip 8.6 (no-default+deflate) + base64 into core Co-Authored-By: Claude Fable 5 --- Cargo.lock | 41 ++ Cargo.toml | 1 + crates/socket-patch-core/Cargo.toml | 2 + crates/socket-patch-core/src/patch/mod.rs | 1 + .../src/patch/vendor/cargo.rs | 1 + .../src/patch/vendor/cargo_config.rs | 1 + .../src/patch/vendor/cargo_lock.rs | 1 + .../src/patch/vendor/composer_lock.rs | 1 + .../socket-patch-core/src/patch/vendor/gem.rs | 1 + .../src/patch/vendor/golang.rs | 1 + .../socket-patch-core/src/patch/vendor/mod.rs | 134 ++++++ .../src/patch/vendor/npm_lock.rs | 1 + .../src/patch/vendor/npm_pack.rs | 1 + .../src/patch/vendor/path.rs | 430 ++++++++++++++++++ .../src/patch/vendor/pypi.rs | 1 + .../src/patch/vendor/pypi_requirements.rs | 1 + .../src/patch/vendor/pypi_uv.rs | 1 + .../src/patch/vendor/pypi_wheel.rs | 1 + .../src/patch/vendor/state.rs | 400 ++++++++++++++++ .../src/patch/vendor/verify.rs | 1 + 20 files changed, 1022 insertions(+) create mode 100644 crates/socket-patch-core/src/patch/vendor/cargo.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/cargo_config.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/cargo_lock.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/composer_lock.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/gem.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/golang.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/mod.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/npm_lock.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/npm_pack.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/path.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/pypi.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/pypi_uv.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/state.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/verify.rs diff --git a/Cargo.lock b/Cargo.lock index 716919a..70ad835 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -740,6 +740,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -2430,6 +2431,7 @@ dependencies = [ name = "socket-patch-core" version = "3.3.0" dependencies = [ + "base64", "flate2", "fs2", "hex", @@ -2449,6 +2451,7 @@ dependencies = [ "uuid", "walkdir", "wiremock", + "zip", ] [[package]] @@ -2918,6 +2921,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.19.0" @@ -3691,8 +3700,40 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.13.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index cf94cd5..06d4b13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ once_cell = "=1.21.3" qbsdiff = "=1.4.4" tar = "=0.4.46" flate2 = "=1.1.9" +zip = { version = "=8.6.0", default-features = false, features = ["deflate"] } fs2 = "=0.4.3" wiremock = "=0.6.5" portable-pty = "=0.9.0" diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml index d5e8e7c..ce98a61 100644 --- a/crates/socket-patch-core/Cargo.toml +++ b/crates/socket-patch-core/Cargo.toml @@ -25,6 +25,8 @@ tar = { workspace = true } flate2 = { workspace = true } fs2 = { workspace = true } tempfile = { workspace = true } +zip = { workspace = true } +base64 = { workspace = true } [features] # `cargo` and `golang` are default features (npm + PyPI + Ruby gems are diff --git a/crates/socket-patch-core/src/patch/mod.rs b/crates/socket-patch-core/src/patch/mod.rs index 176adaa..5e8af87 100644 --- a/crates/socket-patch-core/src/patch/mod.rs +++ b/crates/socket-patch-core/src/patch/mod.rs @@ -14,3 +14,4 @@ pub mod package; pub(crate) mod path_safety; pub mod rollback; pub mod sidecars; +pub mod vendor; diff --git a/crates/socket-patch-core/src/patch/vendor/cargo.rs b/crates/socket-patch-core/src/patch/vendor/cargo.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/cargo.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) diff --git a/crates/socket-patch-core/src/patch/vendor/cargo_config.rs b/crates/socket-patch-core/src/patch/vendor/cargo_config.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/cargo_config.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) diff --git a/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs b/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) diff --git a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) diff --git a/crates/socket-patch-core/src/patch/vendor/gem.rs b/crates/socket-patch-core/src/patch/vendor/gem.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/gem.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) diff --git a/crates/socket-patch-core/src/patch/vendor/golang.rs b/crates/socket-patch-core/src/patch/vendor/golang.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/golang.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) diff --git a/crates/socket-patch-core/src/patch/vendor/mod.rs b/crates/socket-patch-core/src/patch/vendor/mod.rs new file mode 100644 index 0000000..db1f988 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/mod.rs @@ -0,0 +1,134 @@ +//! The `vendor` backend: committable vendoring of patched dependencies. +//! +//! Where `apply` patches installed packages in place (machine-local state), +//! `vendor` ejects each patched package into a committed +//! `.socket/vendor///` and rewires the ecosystem's +//! lockfile/config so the project consumes the vendored copy. After +//! committing `.socket/vendor/` + the lockfile edits, a fresh checkout builds +//! with the patched dependency on machines with no socket-patch installed and +//! no Socket API access (spike-proven per ecosystem against real package +//! managers — see `spikes/PHASE0-FINDINGS.txt`). +//! +//! ## Per-ecosystem wiring +//! +//! | eco | artifact | wiring | +//! |----------|---------------------|------------------------------------------------| +//! | npm | deterministic tgz | package-lock.json `resolved`+`integrity` only | +//! | cargo | crate dir | `.cargo/config.toml` `[patch.crates-io]` + Cargo.lock surgery | +//! | golang | module dir | `go.mod` `replace` ([`ReplaceOwner::Vendor`]) | +//! | composer | package dir | composer.lock `dist` → `{type: path}` | +//! | gem | gem dir (+gemspec) | Gemfile `path:` + Gemfile.lock PATH pair | +//! | pypi | rebuilt wheel | uv: pyproject+uv.lock pair; pip: requirements | +//! +//! ## Ownership & reversal +//! +//! `.socket/vendor/state.json` (committed) records the verbatim original +//! lockfile fragments every wire replaced; `vendor --revert` restores them +//! and removes the artifacts. `rollback`/`remove` stay vendoring-unaware by +//! design. The path-level UUID makes "is this Socket-vendored, by which +//! patch" recoverable from the lockfile string alone ([`path`]). +//! +//! [`ReplaceOwner::Vendor`]: crate::patch::go_mod_edit::ReplaceOwner + +pub mod path; +pub mod state; + +#[cfg(feature = "cargo")] +pub mod cargo; +#[cfg(feature = "cargo")] +pub mod cargo_config; +#[cfg(feature = "cargo")] +pub mod cargo_lock; +#[cfg(feature = "composer")] +pub mod composer_lock; +pub mod gem; +#[cfg(feature = "golang")] +pub mod golang; +pub mod npm_lock; +pub mod npm_pack; +pub mod pypi; +pub mod pypi_requirements; +pub mod pypi_uv; +pub mod pypi_wheel; +pub mod verify; + +pub use path::{ecosystem_dir_for_purl, parse_vendor_path, VendorPathParts, VENDOR_DIR}; +pub use state::{load_state, save_state, VendorEntry, VendorState, VENDOR_STATE_REL}; + +use crate::patch::apply::ApplyResult; + +/// A non-fatal advisory surfaced as a warning event (`code` is a stable +/// reason tag from the CLI contract; `detail` is human text). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VendorWarning { + pub code: &'static str, + pub detail: String, +} + +impl VendorWarning { + pub fn new(code: &'static str, detail: impl Into) -> Self { + Self { + code, + detail: detail.into(), + } + } +} + +/// The result of one backend `vendor_*` call. +#[derive(Debug)] +pub enum VendorOutcome { + /// Refused before any write (wrong package manager, unsupported lockfile + /// flavor, unsafe coordinates, …). `code` is the stable reason tag. + Refused { code: &'static str, detail: String }, + /// The backend ran. `result` carries the per-file verify/patch outcome + /// (the same [`ApplyResult`] contract as apply); `entry` is the state + /// record to persist — present iff `result.success` and not a dry run. + Done { + result: ApplyResult, + entry: Option, + warnings: Vec, + }, +} + +/// The result of one backend `revert_*` call. +#[derive(Debug)] +pub struct RevertOutcome { + pub success: bool, + pub warnings: Vec, + pub error: Option, +} + +impl RevertOutcome { + pub fn ok() -> Self { + Self { + success: true, + warnings: Vec::new(), + error: None, + } + } + + pub fn failed(error: impl Into) -> Self { + Self { + success: false, + warnings: Vec::new(), + error: Some(error.into()), + } + } +} + +/// True iff this build can vendor this PURL's ecosystem. +pub fn is_vendorable(purl: &str) -> bool { + ecosystem_dir_for_purl(purl).is_some() +} + +/// Cheap probe used by `apply` to respect vendor ownership: is `purl` +/// recorded as vendored in the committed ledger? +pub async fn is_purl_vendored(project_root: &std::path::Path, purl: &str) -> bool { + match load_state(project_root).await { + Ok(state) => { + state.entries.contains_key(purl) + || state.entries.values().any(|e| e.base_purl == purl) + } + Err(_) => false, + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/npm_lock.rs b/crates/socket-patch-core/src/patch/vendor/npm_lock.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/npm_lock.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) diff --git a/crates/socket-patch-core/src/patch/vendor/npm_pack.rs b/crates/socket-patch-core/src/patch/vendor/npm_pack.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/npm_pack.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) diff --git a/crates/socket-patch-core/src/patch/vendor/path.rs b/crates/socket-patch-core/src/patch/vendor/path.rs new file mode 100644 index 0000000..7bcecc1 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/path.rs @@ -0,0 +1,430 @@ +//! Vendored-path layout: builders, the lockfile-string recovery parser, and +//! the leaf↔PURL round-trip used by the orphan sweep. +//! +//! ## The convention (contract-documented) +//! +//! ```text +//! .socket/vendor/// +//! ``` +//! +//! The full 36-char lowercase hyphenated patch UUID is a dedicated path level, +//! so the UUID appears verbatim in every lockfile-visible path string — +//! external tools recover "this dependency is Socket-vendored, by patch X" +//! from the lockfile alone, with no access to `.socket/manifest.json` or +//! `state.json`. Each ecosystem keeps its canonical artifact name as the leaf +//! (wheel filenames stay pip-parseable, tarballs stay npm-conventional). +//! Updating a patch changes the UUID, which changes the path, which changes +//! the lockfile — staleness is diffable by construction. +//! +//! ## Leaves per ecosystem +//! +//! | eco | leaf | +//! |----------|----------------------------------------| +//! | npm | `[@scope/]-.tgz` | +//! | cargo | `-/` | +//! | golang | `@/` (nested dirs) | +//! | composer | `/@/` | +//! | gem | `-/` | +//! | pypi | `--.whl` (PEP 427)| + +use std::path::{Path, PathBuf}; + +use crate::patch::path_safety::{ + is_canonical_uuid, is_safe_multi_segment, is_safe_single_segment, +}; +use crate::utils::fs::list_dir_entries; + +/// Project-relative root of all vendored artifacts. +pub const VENDOR_DIR: &str = ".socket/vendor"; + +/// The ecosystem directory names under [`VENDOR_DIR`]. These double as the +/// `` capture of the recovery convention and are independent of which +/// features this binary was compiled with (an orphan sweep must still +/// recognise — and report, not delete — a dir for a compiled-out ecosystem). +pub const ECOSYSTEM_DIRS: &[&str] = &["npm", "cargo", "golang", "composer", "gem", "pypi"]; + +/// The vendor ecosystem-dir name for a PURL, or `None` when the ecosystem has +/// no vendor backend (maven/nuget/jsr) or is compiled out of this binary. +pub fn ecosystem_dir_for_purl(purl: &str) -> Option<&'static str> { + if purl.starts_with("pkg:npm/") { + return Some("npm"); + } + if purl.starts_with("pkg:pypi/") { + return Some("pypi"); + } + if purl.starts_with("pkg:gem/") { + return Some("gem"); + } + #[cfg(feature = "cargo")] + if purl.starts_with("pkg:cargo/") { + return Some("cargo"); + } + #[cfg(feature = "golang")] + if purl.starts_with("pkg:golang/") { + return Some("golang"); + } + #[cfg(feature = "composer")] + if purl.starts_with("pkg:composer/") { + return Some("composer"); + } + None +} + +/// The project-relative uuid dir (`.socket/vendor//`), validated. +/// +/// SECURITY: `uuid` comes from a committed, tamper-able manifest/state file +/// and keys an on-disk directory that vendor creates and `--revert` deletes. +/// Anything that is not the exact canonical UUID grammar is rejected +/// fail-closed before any disk access. +pub fn vendor_uuid_dir_rel(eco: &str, uuid: &str) -> Option { + if !ECOSYSTEM_DIRS.contains(&eco) || !is_canonical_uuid(uuid) { + return None; + } + Some(format!("{VENDOR_DIR}/{eco}/{uuid}")) +} + +/// One parsed vendored path (the output of [`parse_vendor_path`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VendorPathParts { + /// Ecosystem dir name (`npm`, `cargo`, …). + pub eco: String, + /// The 36-char canonical patch UUID. + pub uuid: String, + /// Everything after the uuid level, forward-slashed, no trailing slash. + pub leaf: String, +} + +/// Recover `(eco, uuid, leaf)` from any lockfile-recorded vendored path +/// string — `file:` npm specs, `./`-prefixed go.mod replace targets, +/// composer dist urls, requirement lines, backslashed Windows spellings. +/// This is the documented external-tool recovery rule; `None` means the +/// string is not a Socket-vendored path. +pub fn parse_vendor_path(s: &str) -> Option { + let norm = s.replace('\\', "/"); + let norm = norm.strip_prefix("file:").unwrap_or(&norm); + let norm = norm.strip_prefix("./").unwrap_or(norm); + // Find the `.socket/vendor/` anchor anywhere in the string (a workspace + // sub-project may record `../.socket/vendor/...`). + let anchor = format!("{VENDOR_DIR}/"); + let idx = norm.find(&anchor)?; + // Anchor must sit at a path-component boundary. + if idx > 0 && norm.as_bytes()[idx - 1] != b'/' { + return None; + } + let rest = &norm[idx + anchor.len()..]; + let mut it = rest.splitn(3, '/'); + let eco = it.next()?; + let uuid = it.next()?; + let leaf = it.next()?.trim_end_matches('/'); + if !ECOSYSTEM_DIRS.contains(&eco) || !is_canonical_uuid(uuid) || leaf.is_empty() { + return None; + } + Some(VendorPathParts { + eco: eco.to_string(), + uuid: uuid.to_string(), + leaf: leaf.to_string(), + }) +} + +/// Split a `-` leaf at the version boundary: the version is +/// the suffix after the LAST `-` that is immediately followed by a digit +/// (versions always start with a digit; names may contain digit-bearing +/// segments like `base-64`). Returns `(name, version)`. +fn split_name_version(leaf: &str) -> Option<(&str, &str)> { + let bytes = leaf.as_bytes(); + let mut split = None; + for (i, &b) in bytes.iter().enumerate() { + if b == b'-' && bytes.get(i + 1).is_some_and(|c| c.is_ascii_digit()) { + split = Some(i); + } + } + let i = split?; + let (name, version) = (&leaf[..i], &leaf[i + 1..]); + if name.is_empty() || version.is_empty() { + return None; + } + Some((name, version)) +} + +/// Split a `<…>@` leaf at the LAST `@` in its FINAL path component +/// (golang modules nest directories; composer leaves are `vendor/name@ver`). +fn split_at_version(leaf: &str) -> Option<(&str, &str)> { + let at = leaf.rfind('@')?; + // The `@` must be in the final component (a scope-`@` is at a component + // start and never the last `@` of a well-formed leaf, but be strict). + if leaf[at..].contains('/') { + return None; + } + let (head, version) = (&leaf[..at], &leaf[at + 1..]); + if head.is_empty() || version.is_empty() { + return None; + } + Some((head, version)) +} + +/// Reconstruct the base PURL from a vendored leaf. This is the orphan-sweep +/// FALLBACK identification (state.json is the ledger of record); `None` means +/// "unrecognisable — report, never delete by guess". +pub fn leaf_to_purl(eco: &str, leaf: &str) -> Option { + match eco { + "npm" => { + let stem = leaf.strip_suffix(".tgz")?; + let (name, version) = split_name_version(stem)?; + Some(format!("pkg:npm/{name}@{version}")) + } + "cargo" => { + let (name, version) = split_name_version(leaf)?; + Some(format!("pkg:cargo/{name}@{version}")) + } + "gem" => { + let (name, version) = split_name_version(leaf)?; + Some(format!("pkg:gem/{name}@{version}")) + } + "golang" => { + let (module, version) = split_at_version(leaf)?; + if !is_safe_multi_segment(module) || !is_safe_single_segment(version) { + return None; + } + Some(format!("pkg:golang/{module}@{version}")) + } + "composer" => { + let (path, version) = split_at_version(leaf)?; + let (vendor, name) = path.split_once('/')?; + if vendor.is_empty() || name.is_empty() || name.contains('/') { + return None; + } + Some(format!("pkg:composer/{vendor}/{name}@{version}")) + } + "pypi" => { + // PEP 427 wheel filename: dist-version-(build-)?py-abi-plat.whl; + // dist and version are the first two `-` segments (dist names + // normalise `-` to `_`, so the split is unambiguous). + let stem = leaf.strip_suffix(".whl")?; + let mut it = stem.splitn(3, '-'); + let dist = it.next()?; + let version = it.next()?; + it.next()?; // tags must exist + if dist.is_empty() || version.is_empty() { + return None; + } + Some(format!("pkg:pypi/{dist}@{version}")) + } + _ => None, + } +} + +/// One swept vendored unit: the uuid dir and what could be learned about it. +#[derive(Debug, Clone)] +pub struct SweptVendorDir { + pub eco: String, + pub uuid: String, + /// Absolute path of the uuid dir. + pub dir: PathBuf, + /// Base PURLs reconstructed from the leaves inside (may be empty when + /// nothing inside parses — such a dir is reported, never auto-deleted + /// unless its uuid is positively known stale). + pub purls: Vec, +} + +/// Enumerate every `.socket/vendor///` unit. Non-uuid-shaped dir +/// names are skipped fail-closed (we never touch what we can't positively +/// identify as ours). Used by reconcile and `--revert`'s orphan fallback. +pub async fn sweep_vendor_dirs(project_root: &Path) -> Vec { + let mut out = Vec::new(); + let vendor_root = project_root.join(VENDOR_DIR); + for eco in ECOSYSTEM_DIRS { + let eco_root = vendor_root.join(eco); + for entry in list_dir_entries(&eco_root).await { + let name = entry.file_name().to_string_lossy().into_owned(); + if !is_canonical_uuid(&name) { + continue; + } + let dir = entry.path(); + if !crate::utils::fs::entry_is_dir(&entry).await { + continue; + } + let purls = collect_leaf_purls(eco, &dir).await; + out.push(SweptVendorDir { + eco: (*eco).to_string(), + uuid: name, + dir, + purls, + }); + } + } + out +} + +/// Reconstruct base PURLs from the leaves inside one uuid dir. Walks nested +/// directories until a component parses as a versioned leaf (the golang +/// module / composer vendor-name nesting), mirroring the go-patches walker. +async fn collect_leaf_purls(eco: &str, uuid_dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec<(PathBuf, String)> = vec![(uuid_dir.to_path_buf(), String::new())]; + while let Some((dir, prefix)) = stack.pop() { + for entry in list_dir_entries(&dir).await { + let name = entry.file_name().to_string_lossy().into_owned(); + let leaf = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if let Some(purl) = leaf_to_purl(eco, &leaf) { + out.push(purl); + continue; // never recurse into a recognised unit + } + // Keep descending through structural levels (go module path + // segments, composer vendor dirs, npm @scope dirs) up to a sane + // depth bound. + if crate::utils::fs::entry_is_dir(&entry).await + && leaf.matches('/').count() < 8 + { + stack.push((entry.path(), leaf)); + } + } + } + out.sort(); + out.dedup(); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + #[test] + fn uuid_dir_is_validated() { + assert_eq!( + vendor_uuid_dir_rel("npm", UUID).as_deref(), + Some(".socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f") + ); + assert!(vendor_uuid_dir_rel("npm", "../../escape").is_none()); + assert!(vendor_uuid_dir_rel("npm", "9F6B2C4E-1D3A-4F6B-8C2D-7E5A9B1C3D5F").is_none()); + assert!(vendor_uuid_dir_rel("maven", UUID).is_none(), "unknown eco dir"); + } + + #[test] + fn recovery_parses_every_lockfile_spelling() { + // npm file: spec + let p = parse_vendor_path(&format!( + "file:.socket/vendor/npm/{UUID}/lodash-4.17.21.tgz" + )) + .unwrap(); + assert_eq!((p.eco.as_str(), p.uuid.as_str()), ("npm", UUID)); + assert_eq!(p.leaf, "lodash-4.17.21.tgz"); + + // go.mod replace target + let p = parse_vendor_path(&format!( + "./.socket/vendor/golang/{UUID}/github.com/foo/bar@v1.4.2" + )) + .unwrap(); + assert_eq!(p.eco, "golang"); + assert_eq!(p.leaf, "github.com/foo/bar@v1.4.2"); + + // composer dist url with trailing slash + let p = parse_vendor_path(&format!( + ".socket/vendor/composer/{UUID}/monolog/monolog@2.9.1/" + )) + .unwrap(); + assert_eq!(p.leaf, "monolog/monolog@2.9.1"); + + // cargo config path, backslashes (Windows spelling) + let p = parse_vendor_path(&format!( + ".socket\\vendor\\cargo\\{UUID}\\serde-1.0.190" + )) + .unwrap(); + assert_eq!((p.eco.as_str(), p.leaf.as_str()), ("cargo", "serde-1.0.190")); + + // anchored mid-string (workspace-relative) + assert!(parse_vendor_path(&format!( + "../.socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl" + )) + .is_some()); + + // Rejections: bad uuid, unknown eco, non-boundary anchor. + assert!(parse_vendor_path(".socket/vendor/npm/not-a-uuid/x.tgz").is_none()); + assert!(parse_vendor_path(&format!(".socket/vendor/maven/{UUID}/x")).is_none()); + assert!(parse_vendor_path(&format!("x.socket/vendor/npm/{UUID}/y.tgz")).is_none()); + } + + #[test] + fn leaf_round_trips() { + // npm, incl. scoped and digit-bearing names + prerelease versions. + assert_eq!( + leaf_to_purl("npm", "lodash-4.17.21.tgz").as_deref(), + Some("pkg:npm/lodash@4.17.21") + ); + assert_eq!( + leaf_to_purl("npm", "@scope/pkg-1.2.3.tgz").as_deref(), + Some("pkg:npm/@scope/pkg@1.2.3") + ); + assert_eq!( + leaf_to_purl("npm", "base-64-1.0.0.tgz").as_deref(), + Some("pkg:npm/base-64@1.0.0") + ); + assert_eq!( + leaf_to_purl("npm", "foo-1.0.0-beta.1.tgz").as_deref(), + Some("pkg:npm/foo@1.0.0-beta.1") + ); + // cargo / gem + assert_eq!( + leaf_to_purl("cargo", "serde-1.0.190").as_deref(), + Some("pkg:cargo/serde@1.0.190") + ); + assert_eq!( + leaf_to_purl("gem", "rack-3.2.6").as_deref(), + Some("pkg:gem/rack@3.2.6") + ); + // golang nested module + assert_eq!( + leaf_to_purl("golang", "github.com/foo/bar@v1.4.2").as_deref(), + Some("pkg:golang/github.com/foo/bar@v1.4.2") + ); + // composer + assert_eq!( + leaf_to_purl("composer", "monolog/monolog@2.9.1").as_deref(), + Some("pkg:composer/monolog/monolog@2.9.1") + ); + // pypi wheel + assert_eq!( + leaf_to_purl("pypi", "six-1.16.0-py2.py3-none-any.whl").as_deref(), + Some("pkg:pypi/six@1.16.0") + ); + // Unparseable leaves are None, not garbage. + assert!(leaf_to_purl("npm", "noversion.tgz").is_none()); + assert!(leaf_to_purl("golang", "no-version-here").is_none()); + assert!(leaf_to_purl("pypi", "six-1.16.0.whl").is_none(), "tags required"); + } + + #[tokio::test] + async fn sweep_finds_units_and_skips_non_uuid_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // A recognisable npm unit, a nested golang unit, and junk. + tokio::fs::create_dir_all(root.join(format!(".socket/vendor/npm/{UUID}"))) + .await + .unwrap(); + tokio::fs::write( + root.join(format!(".socket/vendor/npm/{UUID}/lodash-4.17.21.tgz")), + b"x", + ) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join(format!( + ".socket/vendor/golang/{UUID}/github.com/foo/bar@v1.4.2" + ))) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join(".socket/vendor/npm/not-a-uuid")).await.unwrap(); + + let swept = sweep_vendor_dirs(root).await; + assert_eq!(swept.len(), 2, "junk dir skipped: {swept:?}"); + let npm = swept.iter().find(|s| s.eco == "npm").unwrap(); + assert_eq!(npm.purls, vec!["pkg:npm/lodash@4.17.21".to_string()]); + let go = swept.iter().find(|s| s.eco == "golang").unwrap(); + assert_eq!(go.purls, vec!["pkg:golang/github.com/foo/bar@v1.4.2".to_string()]); + assert_eq!(go.uuid, UUID); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/pypi.rs b/crates/socket-patch-core/src/patch/vendor/pypi.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/pypi.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs b/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs b/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) diff --git a/crates/socket-patch-core/src/patch/vendor/state.rs b/crates/socket-patch-core/src/patch/vendor/state.rs new file mode 100644 index 0000000..0fa8990 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/state.rs @@ -0,0 +1,400 @@ +//! The committed vendor ledger: `.socket/vendor/state.json`. +//! +//! `vendor --revert` must restore the EXACT pre-vendor lockfile fragments — +//! registry `resolved` URLs (which may point at a private mirror), the +//! sha512/sha256 integrity strings of registry artifacts, verbatim +//! requirement lines, Cargo.lock `source`/`checksum` pairs. None of those are +//! recoverable offline from the vendored tree, so every wiring edit records +//! the verbatim original (and the new fragment we wrote, so revert can detect +//! third-party drift) here. The file is committed alongside `.socket/vendor/` +//! so any checkout can revert. +//! +//! Trust model: state.json is tamper-able like the manifest. Nothing here is +//! trusted to *name paths for deletion or hashing* without re-validating +//! through `path_safety` / `vendor::path` first; the artifact contents are +//! always re-verified against the manifest's afterHashes, never against this +//! file alone. + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize, Serializer}; + +use crate::utils::fs::atomic_write_bytes; + +use super::path::VENDOR_DIR; + +/// Project-relative path of the ledger. +pub const VENDOR_STATE_REL: &str = ".socket/vendor/state.json"; + +/// Current schema version. +pub const VENDOR_STATE_VERSION: u32 = 1; + +fn serialize_sorted(map: &HashMap, serializer: S) -> Result +where + S: Serializer, + V: Serialize, +{ + map.iter().collect::>().serialize(serializer) +} + +/// The vendored artifact (a tarball/wheel file, or the copy directory for the +/// dir-shaped ecosystems). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VendorArtifact { + /// Project-relative, forward-slashed path of the artifact + /// (`.socket/vendor///`). + pub path: String, + /// Plain sha256 hex of the artifact file (tarball/wheel); empty for + /// dir-shaped ecosystems (their integrity is per-file afterHashes). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sha256: String, + /// Artifact byte size (recorded where the lock format wants it). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + /// True when the artifact is platform-locked (a compiled-extension wheel + /// replacing multi-platform registry wheels). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform_locked: Option, +} + +/// How a wiring edit changed a file. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum WiringAction { + /// An existing fragment was replaced (`original` holds the verbatim old + /// value to restore). + Rewritten, + /// A new fragment was added (revert deletes it; `original` is absent). + Added, +} + +/// One recorded lockfile/manifest edit. `original`/`new` are verbatim +/// fragments whose shape is per-`kind`: JSON objects for package-lock +/// entries, strings for TOML/go.mod/requirement fragments, arrays of strings +/// for multi-line blocks. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WiringRecord { + /// Project-relative file that was edited (`package-lock.json`, `go.mod`, + /// `pyproject.toml`, …). + pub file: String, + /// Discriminator for the fragment shape and the revert routine, e.g. + /// `npm_lock_entry`, `go_replace`, `cargo_patch_entry`, `cargo_lock_entry`, + /// `composer_lock_package`, `uv_sources_entry`, `uv_override`, + /// `uv_lock_package`, `uv_lock_requires_dist`, `requirements_line`, + /// `gemfile_line`, `gemfile_lock_spec`. + pub kind: String, + pub action: WiringAction, + /// A kind-specific key locating the fragment (the lock path + /// `node_modules/lodash`, the package/module name, a line anchor). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + /// Verbatim original fragment ([`WiringAction::Rewritten`] only). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original: Option, + /// The fragment vendor wrote (lets revert detect third-party drift: if + /// the live fragment is neither `new` nor pointing into `.socket/vendor/`, + /// it is left alone with a warning). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new: Option, +} + +/// Original Cargo.lock fields removed by the path-dep surgery; not +/// recomputable offline (the checksum is the sha256 of the registry `.crate` +/// tarball, not of the extracted tree). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CargoLockOriginal { + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checksum: Option, +} + +/// pypi/uv bookkeeping. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UvMeta { + /// `direct` (declared in project.dependencies → tool.uv.sources entry) or + /// `override` (transitive → tool.uv override-dependencies + sources). + pub dep_class: String, + /// The `==X.Y.Z` specifier the lock's requires-dist/overrides carried + /// before the path source replaced it (uv DROPS the specifier for path + /// sources; revert restores it from here). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original_specifier: Option, + /// Whether vendor created the `[tool.uv.sources]` table itself (revert + /// then removes the empty table too). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub created_sources_table: bool, + /// uv.lock `revision` observed at vendor time (diagnostics). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lock_revision: Option, +} + +/// One vendored package. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VendorEntry { + /// Vendor ecosystem dir name (`npm`, `cargo`, `golang`, `composer`, + /// `gem`, `pypi`). + pub ecosystem: String, + /// Qualifier-free base PURL (`pkg:npm/lodash@4.17.21`). The map key is + /// the manifest PURL (possibly qualified); this is the resolved base. + pub base_purl: String, + /// The patch UUID — redundant with the artifact path's uuid level, kept + /// as a cross-check. + pub uuid: String, + pub artifact: VendorArtifact, + /// Every lockfile/manifest edit, in application order (revert runs them + /// in reverse). + pub wiring: Vec, + /// cargo: the lock fields the surgery removed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lock: Option, + /// golang: vendor took over an existing `.socket/go-patches/` redirect. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub took_over_go_patches: bool, + /// pypi: which wiring flavor was used (`uv` | `requirements`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flavor: Option, + /// pypi/uv extras. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uv: Option, +} + +/// The ledger. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VendorState { + pub version: u32, + #[serde(serialize_with = "serialize_sorted")] + pub entries: HashMap, +} + +impl VendorState { + pub fn new() -> Self { + Self { + version: VENDOR_STATE_VERSION, + entries: HashMap::new(), + } + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +impl Default for VendorState { + fn default() -> Self { + Self::new() + } +} + +fn state_path(project_root: &Path) -> PathBuf { + project_root.join(VENDOR_STATE_REL) +} + +/// Load the ledger. A missing file is an empty ledger; an unreadable or +/// unparseable file is an error (fail-closed — revert must not guess). +pub async fn load_state(project_root: &Path) -> std::io::Result { + let path = state_path(project_root); + match tokio::fs::read(&path).await { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("corrupt {}: {e}", path.display()), + ) + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(VendorState::new()), + Err(e) => Err(e), + } +} + +/// Persist the ledger atomically with sorted keys + 2-space indent + trailing +/// newline (deterministic bytes — the file is committed). An EMPTY ledger +/// deletes `state.json` and prunes `.socket/vendor/` when that leaves it +/// empty, so a fully-reverted project carries no vendor residue. +pub async fn save_state(project_root: &Path, state: &VendorState) -> std::io::Result<()> { + let path = state_path(project_root); + if state.is_empty() { + match tokio::fs::remove_file(&path).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + // Prune now-empty .socket/vendor (and only it — never recursive). + let vendor_root = project_root.join(VENDOR_DIR); + let _ = tokio::fs::remove_dir(&vendor_root).await; // fails non-empty: fine + return Ok(()); + } + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let mut bytes = serde_json::to_vec_pretty(state).map_err(std::io::Error::other)?; + bytes.push(b'\n'); + atomic_write_bytes(&path, &bytes).await +} + +/// The informational marker written inside each vendored unit +/// (`socket-patch.vendor.json`, a sibling of the artifact in the uuid dir). +/// Belt-and-braces for tools that have the tree but not the lockfile; never +/// a trust input — sweep/verify key off state.json + the path uuid. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VendorMarker { + pub schema_version: u32, + pub purl: String, + pub patch_uuid: String, + pub ecosystem: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub vulnerabilities: Vec, + /// RFC3339 timestamp supplied by the caller (the CLI formats it). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub vendored_at: String, +} + +/// File name of the marker inside the uuid dir. +pub const VENDOR_MARKER_FILE: &str = "socket-patch.vendor.json"; + +/// Write the marker atomically into `uuid_dir`. +pub async fn write_marker(uuid_dir: &Path, marker: &VendorMarker) -> std::io::Result<()> { + let mut bytes = serde_json::to_vec_pretty(marker).map_err(std::io::Error::other)?; + bytes.push(b'\n'); + atomic_write_bytes(&uuid_dir.join(VENDOR_MARKER_FILE), &bytes).await +} + +#[cfg(test)] +mod tests { + use super::*; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + fn sample_entry() -> VendorEntry { + VendorEntry { + ecosystem: "npm".into(), + base_purl: "pkg:npm/lodash@4.17.21".into(), + uuid: UUID.into(), + artifact: VendorArtifact { + path: format!(".socket/vendor/npm/{UUID}/lodash-4.17.21.tgz"), + sha256: "ab".repeat(32), + size: Some(3668), + platform_locked: None, + }, + wiring: vec![WiringRecord { + file: "package-lock.json".into(), + kind: "npm_lock_entry".into(), + action: WiringAction::Rewritten, + key: Some("node_modules/lodash".into()), + original: Some(serde_json::json!({ + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-orig" + })), + new: Some(serde_json::json!({ + "version": "4.17.21", + "resolved": format!("file:.socket/vendor/npm/{UUID}/lodash-4.17.21.tgz"), + "integrity": "sha512-ours" + })), + }], + lock: None, + took_over_go_patches: false, + flavor: None, + uv: None, + } + } + + #[tokio::test] + async fn round_trip_and_determinism() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut state = VendorState::new(); + state + .entries + .insert("pkg:npm/lodash@4.17.21".into(), sample_entry()); + + save_state(root, &state).await.unwrap(); + let loaded = load_state(root).await.unwrap(); + assert_eq!(loaded, state); + + // Byte-deterministic across re-saves (committed file). + let bytes1 = tokio::fs::read(root.join(VENDOR_STATE_REL)).await.unwrap(); + save_state(root, &loaded).await.unwrap(); + let bytes2 = tokio::fs::read(root.join(VENDOR_STATE_REL)).await.unwrap(); + assert_eq!(bytes1, bytes2); + assert!(bytes1.ends_with(b"\n")); + // Empty optional fields are omitted from the wire form. + let text = String::from_utf8(bytes1).unwrap(); + assert!(!text.contains("tookOverGoPatches")); + assert!(!text.contains("\"flavor\"")); + assert!(text.contains("\"basePurl\""), "camelCase keys: {text}"); + } + + #[tokio::test] + async fn missing_file_is_empty_corrupt_file_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + assert!(load_state(root).await.unwrap().is_empty()); + + tokio::fs::create_dir_all(root.join(".socket/vendor")).await.unwrap(); + tokio::fs::write(root.join(VENDOR_STATE_REL), b"{not json").await.unwrap(); + let err = load_state(root).await.unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + + #[tokio::test] + async fn empty_state_removes_file_and_prunes_empty_vendor_dir() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut state = VendorState::new(); + state + .entries + .insert("pkg:npm/lodash@4.17.21".into(), sample_entry()); + save_state(root, &state).await.unwrap(); + assert!(root.join(VENDOR_STATE_REL).exists()); + + state.entries.clear(); + save_state(root, &state).await.unwrap(); + assert!(!root.join(VENDOR_STATE_REL).exists()); + assert!( + !root.join(VENDOR_DIR).exists(), + ".socket/vendor pruned when empty" + ); + + // But a vendor dir that still holds artifacts is NOT pruned. + let mut state = VendorState::new(); + state + .entries + .insert("pkg:npm/lodash@4.17.21".into(), sample_entry()); + save_state(root, &state).await.unwrap(); + tokio::fs::create_dir_all(root.join(".socket/vendor/npm")).await.unwrap(); + state.entries.clear(); + save_state(root, &state).await.unwrap(); + assert!(root.join(".socket/vendor/npm").exists(), "non-empty dir kept"); + } + + #[tokio::test] + async fn marker_writes_atomically() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + let marker = VendorMarker { + schema_version: 1, + purl: "pkg:npm/lodash@4.17.21".into(), + patch_uuid: UUID.into(), + ecosystem: "npm".into(), + vulnerabilities: vec!["GHSA-xxxx-yyyy-zzzz".into()], + vendored_at: "2026-06-09T00:00:00Z".into(), + }; + write_marker(dir, &marker).await.unwrap(); + let text = tokio::fs::read_to_string(dir.join(VENDOR_MARKER_FILE)) + .await + .unwrap(); + assert!(text.contains("\"patchUuid\"")); + assert!(text.contains(UUID)); + // No stage litter. + for e in std::fs::read_dir(dir).unwrap() { + let name = e.unwrap().file_name().to_string_lossy().into_owned(); + assert!(!name.starts_with(".socket-stage-"), "litter: {name}"); + } + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/verify.rs b/crates/socket-patch-core/src/patch/vendor/verify.rs new file mode 100644 index 0000000..ed2edb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/verify.rs @@ -0,0 +1 @@ +//! (stub — implementation lands with its backend phase) From ef6ce86ef22b228d4af63e08c6da2eec55548aef Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 20:05:24 -0400 Subject: [PATCH 07/31] refactor(cli): extract patch-source staging into commands/fetch_stage.rs apply's offline-guard + overlay-tempdir + download block becomes stage_patch_sources(), shared with the upcoming vendor command. Behavior preserved verbatim; apply_network + apply_invariants suites green. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/commands/apply.rs | 223 ++-------------- .../src/commands/fetch_stage.rs | 245 ++++++++++++++++++ crates/socket-patch-cli/src/commands/mod.rs | 1 + 3 files changed, 262 insertions(+), 207 deletions(-) create mode 100644 crates/socket-patch-cli/src/commands/fetch_stage.rs diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index ec8c5ea..2266363 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -1,8 +1,4 @@ use clap::Args; -use socket_patch_core::api::blob_fetcher::{ - fetch_missing_blobs, fetch_missing_sources, format_fetch_result, get_missing_archives, - get_missing_blobs, DownloadMode, -}; use socket_patch_core::api::client::get_api_client_with_overrides; use socket_patch_core::crawlers::{ detect_npm_pkg_manager, CrawlerOptions, Ecosystem, NpmPkgManager, @@ -23,9 +19,8 @@ use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event}; use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::utils::telemetry::{track_patch_applied, track_patch_apply_failed}; use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::time::Duration; -use tempfile::TempDir; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; @@ -34,39 +29,6 @@ use crate::json_envelope::{ VexSummary, }; -/// Overlay every regular file from `src` into `dst` via hard link (falling -/// back to copy if hard linking fails — e.g. cross-filesystem, permission -/// quirk). Skips files that already exist at `dst`. Silently no-ops if -/// `src` doesn't exist so fresh projects with no `.socket/` cache work. -/// -/// Used by `apply` to stage a transient overlay of the persistent -/// `.socket/` cache inside a tempdir so the apply pipeline can read -/// pre-cached artifacts and freshly-fetched ones from the same path -/// without ever mutating `.socket/`. -async fn overlay_dir(src: &Path, dst: &Path) { - let mut entries = match tokio::fs::read_dir(src).await { - Ok(e) => e, - Err(_) => return, - }; - while let Ok(Some(entry)) = entries.next_entry().await { - let file_type = match entry.file_type().await { - Ok(t) => t, - Err(_) => continue, - }; - if !file_type.is_file() { - continue; - } - let from = entry.path(); - let to = dst.join(entry.file_name()); - if tokio::fs::metadata(&to).await.is_ok() { - continue; - } - if tokio::fs::hard_link(&from, &to).await.is_err() { - let _ = tokio::fs::copy(&from, &to).await; - } - } -} - use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls}; #[derive(Args)] @@ -709,177 +671,24 @@ async fn apply_patches_inner( .map_err(|e| e.to_string())? .ok_or_else(|| "Invalid manifest".to_string())?; - // The persistent cache directories under `.socket/`. Apply only ever - // *reads* from these — writes (downloads, cleanup) happen against a - // transient overlay tempdir constructed below when fetching is needed. + // Resolve patch sources (read `.socket/` directly, or stage an overlay + // tempdir + download the gap). Shared with `vendor` via fetch_stage. let socket_dir = manifest_path.parent().unwrap(); - let socket_blobs_path = socket_dir.join("blobs"); - let socket_diffs_path = socket_dir.join("diffs"); - let socket_packages_path = socket_dir.join("packages"); - - let download_mode = DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?; - - // Compute per-patch source availability so both the offline guard - // (next block) and the `download_needed` decision below share the - // same notion of what's already on disk. These probes are read-only. - let missing_blobs = get_missing_blobs(&manifest, &socket_blobs_path).await; - let missing_diff_archives = get_missing_archives(&manifest, &socket_diffs_path).await; - let missing_package_archives = get_missing_archives(&manifest, &socket_packages_path).await; - - // A patch is "locally applicable" iff at least one of: - // - every `after_hash` blob it references is on disk, OR - // - its diff archive is on disk, OR - // - its package archive is on disk. - // The apply pipeline will pick whichever is present per file. - let patches_without_source: Vec<&str> = manifest - .patches - .iter() - .filter_map(|(purl, record)| { - let all_blobs_present = record - .files - .values() - .all(|f| !missing_blobs.contains(&f.after_hash)); - let diff_present = !missing_diff_archives.contains(&record.uuid); - let pkg_present = !missing_package_archives.contains(&record.uuid); - if all_blobs_present || diff_present || pkg_present { - None - } else { - Some(purl.as_str()) - } - }) - .collect(); - - if args.common.offline { - // Offline: bail only if some patch has no usable local source. - // Note: with `--force`, the apply pipeline can short-circuit - // verification on its own; we still surface the no-source - // diagnosis so the user runs `repair` before retrying. - if !patches_without_source.is_empty() { - if !args.common.silent && !args.common.json { - eprintln!( - "Error: {} patch(es) have no local source and --offline is set:", - patches_without_source.len() - ); - for purl in patches_without_source.iter().take(5) { - eprintln!(" - {}", purl); - } - if patches_without_source.len() > 5 { - eprintln!(" ... and {} more", patches_without_source.len() - 5); - } - eprintln!("Run \"socket-patch repair\" to download missing artifacts."); - } - return Ok((false, Vec::new(), Vec::new())); - } - } - - // Decide what (if anything) needs downloading. - // - // The apply pipeline tries sources in the order package → diff → - // blob locally. We honor `--download-mode` for the primary fetch - // when there's actually a gap to close. Skip the archive fetch - // entirely when all file blobs are already present locally — - // apply will succeed via the blob path, and the archive endpoints - // would just 404 (current server doesn't serve them yet). - let download_needed = !args.common.offline - && match download_mode { - DownloadMode::File => !missing_blobs.is_empty(), - DownloadMode::Diff | DownloadMode::Package if missing_blobs.is_empty() => false, - DownloadMode::Diff => !missing_diff_archives.is_empty(), - DownloadMode::Package => !missing_package_archives.is_empty(), - }; - - // Determine where the apply pipeline should read patch sources from. - // - // - If nothing needs downloading (offline mode, or every required - // artifact is already in `.socket/`), read straight from `.socket/`. - // Apply is purely read-only against the persistent cache. - // - Otherwise, stage a transient overlay tempdir that hardlinks every - // existing `.socket/` artifact and receives fresh downloads. Apply - // reads exclusively from the tempdir; `.socket/` is never mutated. - // - // `_stage_dir` keeps the `TempDir` handle alive for the rest of this - // function — on drop the OS removes the directory and any downloaded - // bytes go with it. - let (blobs_path, diffs_path, packages_path, _stage_dir): ( - PathBuf, - PathBuf, - PathBuf, - Option, - ) = if download_needed { - let stage = tempfile::tempdir().map_err(|e| e.to_string())?; - let stage_blobs = stage.path().join("blobs"); - let stage_diffs = stage.path().join("diffs"); - let stage_packages = stage.path().join("packages"); - for dir in [&stage_blobs, &stage_diffs, &stage_packages] { - tokio::fs::create_dir_all(dir) - .await - .map_err(|e| e.to_string())?; - } - overlay_dir(&socket_blobs_path, &stage_blobs).await; - overlay_dir(&socket_diffs_path, &stage_diffs).await; - overlay_dir(&socket_packages_path, &stage_packages).await; - - if !args.common.silent && !args.common.json { - println!( - "Downloading missing patch artifacts (mode: {})...", - download_mode.as_tag() - ); - } - - let (client, _) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; - let sources = PatchSources { - blobs_path: &stage_blobs, - packages_path: Some(&stage_packages), - diffs_path: Some(&stage_diffs), - }; - let fetch_result = - fetch_missing_sources(&manifest, &sources, download_mode, &client, None).await; - - if !args.common.silent && !args.common.json { - println!("{}", format_fetch_result(&fetch_result)); - } - - // For non-file modes, automatically fetch any still-missing file - // blobs as a fallback. Patches that lack the requested mode on - // the server will still apply via the legacy blob path. - if download_mode != DownloadMode::File { - let still_missing_blobs = get_missing_blobs(&manifest, &stage_blobs).await; - if !still_missing_blobs.is_empty() { - if !args.common.silent && !args.common.json { - println!( - "Falling back to per-file blob downloads for {} blob(s)...", - still_missing_blobs.len() - ); - } - let blob_result = - fetch_missing_blobs(&manifest, &stage_blobs, &client, None).await; - if !args.common.silent && !args.common.json { - println!("{}", format_fetch_result(&blob_result)); - } - if blob_result.failed > 0 && fetch_result.failed > 0 { - if !args.common.silent && !args.common.json { - eprintln!("Some artifacts could not be downloaded. Cannot apply patches."); - } - return Ok((false, Vec::new(), Vec::new())); - } - } - } else if fetch_result.failed > 0 { - if !args.common.silent && !args.common.json { - eprintln!("Some blobs could not be downloaded. Cannot apply patches."); - } - return Ok((false, Vec::new(), Vec::new())); + let staged = match crate::commands::fetch_stage::stage_patch_sources( + &args.common, + &manifest, + socket_dir, + ) + .await? + { + crate::commands::fetch_stage::StageOutcome::Ready(s) => s, + crate::commands::fetch_stage::StageOutcome::Unavailable => { + return Ok((false, Vec::new(), Vec::new())) } - - (stage_blobs, stage_diffs, stage_packages, Some(stage)) - } else { - ( - socket_blobs_path.clone(), - socket_diffs_path.clone(), - socket_packages_path.clone(), - None, - ) }; + let blobs_path = staged.blobs.clone(); + let diffs_path = staged.diffs.clone(); + let packages_path = staged.packages.clone(); // Partition manifest PURLs by ecosystem let manifest_purls: Vec = manifest.patches.keys().cloned().collect(); diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs new file mode 100644 index 0000000..3a76f0c --- /dev/null +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -0,0 +1,245 @@ +//! Shared patch-source staging for the mutating commands (`apply`, `vendor`). +//! +//! Resolves where the patch pipeline should read blob/diff/package artifacts +//! from, downloading what's missing into a transient overlay tempdir. The +//! persistent `.socket/{blobs,diffs,packages}` cache is only ever *read* — +//! downloads land in the tempdir and are discarded when it drops (filling the +//! cache is `repair`'s job, keeping these commands read-only against +//! `.socket/`). + +use std::path::{Path, PathBuf}; + +use socket_patch_core::api::blob_fetcher::{ + fetch_missing_blobs, fetch_missing_sources, format_fetch_result, get_missing_archives, + get_missing_blobs, DownloadMode, +}; +use socket_patch_core::api::client::get_api_client_with_overrides; +use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::patch::apply::PatchSources; +use tempfile::TempDir; + +use crate::args::GlobalArgs; + +/// Resolved artifact locations for the patch pipeline. Holds the overlay +/// `TempDir` alive — sources become invalid when this is dropped. +pub struct StagedSources { + pub blobs: PathBuf, + pub diffs: PathBuf, + pub packages: PathBuf, + _stage: Option, +} + +impl StagedSources { + /// Borrow as the core pipeline's source set. + pub fn as_patch_sources(&self) -> PatchSources<'_> { + PatchSources { + blobs_path: &self.blobs, + packages_path: Some(&self.packages), + diffs_path: Some(&self.diffs), + } + } +} + +/// The staging outcome. +pub enum StageOutcome { + /// Every patch has a readable source at the returned paths. + Ready(StagedSources), + /// Sources are unavailable (offline with missing artifacts, or downloads + /// failed). User-facing diagnostics were already printed; the caller + /// reports command failure. + Unavailable, +} + +/// Mirror `src`'s files into `dst` by hardlink (copy fallback). Pre-seeds the +/// overlay tempdir with everything already cached so only the gap downloads. +async fn overlay_dir(src: &Path, dst: &Path) { + let mut entries = match tokio::fs::read_dir(src).await { + Ok(e) => e, + Err(_) => return, + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let file_type = match entry.file_type().await { + Ok(t) => t, + Err(_) => continue, + }; + if !file_type.is_file() { + continue; + } + let from = entry.path(); + let to = dst.join(entry.file_name()); + if tokio::fs::metadata(&to).await.is_ok() { + continue; + } + if tokio::fs::hard_link(&from, &to).await.is_err() { + let _ = tokio::fs::copy(&from, &to).await; + } + } +} + +/// Resolve patch sources for `manifest`: read straight from `.socket/` when +/// everything needed is cached (or `--offline`), else stage an overlay +/// tempdir and fetch the gap. `Err` is a hard setup failure (bad +/// `--download-mode`, tempdir creation); `Ok(Unavailable)` is the soft +/// "cannot proceed" path with diagnostics already printed. +pub async fn stage_patch_sources( + common: &GlobalArgs, + manifest: &PatchManifest, + socket_dir: &Path, +) -> Result { + let socket_blobs_path = socket_dir.join("blobs"); + let socket_diffs_path = socket_dir.join("diffs"); + let socket_packages_path = socket_dir.join("packages"); + + let download_mode = DownloadMode::parse(&common.download_mode).map_err(|e| e.to_string())?; + + // Compute per-patch source availability so both the offline guard and + // the `download_needed` decision share the same notion of what's already + // on disk. These probes are read-only. + let missing_blobs = get_missing_blobs(manifest, &socket_blobs_path).await; + let missing_diff_archives = get_missing_archives(manifest, &socket_diffs_path).await; + let missing_package_archives = get_missing_archives(manifest, &socket_packages_path).await; + + // A patch is "locally applicable" iff at least one of: + // - every `after_hash` blob it references is on disk, OR + // - its diff archive is on disk, OR + // - its package archive is on disk. + // The patch pipeline picks whichever is present per file. + let patches_without_source: Vec<&str> = manifest + .patches + .iter() + .filter_map(|(purl, record)| { + let all_blobs_present = record + .files + .values() + .all(|f| !missing_blobs.contains(&f.after_hash)); + let diff_present = !missing_diff_archives.contains(&record.uuid); + let pkg_present = !missing_package_archives.contains(&record.uuid); + if all_blobs_present || diff_present || pkg_present { + None + } else { + Some(purl.as_str()) + } + }) + .collect(); + + if common.offline { + // Offline: bail only if some patch has no usable local source. + // Note: with `--force`, the patch pipeline can short-circuit + // verification on its own; we still surface the no-source + // diagnosis so the user runs `repair` before retrying. + if !patches_without_source.is_empty() { + if !common.silent && !common.json { + eprintln!( + "Error: {} patch(es) have no local source and --offline is set:", + patches_without_source.len() + ); + for purl in patches_without_source.iter().take(5) { + eprintln!(" - {}", purl); + } + if patches_without_source.len() > 5 { + eprintln!(" ... and {} more", patches_without_source.len() - 5); + } + eprintln!("Run \"socket-patch repair\" to download missing artifacts."); + } + return Ok(StageOutcome::Unavailable); + } + } + + // Decide what (if anything) needs downloading. + // + // The patch pipeline tries sources in the order package → diff → blob + // locally. We honor `--download-mode` for the primary fetch when there's + // actually a gap to close. Skip the archive fetch entirely when all file + // blobs are already present locally — the pipeline will succeed via the + // blob path, and the archive endpoints would just 404 (current server + // doesn't serve them yet). + let download_needed = !common.offline + && match download_mode { + DownloadMode::File => !missing_blobs.is_empty(), + DownloadMode::Diff | DownloadMode::Package if missing_blobs.is_empty() => false, + DownloadMode::Diff => !missing_diff_archives.is_empty(), + DownloadMode::Package => !missing_package_archives.is_empty(), + }; + + if !download_needed { + return Ok(StageOutcome::Ready(StagedSources { + blobs: socket_blobs_path, + diffs: socket_diffs_path, + packages: socket_packages_path, + _stage: None, + })); + } + + // Stage a transient overlay tempdir that hardlinks every existing + // `.socket/` artifact and receives fresh downloads. The pipeline reads + // exclusively from the tempdir; `.socket/` is never mutated. Dropping + // `StagedSources` removes the directory and any downloaded bytes. + let stage = tempfile::tempdir().map_err(|e| e.to_string())?; + let stage_blobs = stage.path().join("blobs"); + let stage_diffs = stage.path().join("diffs"); + let stage_packages = stage.path().join("packages"); + for dir in [&stage_blobs, &stage_diffs, &stage_packages] { + tokio::fs::create_dir_all(dir) + .await + .map_err(|e| e.to_string())?; + } + overlay_dir(&socket_blobs_path, &stage_blobs).await; + overlay_dir(&socket_diffs_path, &stage_diffs).await; + overlay_dir(&socket_packages_path, &stage_packages).await; + + if !common.silent && !common.json { + println!( + "Downloading missing patch artifacts (mode: {})...", + download_mode.as_tag() + ); + } + + let (client, _) = get_api_client_with_overrides(common.api_client_overrides()).await; + let sources = PatchSources { + blobs_path: &stage_blobs, + packages_path: Some(&stage_packages), + diffs_path: Some(&stage_diffs), + }; + let fetch_result = fetch_missing_sources(manifest, &sources, download_mode, &client, None).await; + + if !common.silent && !common.json { + println!("{}", format_fetch_result(&fetch_result)); + } + + // For non-file modes, automatically fetch any still-missing file blobs as + // a fallback. Patches that lack the requested mode on the server will + // still apply via the legacy blob path. + if download_mode != DownloadMode::File { + let still_missing_blobs = get_missing_blobs(manifest, &stage_blobs).await; + if !still_missing_blobs.is_empty() { + if !common.silent && !common.json { + println!( + "Falling back to per-file blob downloads for {} blob(s)...", + still_missing_blobs.len() + ); + } + let blob_result = fetch_missing_blobs(manifest, &stage_blobs, &client, None).await; + if !common.silent && !common.json { + println!("{}", format_fetch_result(&blob_result)); + } + if blob_result.failed > 0 && fetch_result.failed > 0 { + if !common.silent && !common.json { + eprintln!("Some artifacts could not be downloaded. Cannot apply patches."); + } + return Ok(StageOutcome::Unavailable); + } + } + } else if fetch_result.failed > 0 { + if !common.silent && !common.json { + eprintln!("Some blobs could not be downloaded. Cannot apply patches."); + } + return Ok(StageOutcome::Unavailable); + } + + Ok(StageOutcome::Ready(StagedSources { + blobs: stage_blobs, + diffs: stage_diffs, + packages: stage_packages, + _stage: Some(stage), + })) +} diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 4b092f0..9e1a825 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod apply; +pub mod fetch_stage; pub mod get; pub mod list; pub mod lock_cli; From 24055541ba71274a0c0e23a377d3e7ce3fb8b2fa Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 20:09:12 -0400 Subject: [PATCH 08/31] feat(vendor): vendored-patch verification + Command::Vendor envelope tag verify_vendored_patch_record: positive file-level evidence against the committed artifact (dirs hashed in place; npm tgz / pypi whl decoded in memory, bomb-capped). Fail-closed: no_files -> vendor_path_unsafe -> vendor_uuid_mismatch (path-level uuid IS the staleness signal) -> vendor_artifact_missing -> unreadable/hash_mismatch. Poisoned state.json paths never stat outside the project tree. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/json_envelope.rs | 2 + .../src/patch/vendor/verify.rs | 393 +++++++++++++++++- 2 files changed, 394 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 73fab72..6c8211b 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -372,6 +372,7 @@ pub enum Command { Repair, Setup, Unlock, + Vendor, Vex, } @@ -844,6 +845,7 @@ mod tests { (Command::Repair, "repair"), (Command::Setup, "setup"), (Command::Unlock, "unlock"), + (Command::Vendor, "vendor"), (Command::Vex, "vex"), ] { let serialized = serde_json::to_string(&command).unwrap(); diff --git a/crates/socket-patch-core/src/patch/vendor/verify.rs b/crates/socket-patch-core/src/patch/vendor/verify.rs index ed2edb3..c210b0f 100644 --- a/crates/socket-patch-core/src/patch/vendor/verify.rs +++ b/crates/socket-patch-core/src/patch/vendor/verify.rs @@ -1 +1,392 @@ -//! (stub — implementation lands with its backend phase) +//! Verification of vendored patches for VEX attestation and drift audits. +//! +//! A vendored patch is attested only on **positive file-level evidence**: the +//! committed artifact must exist at its uuid-keyed path and every file the +//! manifest claims the patch modified must hash (git-blob sha256) to its +//! `afterHash` inside that artifact — the same standard `vex::verify` applies +//! to installed trees. Dir-shaped ecosystems are hashed in place; npm +//! tarballs and pypi wheels are decoded in memory (bounded — the artifacts +//! are committed and tamper-able, so a crafted archive must not OOM an +//! audit). +//! +//! Fail-closed order (each failure is a stable snake_case routing tag): +//! `no_files` → `vendor_path_unsafe` → `vendor_uuid_mismatch` → +//! `vendor_artifact_missing` → `vendor_artifact_unreadable` / +//! `file_not_found` / `vendor_hash_mismatch`. + +use std::collections::HashMap; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use crate::hash::git_sha256::compute_git_sha256_from_bytes; +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{normalize_file_path, verify_file_patch, VerifyStatus}; +use crate::patch::package::read_archive_to_map; + +use super::path::parse_vendor_path; +use super::state::VendorEntry; + +/// Hard cap on decompressed wheel bytes, mirroring +/// `patch::package`'s bomb posture for patch archives. +const MAX_WHEEL_DECOMPRESSED_BYTES: u64 = 64 * 1024 * 1024; +const MAX_WHEEL_ENTRIES: usize = 10_000; + +/// Validate `entry.artifact.path` and resolve it under `project_root`. +/// +/// SECURITY: state.json is committed and tamper-able. The artifact path is +/// about to be stat'd/read/hashed, so it must (a) parse as a canonical +/// vendored path (which validates the uuid grammar), (b) be relative with no +/// `..`/absolute/NUL components, and (c) carry the uuid of the patch record +/// being attested — a poisoned path must neither read outside the project +/// tree nor launder one patch's artifact into another's attestation. +fn checked_artifact_path( + project_root: &Path, + entry: &VendorEntry, + record: &PatchRecord, +) -> Result { + let rel = &entry.artifact.path; + let parts = parse_vendor_path(rel).ok_or_else(|| "vendor_path_unsafe".to_string())?; + let norm = rel.replace('\\', "/"); + if norm.starts_with('/') + || norm.contains('\0') + || !norm.starts_with(".socket/vendor/") + || norm.split('/').any(|seg| seg == ".." || seg.is_empty()) + { + return Err("vendor_path_unsafe".to_string()); + } + // Stale-vendor detection: the path-level uuid IS the staleness signal — + // a patch update changes record.uuid, so an artifact still sitting at the + // old uuid path must not attest the new patch. + if parts.uuid != record.uuid || entry.uuid != record.uuid { + return Err("vendor_uuid_mismatch".to_string()); + } + Ok(project_root.join(norm)) +} + +/// `Ok(())` iff every `record.files` entry hashes to its `afterHash` inside +/// the vendored artifact named by `entry`. The error is a stable routing tag +/// (see module docs) compatible with `vex::verify::FailedPatch.reason`. +pub async fn verify_vendored_patch_record( + project_root: &Path, + entry: &VendorEntry, + record: &PatchRecord, +) -> Result<(), String> { + if record.files.is_empty() { + // Same contract as vex::verify: nothing to hash ⇒ never attested. + return Err("no_files".to_string()); + } + + let artifact = checked_artifact_path(project_root, entry, record)?; + if tokio::fs::metadata(&artifact).await.is_err() { + return Err("vendor_artifact_missing".to_string()); + } + + let path_str = artifact.to_string_lossy().to_string(); + if path_str.ends_with(".tgz") || path_str.ends_with(".tar.gz") { + verify_tarball_members(&artifact, record).await + } else if path_str.ends_with(".whl") { + verify_wheel_members(&artifact, record).await + } else { + verify_dir_members(&artifact, record).await + } +} + +/// Dir-shaped ecosystems (cargo/golang/composer/gem): hash files in place, +/// reusing the hardened per-file verifier (it normalizes manifest keys and +/// fail-closes on path-escaping keys). +async fn verify_dir_members(dir: &Path, record: &PatchRecord) -> Result<(), String> { + for (file_name, info) in &record.files { + let result = verify_file_patch(dir, file_name, info).await; + match result.status { + VerifyStatus::AlreadyPatched => continue, + VerifyStatus::Ready | VerifyStatus::HashMismatch => { + return Err("vendor_hash_mismatch".to_string()) + } + VerifyStatus::NotFound => return Err("file_not_found".to_string()), + } + } + Ok(()) +} + +/// npm tarballs: decode in memory via the bomb-capped patch-archive reader +/// (it strips the `package/` prefix, matching `normalize_file_path`'d keys) +/// and hash each member against its afterHash. +async fn verify_tarball_members(tgz: &Path, record: &PatchRecord) -> Result<(), String> { + let tgz = tgz.to_path_buf(); + let map = tokio::task::spawn_blocking(move || read_archive_to_map(&tgz)) + .await + .map_err(|_| "vendor_artifact_unreadable".to_string())? + .map_err(|_| "vendor_artifact_unreadable".to_string())?; + verify_member_map(&map, record) +} + +/// pypi wheels: bounded zip decode (member names are site-packages-relative, +/// exactly the manifest's pypi key space). +async fn verify_wheel_members(whl: &Path, record: &PatchRecord) -> Result<(), String> { + let whl = whl.to_path_buf(); + let map = tokio::task::spawn_blocking(move || read_wheel_to_map(&whl)) + .await + .map_err(|_| "vendor_artifact_unreadable".to_string())??; + verify_member_map(&map, record) +} + +fn read_wheel_to_map(whl: &Path) -> Result>, String> { + let file = std::fs::File::open(whl).map_err(|_| "vendor_artifact_unreadable".to_string())?; + let mut zip = + zip::ZipArchive::new(file).map_err(|_| "vendor_artifact_unreadable".to_string())?; + if zip.len() > MAX_WHEEL_ENTRIES { + return Err("vendor_artifact_unreadable".to_string()); + } + let mut out = HashMap::new(); + let mut total: u64 = 0; + for i in 0..zip.len() { + let mut entry = zip + .by_index(i) + .map_err(|_| "vendor_artifact_unreadable".to_string())?; + if !entry.is_file() { + continue; + } + // SECURITY: bound the cumulative decompressed size before reading — + // a committed-but-tampered wheel must not balloon an audit's memory. + total = total.saturating_add(entry.size()); + if total > MAX_WHEEL_DECOMPRESSED_BYTES { + return Err("vendor_artifact_unreadable".to_string()); + } + let name = entry.name().to_string(); + let mut bytes = Vec::new(); + entry + .by_ref() + .take(MAX_WHEEL_DECOMPRESSED_BYTES) + .read_to_end(&mut bytes) + .map_err(|_| "vendor_artifact_unreadable".to_string())?; + out.insert(name, bytes); + } + Ok(out) +} + +fn verify_member_map( + members: &HashMap>, + record: &PatchRecord, +) -> Result<(), String> { + for (file_name, info) in &record.files { + let key = normalize_file_path(file_name); + let bytes = members + .get(key) + .or_else(|| members.get(file_name.as_str())) + .ok_or_else(|| "file_not_found".to_string())?; + let hash = compute_git_sha256_from_bytes(bytes); + if !hash.eq_ignore_ascii_case(&info.after_hash) { + return Err("vendor_hash_mismatch".to_string()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::schema::PatchFileInfo; + use crate::patch::vendor::state::VendorArtifact; + use flate2::write::GzEncoder; + use std::io::Write; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const PATCHED: &[u8] = b"patched bytes\n"; + + fn record(uuid: &str, file_key: &str) -> PatchRecord { + let mut files = HashMap::new(); + files.insert( + file_key.to_string(), + PatchFileInfo { + before_hash: "b".into(), + after_hash: compute_git_sha256_from_bytes(PATCHED), + }, + ); + PatchRecord { + uuid: uuid.to_string(), + exported_at: "t".into(), + files, + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + } + } + + fn entry(eco: &str, uuid: &str, rel_path: &str) -> VendorEntry { + VendorEntry { + ecosystem: eco.into(), + base_purl: "pkg:npm/x@1.0.0".into(), + uuid: uuid.into(), + artifact: VendorArtifact { + path: rel_path.into(), + sha256: String::new(), + size: None, + platform_locked: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + flavor: None, + uv: None, + } + } + + fn write_tgz(dest: &Path, member: &str, bytes: &[u8]) { + let mut builder = tar::Builder::new(GzEncoder::new( + std::fs::File::create(dest).unwrap(), + flate2::Compression::new(6), + )); + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append_data(&mut header, member, bytes).unwrap(); + builder.into_inner().unwrap().finish().unwrap(); + } + + fn write_whl(dest: &Path, member: &str, bytes: &[u8]) { + let file = std::fs::File::create(dest).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file::<_, ()>(member, Default::default()).unwrap(); + zip.write_all(bytes).unwrap(); + zip.finish().unwrap(); + } + + #[tokio::test] + async fn dir_artifact_verifies_and_detects_tamper() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let rel = format!(".socket/vendor/cargo/{UUID}/serde-1.0.0"); + let dir = root.join(&rel); + tokio::fs::create_dir_all(dir.join("src")).await.unwrap(); + tokio::fs::write(dir.join("src/lib.rs"), PATCHED).await.unwrap(); + + let rec = record(UUID, "src/lib.rs"); + let ent = entry("cargo", UUID, &rel); + assert!(verify_vendored_patch_record(root, &ent, &rec).await.is_ok()); + + tokio::fs::write(dir.join("src/lib.rs"), b"tampered").await.unwrap(); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + "vendor_hash_mismatch" + ); + + tokio::fs::remove_file(dir.join("src/lib.rs")).await.unwrap(); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + "file_not_found" + ); + } + + #[tokio::test] + async fn tarball_members_verified_with_package_prefix_keys() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let rel = format!(".socket/vendor/npm/{UUID}/x-1.0.0.tgz"); + tokio::fs::create_dir_all(root.join(format!(".socket/vendor/npm/{UUID}"))) + .await + .unwrap(); + write_tgz(&root.join(&rel), "package/index.js", PATCHED); + + // Manifest npm keys carry the package/ prefix. + let rec = record(UUID, "package/index.js"); + let ent = entry("npm", UUID, &rel); + assert!(verify_vendored_patch_record(root, &ent, &rec).await.is_ok()); + + // One tampered byte inside the archive flips the verdict. + write_tgz(&root.join(&rel), "package/index.js", b"tampered"); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + "vendor_hash_mismatch" + ); + + // Member missing entirely. + write_tgz(&root.join(&rel), "package/other.js", PATCHED); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + "file_not_found" + ); + + // Truncated/corrupt gzip is unreadable, not a crash. + tokio::fs::write(root.join(&rel), b"\x1f\x8b00garbage").await.unwrap(); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + "vendor_artifact_unreadable" + ); + } + + #[tokio::test] + async fn wheel_members_verified() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let rel = format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl"); + tokio::fs::create_dir_all(root.join(format!(".socket/vendor/pypi/{UUID}"))) + .await + .unwrap(); + write_whl(&root.join(&rel), "six.py", PATCHED); + + let rec = record(UUID, "six.py"); + let ent = entry("pypi", UUID, &rel); + assert!(verify_vendored_patch_record(root, &ent, &rec).await.is_ok()); + + write_whl(&root.join(&rel), "six.py", b"tampered"); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + "vendor_hash_mismatch" + ); + } + + #[tokio::test] + async fn fail_closed_ordering_and_guards() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let rel = format!(".socket/vendor/npm/{UUID}/x-1.0.0.tgz"); + + // no_files first. + let mut rec = record(UUID, "package/index.js"); + rec.files.clear(); + let ent = entry("npm", UUID, &rel); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + "no_files" + ); + + // SECURITY: poisoned state.json paths never stat/read outside the + // project tree — rejected before any disk access. + let rec = record(UUID, "package/index.js"); + let escape = format!(".socket/vendor/npm/{UUID}/../../../escape.tgz"); + for bad in [ + "/etc/passwd", + "../../outside.tgz", + escape.as_str(), + ".socket/vendor/npm/not-a-uuid/x.tgz", + ] { + let ent = entry("npm", UUID, bad); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + "vendor_path_unsafe", + "path {bad} must be rejected" + ); + } + + // Stale vendor: artifact still at the OLD uuid while the record moved on. + let new_uuid = "11111111-2222-4333-8444-555555555555"; + let rec_new = record(new_uuid, "package/index.js"); + let ent_old = entry("npm", UUID, &rel); + assert_eq!( + verify_vendored_patch_record(root, &ent_old, &rec_new) + .await + .unwrap_err(), + "vendor_uuid_mismatch" + ); + + // Missing artifact (path fine, uuid fine, nothing on disk). + let ent = entry("npm", UUID, &rel); + let rec = record(UUID, "package/index.js"); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + "vendor_artifact_missing" + ); + } +} From f7cda25d9b586b7b625bea3965469cacb6dbd1aa Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 20:11:21 -0400 Subject: [PATCH 09/31] feat(apply): yield to vendor ownership (golang skip, --check exclusion, vendored event reason) - try_local_go_apply: a purl recorded in .socket/vendor/state.json is never taken back over; synthesized success routes to Skipped/'vendored' - result_to_event: vendored package_path marker -> reason 'vendored' - run_check: vendored purls excluded from the go-patches drift audit Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/commands/apply.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 2266363..ca1dc11 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -101,6 +101,26 @@ async fn try_local_go_apply( if !is_local_go(purl, common) { return None; } + // Vendor ownership wins: a module recorded in `.socket/vendor/state.json` + // is managed by the explicit `socket-patch vendor` action; the implicit + // apply must not repoint its `replace` back at `.socket/go-patches/`. + // The synthesized result's vendored package_path routes the event to + // `Skipped`/`vendored` (see `result_to_event`). + if socket_patch_core::patch::vendor::is_purl_vendored(&common.cwd, purl).await { + return Some(ApplyResult { + package_key: purl.to_string(), + package_path: format!( + "{}/golang (managed by vendor)", + socket_patch_core::patch::vendor::VENDOR_DIR + ), + success: true, + files_verified: Vec::new(), + files_patched: Vec::new(), + applied_via: HashMap::new(), + error: None, + sidecar: None, + }); + } // `pkg_path` is the pristine, case-encoded module-cache dir; `module`/ // `version` are the decoded PURL components keying the copy + `replace`. let (module, version) = parse_golang_purl(purl)?; @@ -190,10 +210,23 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { { use socket_patch_core::patch::go_redirect::Drift as GoDrift; if go_in_local_scope(&args.common) { + // Vendored modules are excluded: their replace directives point at + // `.socket/vendor/golang/` (the verify engine skips Vendor-owned + // entries) and their state is audited by `vendor`, not `--check`. + let vendored = socket_patch_core::patch::vendor::load_state(&args.common.cwd) + .await + .map(|s| { + s.entries + .iter() + .flat_map(|(k, e)| [k.clone(), e.base_purl.clone()]) + .collect::>() + }) + .unwrap_or_default(); let desired: HashSet = manifest .patches .keys() .filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Golang)) + .filter(|p| !vendored.contains(*p)) .cloned() .collect(); checked += desired.len(); @@ -330,6 +363,15 @@ pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent ); } + // A package managed by `socket-patch vendor` is skipped with its own + // reason: apply runs implicitly (postinstall/CI) and must never flip + // ownership back from the explicit vendor action. The synthesized result + // carries the vendored path as its package_path, which is the marker. + if result.package_path.contains(".socket/vendor/") { + return PatchEvent::new(PatchAction::Skipped, purl) + .with_reason("vendored", "managed by `socket-patch vendor`"); + } + if all_files_already_patched(result) { return PatchEvent::new(PatchAction::Skipped, purl) .with_reason("already_patched", "All files already match afterHash"); From 22cfe19d9d1d46e92db7b2ceb63dfa74b7cf51af Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 20:15:51 -0400 Subject: [PATCH 10/31] feat(cli): vendor command pipeline + vendor telemetry events (not yet wired) commands/vendor.rs: full vendor + --revert pipelines (lock, manifest gate, fetch staging, reconcile-dropped, crawl, variant probe, per-eco dispatch, per-package state persistence, orphan sweep, VEX embed, envelope). Stays undeclared in commands/mod.rs until the ecosystem backends land so concurrent builds stay green. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/commands/apply.rs | 2 +- .../socket-patch-cli/src/commands/vendor.rs | 768 ++++++++++++++++++ .../socket-patch-core/src/utils/telemetry.rs | 41 + 3 files changed, 810 insertions(+), 1 deletion(-) create mode 100644 crates/socket-patch-cli/src/commands/vendor.rs diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index ca1dc11..b857059 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -330,7 +330,7 @@ fn all_files_already_patched(result: &ApplyResult) -> bool { /// while a wheel is installed). Skipping it avoids attempting — and /// spuriously reporting a `Failed` event for — a variant that was never /// installed. -fn variant_matches_installed(first_file_status: Option<&VerifyStatus>) -> bool { +pub(crate) fn variant_matches_installed(first_file_status: Option<&VerifyStatus>) -> bool { match first_file_status { None => true, Some(status) => { diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs new file mode 100644 index 0000000..2e03521 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -0,0 +1,768 @@ +//! `socket-patch vendor` — committable vendoring of patched dependencies. +//! +//! Works like `apply`, but instead of patching installed packages in place it +//! ejects each patched package into `.socket/vendor///…` and +//! rewires the ecosystem's lockfile/config so the project consumes the +//! vendored copy. After committing `.socket/vendor/` + the lockfile edits, a +//! fresh checkout builds with the patched dependency on machines with no +//! socket-patch and no Socket API access. `--revert` restores the recorded +//! original lockfile fragments and removes the artifacts. `rollback`/`remove` +//! stay vendoring-unaware by design — this command owns the whole lifecycle. + +use clap::Args; +use socket_patch_core::api::client::get_api_client_with_overrides; +use socket_patch_core::crawlers::{detect_npm_pkg_manager, CrawlerOptions, Ecosystem, NpmPkgManager}; +use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; +use socket_patch_core::patch::apply::{verify_file_patch, PatchSources}; +use socket_patch_core::patch::copy_tree::remove_tree; +use socket_patch_core::patch::vendor::{ + self, ecosystem_dir_for_purl, load_state, save_state, RevertOutcome, VendorEntry, + VendorOutcome, VendorWarning, +}; +use socket_patch_core::utils::purl::strip_purl_qualifiers; +use socket_patch_core::utils::telemetry::{track_patch_vendor_failed, track_patch_vendored}; +use socket_patch_core::vex::time::now_rfc3339; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::time::Duration; + +use crate::args::{apply_env_toggles, GlobalArgs}; +use crate::commands::apply::{result_to_event, variant_matches_installed}; +use crate::commands::fetch_stage::{stage_patch_sources, StageOutcome}; +use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event}; +use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; +use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls}; +use crate::json_envelope::{ + Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status, VexSummary, +}; + +#[derive(Args)] +pub struct VendorArgs { + #[command(flatten)] + pub common: GlobalArgs, + + /// Skip pre-vendor hash verification (vendor even if the installed + /// package's files differ from the patch's beforeHash). + #[arg(short = 'f', long, env = "SOCKET_FORCE", default_value_t = false)] + pub force: bool, + + /// Undo vendoring: restore the recorded original lockfile fragments and + /// remove the `.socket/vendor/` artifacts. Works without a manifest. + #[arg( + long = "revert", + env = "SOCKET_VENDOR_REVERT", + default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), + )] + pub revert: bool, + + /// On a successful vendor, also generate an OpenVEX 0.2.0 document + /// (same contract as `apply --vex`). + #[command(flatten)] + pub vex: VexEmbedArgs, +} + +/// Refusal codes that are expected skips, not command failures: the user's +/// request is still fully satisfied when these are the only non-successes. +fn refusal_is_benign(code: &str) -> bool { + matches!(code, "vendor_unsupported_ecosystem" | "already_vendored") +} + +/// Dispatch one purl to its ecosystem backend. `pkg_path` is the crawler's +/// installed location (site-packages root for pypi, the package dir +/// otherwise). Returns `None` for purls with no vendor backend in this build. +#[allow(clippy::too_many_arguments)] +async fn dispatch_vendor_one( + purl: &str, + pkg_path: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, +) -> Option { + let eco = ecosystem_dir_for_purl(purl)?; + Some(match eco { + "npm" => { + socket_patch_core::patch::vendor::npm_lock::vendor_npm( + purl, + pkg_path, + project_root, + record, + sources, + vendored_at, + dry_run, + force, + ) + .await + } + "pypi" => { + socket_patch_core::patch::vendor::pypi::vendor_pypi( + purl, + pkg_path, + project_root, + record, + sources, + vendored_at, + dry_run, + force, + ) + .await + } + "gem" => { + socket_patch_core::patch::vendor::gem::vendor_gem( + purl, + pkg_path, + project_root, + record, + sources, + vendored_at, + dry_run, + force, + ) + .await + } + #[cfg(feature = "cargo")] + "cargo" => { + socket_patch_core::patch::vendor::cargo::vendor_cargo_crate( + purl, + pkg_path, + project_root, + record, + sources, + vendored_at, + dry_run, + force, + ) + .await + } + #[cfg(feature = "golang")] + "golang" => { + socket_patch_core::patch::vendor::golang::vendor_go_module( + purl, + pkg_path, + project_root, + record, + sources, + vendored_at, + dry_run, + force, + ) + .await + } + #[cfg(feature = "composer")] + "composer" => { + socket_patch_core::patch::vendor::composer_lock::vendor_composer( + purl, + pkg_path, + project_root, + record, + sources, + vendored_at, + dry_run, + force, + ) + .await + } + _ => return None, + }) +} + +/// Dispatch one recorded entry to its ecosystem's revert. +async fn dispatch_revert_one( + entry: &VendorEntry, + project_root: &Path, + dry_run: bool, +) -> RevertOutcome { + match entry.ecosystem.as_str() { + "npm" => { + socket_patch_core::patch::vendor::npm_lock::revert_npm(entry, project_root, dry_run) + .await + } + "pypi" => { + socket_patch_core::patch::vendor::pypi::revert_pypi(entry, project_root, dry_run).await + } + "gem" => { + socket_patch_core::patch::vendor::gem::revert_gem(entry, project_root, dry_run).await + } + #[cfg(feature = "cargo")] + "cargo" => { + socket_patch_core::patch::vendor::cargo::revert_cargo_vendor( + entry, + project_root, + dry_run, + ) + .await + } + #[cfg(feature = "golang")] + "golang" => { + socket_patch_core::patch::vendor::golang::revert_go_vendor(entry, project_root, dry_run) + .await + } + #[cfg(feature = "composer")] + "composer" => { + socket_patch_core::patch::vendor::composer_lock::revert_composer( + entry, + project_root, + dry_run, + ) + .await + } + other => RevertOutcome::failed(format!( + "this build has no vendor backend for ecosystem `{other}`" + )), + } +} + +/// Surface a backend warning: stderr line for humans, a Skipped event with +/// the stable code for JSON consumers (Skipped never flips the status). +fn record_warning(env: &mut Envelope, purl: &str, warning: &VendorWarning, common: &GlobalArgs) { + if !common.silent && !common.json { + eprintln!("Warning ({}): {}", warning.code, warning.detail); + } + env.record( + PatchEvent::new(PatchAction::Skipped, purl.to_string()) + .with_reason(warning.code, warning.detail.clone()), + ); +} + +pub async fn run(args: VendorArgs) -> i32 { + apply_env_toggles(&args.common); + let (telemetry_client, _) = + get_api_client_with_overrides(args.common.api_client_overrides()).await; + let api_token = telemetry_client.api_token().cloned(); + let org_slug = telemetry_client.org_slug().cloned(); + + let manifest_path = args.common.resolved_manifest_path(); + let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")).to_path_buf(); + + // `--revert` derives everything from state.json + the vendor tree; it + // must work after the manifest was deleted. Plain vendor needs the + // manifest and exits clean without one (same contract as apply). + if !args.revert && tokio::fs::metadata(&manifest_path).await.is_err() { + if args.common.json { + let mut env = Envelope::new(Command::Vendor); + env.status = Status::NoManifest; + env.dry_run = args.common.dry_run; + println!("{}", env.to_pretty_json()); + } else if !args.common.silent { + println!("No .socket folder found, nothing to vendor."); + } + return 0; + } + + // Same lock as apply/rollback: vendor mutates the same lockfiles and + // `.socket/` tree, so a separate lock would allow an apply↔vendor race. + let acquired = match acquire_or_emit( + &socket_dir, + Command::Vendor, + args.common.json, + args.common.silent, + args.common.dry_run, + Duration::from_secs(args.common.lock_timeout.unwrap_or(0)), + args.common.break_lock, + ) { + Ok(acquired) => acquired, + Err(code) => return code, + }; + let _lock = acquired.guard; + let lock_was_broken = acquired.broke_lock; + + let mut env = Envelope::new(Command::Vendor); + env.dry_run = args.common.dry_run; + if lock_was_broken { + env.record(lock_broken_event(&socket_dir)); + } + + let exit = if args.revert { + run_revert(&args, &mut env).await + } else { + run_vendor(&args, &manifest_path, &mut env).await + }; + + // Embedded VEX: same contract as `apply --vex` — only on success, and a + // requested-but-failed VEX flips the exit code. + let mut exit = exit; + if exit == 0 && !args.revert && args.vex.vex.is_some() { + let params = args.vex.to_build_params(); + match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { + Ok(summary) => { + env.vex = Some(VexSummary { + path: args.vex.vex.as_ref().unwrap().display().to_string(), + statements: summary.statements, + format: "openvex-0.2.0".to_string(), + }); + } + Err(e) => { + env.mark_error(EnvelopeError::new(e.code, e.message.clone())); + exit = 1; + } + } + } + + if args.common.json { + println!("{}", env.to_pretty_json()); + } + + if !args.revert { + if exit == 0 { + track_patch_vendored( + env.summary.applied, + args.common.dry_run, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; + } else { + track_patch_vendor_failed( + "vendor completed with failures", + args.common.dry_run, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; + } + } + + exit +} + +async fn run_vendor(args: &VendorArgs, manifest_path: &Path, env: &mut Envelope) -> i32 { + let common = &args.common; + let manifest = match read_manifest(manifest_path).await { + Ok(Some(m)) => m, + Ok(None) => return 0, // vanished since the existence check (TOCTOU) + Err(e) => { + env.mark_error(EnvelopeError::new("invalid_manifest", e.to_string())); + if !common.json && !common.silent { + eprintln!("Error: could not read manifest: {e}"); + } + return 1; + } + }; + + // Reconcile first (mirrors apply's placement): entries vendored by a + // previous run whose patches were dropped from the manifest are reverted + // even when zero in-scope patches remain. + let mut has_errors = reconcile_dropped(&manifest, common, env).await; + + let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); + let staged = match stage_patch_sources(common, &manifest, socket_dir).await { + Ok(StageOutcome::Ready(s)) => s, + Ok(StageOutcome::Unavailable) => { + env.mark_error(EnvelopeError::new( + "no_local_source", + "patch artifacts unavailable (offline or download failure)", + )); + return 1; + } + Err(e) => { + env.mark_error(EnvelopeError::new("stage_failed", e)); + return 1; + } + }; + let sources = staged.as_patch_sources(); + + let manifest_purls: Vec = manifest.patches.keys().cloned().collect(); + let partitioned = partition_purls(&manifest_purls, common.ecosystems.as_deref()); + let target_manifest_purls: HashSet = partitioned + .values() + .flat_map(|p| p.iter().cloned()) + .collect(); + + // Purls with no vendor backend (maven/nuget/jsr, or compiled-out + // ecosystems) are expected skips, not failures. + let (vendorable, unsupported): (Vec, Vec) = target_manifest_purls + .iter() + .cloned() + .partition(|p| vendor::is_vendorable(p)); + for purl in &unsupported { + env.record( + PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( + "vendor_unsupported_ecosystem", + "vendoring is not supported for this ecosystem", + ), + ); + } + + if vendorable.is_empty() { + if !common.json && !common.silent { + println!("No vendorable patches in scope."); + } + return i32::from(has_errors); + } + + // npm layout gate: vendor rewrites package-lock.json semantics only. + // yarn/pnpm/bun each have a native first-class patch flow; refuse their + // npm purls per-purl so other ecosystems still vendor. + let pkg_manager = detect_npm_pkg_manager(&common.cwd); + let npm_manager_refusal: Option<&str> = match pkg_manager { + NpmPkgManager::YarnBerryPnP | NpmPkgManager::YarnClassic => Some("yarn patch "), + NpmPkgManager::Pnpm => Some("pnpm patch "), + NpmPkgManager::Bun => Some("bun patch "), + _ => None, + }; + + let vendorable_partition: HashMap> = partitioned + .into_iter() + .map(|(eco, purls)| { + ( + eco, + purls.into_iter().filter(|p| vendor::is_vendorable(p)).collect(), + ) + }) + .collect(); + + let crawler_options = CrawlerOptions { + cwd: common.cwd.clone(), + global: common.global, + global_prefix: common.global_prefix.clone(), + batch_size: 100, + }; + let all_packages = find_packages_for_purls( + &vendorable_partition, + &crawler_options, + common.silent || common.json, + ) + .await; + + let vendored_at = now_rfc3339(); + let mut state = match load_state(&common.cwd).await { + Ok(s) => s, + Err(e) => { + env.mark_error(EnvelopeError::new("vendor_state_unreadable", e.to_string())); + return 1; + } + }; + + // Release-variant grouping (pypi `?artifact_id=`, gem `?platform=`): + // the crawler emits base purls; match the manifest's qualified variants + // against the installed distribution via the first-file probe. + let mut variant_groups: HashMap> = HashMap::new(); + for purl in &vendorable { + if Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()) { + variant_groups + .entry(strip_purl_qualifiers(purl).to_string()) + .or_default() + .push(purl.clone()); + } + } + + let mut matched: HashSet = HashSet::new(); + let mut handled_bases: HashSet = HashSet::new(); + + for (purl, pkg_path) in &all_packages { + let is_variant_eco = + Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()); + let candidates: Vec = if is_variant_eco { + let base = strip_purl_qualifiers(purl).to_string(); + if !handled_bases.insert(base.clone()) { + continue; + } + variant_groups.get(&base).cloned().unwrap_or_else(|| vec![base]) + } else { + vec![purl.clone()] + }; + + for candidate in &candidates { + let Some(record) = manifest.patches.get(candidate) else { + continue; + }; + + // Variant probe: only the installed distribution's variant is + // vendored (mirrors apply / select_installed_variants). + if is_variant_eco && !args.force { + let first = match record.files.iter().next() { + Some((f, info)) => Some(verify_file_patch(pkg_path, f, info).await.status), + None => None, + }; + if !variant_matches_installed(first.as_ref()) { + continue; + } + } + matched.insert(candidate.clone()); + + // npm layout refusal. + if ecosystem_dir_for_purl(candidate) == Some("npm") { + if let Some(native) = npm_manager_refusal { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( + "vendor_pkg_manager_unsupported", + format!( + "this project uses {pkg_manager:?}; socket-patch vendor only rewrites package-lock.json — use `{native}` instead" + ), + ), + ); + continue; + } + } + + let outcome = dispatch_vendor_one( + candidate, + pkg_path, + &common.cwd, + record, + &sources, + &vendored_at, + common.dry_run, + args.force, + ) + .await; + + match outcome { + None => { + env.record( + PatchEvent::new(PatchAction::Skipped, candidate.clone()).with_reason( + "vendor_unsupported_ecosystem", + "vendoring is not supported for this ecosystem", + ), + ); + } + Some(VendorOutcome::Refused { code, detail }) => { + if refusal_is_benign(code) { + env.record( + PatchEvent::new(PatchAction::Skipped, candidate.clone()) + .with_reason(code, detail.clone()), + ); + } else { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()) + .with_error(code, detail.clone()), + ); + } + if !common.silent && !common.json { + eprintln!("Cannot vendor {candidate}: {detail}"); + } + } + Some(VendorOutcome::Done { + result, + entry, + warnings, + }) => { + if !result.success { + has_errors = true; + if !common.silent && !common.json { + eprintln!( + "Failed to vendor {}: {}", + candidate, + result.error.as_deref().unwrap_or("unknown error") + ); + } + } + env.record(result_to_event(&result, common.dry_run)); + for w in &warnings { + record_warning(env, candidate, w, common); + } + if let Some(entry) = entry { + state.entries.insert(candidate.clone(), entry); + // Persist per-package so a crash mid-run leaves a + // ledger that matches what's already wired. + if let Err(e) = save_state(&common.cwd, &state).await { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()) + .with_error("vendor_state_write_failed", e.to_string()), + ); + } + } + } + } + } + } + + // Manifest entries that targeted in-scope ecosystems but had no + // installed package on disk. + let mut unmatched: Vec = vendorable + .iter() + .filter(|p| !matched.contains(*p)) + .cloned() + .collect(); + unmatched.sort(); + // A base that vendored one variant accounts for its qualified siblings. + let vendored_bases: HashSet = matched + .iter() + .map(|p| strip_purl_qualifiers(p).to_string()) + .collect(); + unmatched.retain(|p| !vendored_bases.contains(strip_purl_qualifiers(p))); + if !unmatched.is_empty() { + has_errors = true; + for purl in &unmatched { + env.record( + PatchEvent::new(PatchAction::Skipped, purl.clone()) + .with_reason("package_not_installed", "no installed package found"), + ); + if !common.silent && !common.json { + eprintln!("Cannot vendor {purl}: package not installed"); + } + } + } + + if !common.json && !common.silent { + let verb = if common.dry_run { "Would vendor" } else { "Vendored" }; + println!( + "{verb} {} package(s); {} skipped; {} failed.", + env.summary.applied, env.summary.skipped, env.summary.failed + ); + if env.summary.applied > 0 && !common.dry_run { + println!("Commit .socket/vendor/ and the updated lockfiles to make the patches portable."); + } + } + + if has_errors { + env.mark_partial_failure(); + 1 + } else { + 0 + } +} + +/// Revert vendored entries whose patches were dropped from the manifest. +async fn reconcile_dropped( + manifest: &PatchManifest, + common: &GlobalArgs, + env: &mut Envelope, +) -> bool { + let mut state = match load_state(&common.cwd).await { + Ok(s) => s, + Err(_) => return false, // unreadable state is reported by the main path + }; + let stale: Vec = state + .entries + .iter() + .filter(|(purl, entry)| { + !manifest.patches.contains_key(*purl) + && !manifest.patches.contains_key(&entry.base_purl) + }) + .map(|(purl, _)| purl.clone()) + .collect(); + let mut had_error = false; + for purl in stale { + let entry = state.entries.get(&purl).cloned().expect("listed above"); + let outcome = dispatch_revert_one(&entry, &common.cwd, common.dry_run).await; + for w in &outcome.warnings { + record_warning(env, &purl, w, common); + } + if outcome.success { + env.record( + PatchEvent::new(PatchAction::Removed, purl.clone()) + .with_reason("vendor_reconciled", "patch no longer in manifest"), + ); + if !common.dry_run { + state.entries.remove(&purl); + } + } else { + had_error = true; + env.record( + PatchEvent::new(PatchAction::Failed, purl.clone()).with_error( + "revert_failed", + outcome.error.unwrap_or_else(|| "unknown error".into()), + ), + ); + } + } + if !common.dry_run { + let _ = save_state(&common.cwd, &state).await; + } + had_error +} + +async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { + let common = &args.common; + let mut state = match load_state(&common.cwd).await { + Ok(s) => s, + Err(e) => { + env.mark_error(EnvelopeError::new("vendor_state_unreadable", e.to_string())); + if !common.json && !common.silent { + eprintln!("Error: could not read .socket/vendor/state.json: {e}"); + } + return 1; + } + }; + + let mut has_errors = false; + let recorded: Vec = { + let mut keys: Vec = state.entries.keys().cloned().collect(); + keys.sort(); + keys + }; + + for purl in &recorded { + let entry = state.entries.get(purl).cloned().expect("key listed above"); + let outcome = dispatch_revert_one(&entry, &common.cwd, common.dry_run).await; + for w in &outcome.warnings { + record_warning(env, purl, w, common); + } + if outcome.success { + env.record(PatchEvent::new(PatchAction::Removed, purl.clone())); + if !common.dry_run { + state.entries.remove(purl); + if let Err(e) = save_state(&common.cwd, &state).await { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, purl.clone()) + .with_error("vendor_state_write_failed", e.to_string()), + ); + } + } + } else { + has_errors = true; + env.record(PatchEvent::new(PatchAction::Failed, purl.clone()).with_error( + "revert_failed", + outcome.error.unwrap_or_else(|| "unknown error".into()), + )); + if !common.silent && !common.json { + eprintln!("Failed to revert {purl}"); + } + } + } + + // Orphan sweep: uuid dirs on disk with no ledger entry (a hand-edited + // state file, or artifacts left by an interrupted run). The lockfile + // wiring for these is already gone or owned by a recorded entry, so + // removal is safe; unparseable dirs are reported, never deleted. + let swept = vendor::path::sweep_vendor_dirs(&common.cwd).await; + let recorded_uuids: HashSet<&str> = state.entries.values().map(|e| e.uuid.as_str()).collect(); + for unit in swept { + if recorded_uuids.contains(unit.uuid.as_str()) { + continue; + } + if !common.dry_run { + let _ = remove_tree(&unit.dir).await; + } + let label = unit + .purls + .first() + .cloned() + .unwrap_or_else(|| format!("{}/{}", unit.eco, unit.uuid)); + env.record( + PatchEvent::new(PatchAction::Removed, label) + .with_reason("vendor_orphan_removed", "vendored dir had no ledger entry"), + ); + } + + if env.events.is_empty() { + if !common.json && !common.silent { + println!("Nothing vendored to revert."); + } + return 0; + } + + if !common.json && !common.silent { + let verb = if common.dry_run { "Would revert" } else { "Reverted" }; + println!( + "{verb} {} vendored package(s); {} failed.", + env.summary.removed, env.summary.failed + ); + } + + if has_errors { + env.mark_partial_failure(); + 1 + } else { + 0 + } +} diff --git a/crates/socket-patch-core/src/utils/telemetry.rs b/crates/socket-patch-core/src/utils/telemetry.rs index b36a590..b3e908a 100644 --- a/crates/socket-patch-core/src/utils/telemetry.rs +++ b/crates/socket-patch-core/src/utils/telemetry.rs @@ -39,6 +39,9 @@ pub enum PatchTelemetryEventType { PatchScanFailed, PatchFetched, PatchFetchFailed, + // Write-side: vendor + PatchVendored, + PatchVendorFailed, // Inspection / housekeeping PatchListed, PatchRepaired, @@ -58,6 +61,8 @@ impl PatchTelemetryEventType { Self::PatchApplied => "patch_applied", Self::PatchApplyFailed => "patch_apply_failed", Self::PatchRemoved => "patch_removed", + Self::PatchVendored => "patch_vendored", + Self::PatchVendorFailed => "patch_vendor_failed", Self::PatchRemoveFailed => "patch_remove_failed", Self::PatchRolledBack => "patch_rolled_back", Self::PatchRollbackFailed => "patch_rollback_failed", @@ -459,6 +464,42 @@ pub async fn track_patch_apply_failed( .await; } +/// Track a successful vendor run (count = packages vendored). +pub async fn track_patch_vendored( + vendored_count: u32, + dry_run: bool, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + fire( + PatchTelemetryEventType::PatchVendored, + "vendor", + serde_json::json!({ "patches_count": vendored_count, "dry_run": dry_run }), + None::<&str>, + api_token, + org_slug, + ) + .await; +} + +/// Track a failed vendor run. +pub async fn track_patch_vendor_failed( + error: impl std::fmt::Display, + dry_run: bool, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + fire( + PatchTelemetryEventType::PatchVendorFailed, + "vendor", + serde_json::json!({ "dry_run": dry_run }), + Some(error), + api_token, + org_slug, + ) + .await; +} + /// Track a successful patch removal. pub async fn track_patch_removed( removed_count: usize, From ed7df81e7cba953290df093e35abc988301530a3 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 20:18:36 -0400 Subject: [PATCH 11/31] docs: vendor command contract section + CHANGELOG entries Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 35 ++++++++ crates/socket-patch-cli/CLI_CONTRACT.md | 105 ++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fc736..db5a58a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,41 @@ in this file — see `.github/workflows/release.yml` (`version` job). ### Added +- **New `vendor` subcommand: committable vendoring of patched dependencies.** + Where `apply` patches installed packages in place (machine-local state), + `socket-patch vendor` ejects each patched package into a committed + `.socket/vendor///` and rewires the + ecosystem's lockfile so the project consumes the vendored copy — after + committing, a fresh checkout builds with the patched dependency on machines + with no socket-patch installed and no Socket API access. Per ecosystem + (each mechanism validated against the real package manager): npm rewrites + `package-lock.json` only (deterministic patched tarball, recomputed + integrity, `npm ci`-verified); cargo writes a `[patch.crates-io]` entry in + `.cargo/config.toml` plus surgical Cargo.lock edits so `cargo build + --locked --offline` works; golang reuses the `replace`-directive engine + pointed at the vendor tree; composer rewrites the lock entry to a + `dist: path` copy; gem edits the Gemfile + Gemfile.lock pair in bundler's + canonical form; pypi rebuilds a valid wheel (regenerated RECORD) wired + through uv's `pyproject.toml`/`uv.lock` pair (uv-first) or + requirements.txt (`pip` / `uv pip`). The patch UUID is recoverable from the + lockfile path string alone (a documented convention for external tools), a + committed `.socket/vendor/state.json` ledger records the verbatim original + lockfile fragments, and `vendor --revert` restores them byte-exactly. + `vendor --vex` mirrors `apply --vex`; VEX generation attests vendored + patches by hashing the committed artifacts, and `apply` yields ownership of + vendored packages (`vendored` skip reason). + +### Fixed + +- **VEX now attests Go `replace`-redirect patches.** `socket-patch vex` + previously verified golang patches against the pristine module cache + instead of the patched `.socket/go-patches/` copy, so redirect-applied + patches were silently omitted from the document (reported `not_applied`, + or `package_not_found` on cache-less CI). Verification now follows the + managed `replace` directive to the committed copy. + +### Added (pre-existing unreleased entries) + - **Cargo support (`cargo` is now a default feature).** `apply` patches a Rust dependency **in place** wherever the crawler finds it — the project `vendor/` directory or the shared `$CARGO_HOME` registry cache — rewriting the crate's diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index cde2d18..1fcf18e 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -16,6 +16,7 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth | `remove` | — | Remove patch from manifest (rolls back first); requires positional `identifier` | | `setup` | — | Wire automatic-patching install hooks (npm/pypi/gem) | | `repair` | `gc` | Download missing blobs + clean up unused ones | +| `vendor` | — | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles | | `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest | **Bare-UUID fallback.** `socket-patch ` is rewritten to `socket-patch get `. The UUID shape checked is the standard 8-4-4-4-12 hex pattern (case-insensitive). See [`src/lib.rs::looks_like_uuid`](src/lib.rs). @@ -54,8 +55,10 @@ Beyond the globals above, each subcommand defines a small set of local arguments | Subcommand | Local arg | Env var | Purpose | |---|---|---|---| | `apply` | `--force` / `-f` | `SOCKET_FORCE` | Bypass beforeHash check | -| `apply`, `scan` | `--vex` | `SOCKET_VEX` | Generate an OpenVEX 0.2.0 document at this path on a successful run; see "embedded VEX" below | -| `apply`, `scan` | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | Passthrough to the embedded VEX builder; mirror the standalone `vex` knobs. Inert unless `--vex` is set | +| `vendor` | `--force` / `-f` | `SOCKET_FORCE` | Bypass beforeHash check when staging the vendored copy | +| `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest | +| `apply`, `scan`, `vendor` | `--vex` | `SOCKET_VEX` | Generate an OpenVEX 0.2.0 document at this path on a successful run; see "embedded VEX" below | +| `apply`, `scan`, `vendor` | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | Passthrough to the embedded VEX builder; mirror the standalone `vex` knobs. Inert unless `--vex` is set | | `scan` | `--apply` / `--prune` / `--sync` | — | Mode selectors (sync = apply + prune) | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size (default `100`) | | `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + save-vs-apply mode | @@ -75,9 +78,9 @@ Beyond the globals above, each subcommand defines a small set of local arguments The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. -### Embedded VEX (`apply --vex` / `scan --vex`) +### Embedded VEX (`apply --vex` / `scan --vex` / `vendor --vex`) -`--vex ` folds OpenVEX 0.2.0 generation into `apply` and `scan`: 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. +`--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. Contract details: @@ -137,8 +140,10 @@ in particular, are behavior changes that gate a version bump when implemented). the clone with no re-run required. *(Implemented; a consequence of properties 5 + 1.)* 7. **Reflected in VEX.** A patch contributes a `not_affected` statement to the repo's OpenVEX document - only for ecosystems that are **actually set up** — or explicitly declared **manual** (below). Patches - for an ecosystem that is neither set up nor declared manual produce no VEX statement. *(Implemented — + only for ecosystems that are **actually set up** — or explicitly declared **manual** (below) — or + **vendored** (a `socket-patch vendor`ed package needs no install hook by construction: the package + manager itself installs the patched artifact, so its purls bypass this filter). Patches for an + ecosystem that is neither set up, declared manual, nor vendored produce no VEX statement. *(Implemented — `generate_vex` filters `applied` to ecosystems returned by `commands/setup::configured_ecosystems` (on-disk hook presence) ∪ the manifest's `setup.manual`, in addition to the existing `--ecosystems` filter and on-disk verification. Applies in both verify and `--no-verify` modes.)* @@ -299,6 +304,84 @@ and none errored): `no_files` and `not_configured`); `1` on any per-file error, partial failure, or — for `--check` — any manifest that needs configuration. `setup --check --remove` is a clap usage error (exit `2`). +## Vendor command contract + +`vendor` is `apply`'s committable sibling: instead of patching installed packages in place +(machine-local state), it ejects each patched package into `.socket/vendor/` and rewires the +ecosystem's lockfile/config so the project consumes the vendored copy. After committing +`.socket/vendor/` + the lockfile edits, a fresh checkout builds with the patched dependency on +machines with **no socket-patch installed and no Socket API access** (registry access for other, +unvendored dependencies may still be needed). Every mechanism below was validated against the real +package managers (`spikes/PHASE0-FINDINGS.txt`). + +### Path convention + patch-UUID recovery (stable) + +```text +.socket/vendor/// +``` + +The full 36-char lowercase hyphenated patch UUID is a dedicated path level, so it appears verbatim +in every lockfile-visible path string. External tools recover "this dependency is Socket-vendored, +by patch ``" from the lockfile alone with this rule (no access to `.socket/` needed): + +```text +(?:file:)?(?:\./)?\.socket[/\\]vendor[/\\](npm|cargo|golang|composer|gem|pypi)[/\\]([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})[/\\](.+) +``` + +Updating a patch changes the UUID → changes the path → changes the lockfile, so staleness is +diffable by construction. Each vendored unit also carries an informational +`socket-patch.vendor.json` marker (`{schemaVersion, purl, patchUuid, ecosystem, vulnerabilities, +vendoredAt}`) next to the artifact — belt-and-braces for tools that have the tree but not the +lockfile; never a trust input. + +### Per-ecosystem wiring matrix + +| eco | vendored artifact | committed wiring | consumption proof | +|---|---|---|---| +| npm | deterministic patched tarball `[@scope/]-.tgz` | `package-lock.json` only (`npm-shrinkwrap.json` wins when present): every entry matching name+version gets `resolved: "file:…"` + recomputed `integrity`. `package.json` untouched | `npm ci` (integrity-verified). Plain `npm install` preserves the entry; `npm update ` re-resolves and drops it | +| cargo | crate dir `-/` (no `.cargo-checksum.json`) | `.cargo/config.toml` `[patch.crates-io]` path entry **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed) | `cargo build --locked --offline` on a fresh checkout. Requires cargo ≥ 1.56 (`[patch]` in config files). Note: path deps build **without** `--cap-lints allow` | +| golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | +| composer | package dir `/@/` | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: null}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). `composer update ` reverts it | +| gem | gem dir `-/` + gemspec materialized from `specifications/` | **Gemfile + Gemfile.lock pair**: the `gem` line gains `path:` (or a managed block for transitive deps); the lock's spec block moves GEM→PATH and the DEPENDENCIES entry becomes ` (= )!`, in bundler's exact canonical form | `bundle install` (normal **and** `BUNDLE_FROZEN=true`), byte-stable lock. Lock-only edits are a silent unpatch — hence the mandatory pair | +| pypi | rebuilt wheel (canonical PEP 427 filename; RECORD regenerated correctly) | **uv projects** (uv.lock present): `[tool.uv.sources] = {path}` in pyproject + surgical uv.lock rewrite; transitive deps via `[tool.uv] override-dependencies`. **requirements.txt** (pip / `uv pip`): pin line → `./ --hash=sha256:` (markers carried over; transitive deps appended) | `uv sync --locked` / `--frozen --offline` (hash-verified, byte-stable lock); `pip install -r` / `uv pip install -r` **run from the project root** (both resolve bare paths against the CWD) | + +Unsupported in this build (maven/nuget/jsr, compiled-out ecosystems, poetry/pdm/pipenv pyproject +flavors, yarn/pnpm/bun npm layouts) refuse per-purl with stable reason codes pointing at the native +alternative (`yarn|pnpm|bun patch`, the `.pth` setup hook, …). + +### Ownership, state, and reversal + +* `.socket/vendor/state.json` (committed) is the revert ledger: every wiring edit records the + **verbatim original** lockfile fragment it replaced (registry URLs, integrity strings, Cargo.lock + `source`/`checksum`, requirement lines, uv specifiers). Those are not recoverable offline, so + `--revert` without the ledger fails with `vendor_state_missing` rather than guessing. +* `vendor --revert` restores the originals (fragments that no longer match — a user re-resolved — + are left alone with a `vendor_lock_entry_drifted` warning), removes the artifacts, prunes the + ledger, and sweeps orphan uuid dirs. It works without a manifest. +* Re-running `vendor` is idempotent (byte-stable lockfiles, deterministic artifacts → + `already_vendored` skips). Patches dropped from the manifest are auto-reverted at the start of + the next `vendor` run (`vendor_reconciled` events). +* `rollback` and `remove` are **vendoring-unaware by design**: `remove ` deletes the manifest + entry but the vendoring stays until the next `vendor` run reconciles it (or `--revert`). +* **apply yields to vendor**: a purl recorded in the ledger is skipped by `apply` with reason + `vendored` (golang especially — apply never repoints a vendor-owned `replace` back at + `.socket/go-patches/`), and `apply --check` excludes vendored modules from its drift audit. + +### Caveats (documented behavior, not bugs) + +* npm: a **warm local npm cache** can satisfy `npm ci` by integrity even when the vendored tarball + is deleted or corrupted on disk — the lockfile integrity, not the file, is the source of truth. + Fresh checkouts (the committable guarantee) fail closed. Never reuse a stale registry integrity: + recomputation is mandatory and enforced by the implementation. +* npm redacts uuid-like path segments as `***` in its own error output (its secret heuristic); + the path on disk and in the lockfile is unaffected. +* cargo: invoking cargo from **outside** the project root skips `.cargo/config.toml` discovery and + an unlocked build will silently re-lock to the registry crate. CI should build with `--locked`. +* pip/`uv pip`: bare relative requirement paths resolve against the invoking process's CWD; run + installs from the project root. +* `vendor` exits like `apply`: 0 on success (benign skips included), 1 on any refusal/failure + (`partialFailure`), 2 on usage errors. `--dry-run` verifies and writes nothing. + ## Environment variables All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names are still honored at runtime for compatibility: on first read of any of the three the binary emits a one-shot deprecation warning to stderr (the warning fires unconditionally — even under `--silent` / `--json` — because it's a transition signal users need to see). The legacy names will be removed in the next major release. @@ -423,6 +506,13 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `paid_required` | `failed` / status=`paidRequired` | get/scan: patch needs a paid plan and the caller's token isn't entitled. | | `download_failed` | `failed` | repair/get: network or 404 on patch fetch. | | `rollback_failed` | `failed` | remove/rollback: file restore could not complete. | +| `vendored` | `skipped` | apply: the package is managed by `socket-patch vendor`; apply yields ownership. | +| `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (maven/nuget/jsr, or compiled out). | +| `already_vendored` | `skipped` | vendor: artifact + wiring already in sync for this patch uuid. | +| `vendor_pkg_manager_unsupported` | `failed` | vendor (npm): project uses yarn/pnpm/bun — use the manager's native patch flow. | +| `unsafe_coordinates` | `failed` | vendor: purl/uuid would escape `.socket/vendor/` (tampered manifest/state); refused before any write. | +| `revert_failed` | `failed` | vendor --revert: a recorded entry could not be reverted. | +| `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | ### Top-level `EnvelopeError` codes @@ -439,7 +529,8 @@ Every `--json` invocation emits a single JSON object that follows the **unified | Subcommand | Emits | |--------------|---| -| `apply` | `Applied` · `Updated` · `Skipped` (already_patched / package_not_installed) · `Failed` · `Verified` (dry-run) | +| `apply` | `Applied` · `Updated` · `Skipped` (already_patched / package_not_installed / vendored) · `Failed` · `Verified` (dry-run) | +| `vendor` | `Applied` (= vendored; `command` routes) · `Skipped` (refusals, warnings, unsupported ecosystems) · `Failed` · `Removed` (reconcile + `--revert`) · `Verified` (dry-run) | | `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`) | | `repair`/`gc`| `Downloaded` (or `Verified` on dry-run) · `Removed` (or `Verified`) · `Failed` artifact events | | `remove` | `Removed` (per purl) · artifact-level `Removed` event (with `details.blobsRemoved`, `details.rolledBack`) | From 28030b5c8e62e0c67bc87e6cb2acf9b856aa958b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 20:18:50 -0400 Subject: [PATCH 12/31] docs: single Added section in CHANGELOG Unreleased Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db5a58a..52605e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,16 +40,6 @@ in this file — see `.github/workflows/release.yml` (`version` job). patches by hashing the committed artifacts, and `apply` yields ownership of vendored packages (`vendored` skip reason). -### Fixed - -- **VEX now attests Go `replace`-redirect patches.** `socket-patch vex` - previously verified golang patches against the pristine module cache - instead of the patched `.socket/go-patches/` copy, so redirect-applied - patches were silently omitted from the document (reported `not_applied`, - or `package_not_found` on cache-less CI). Verification now follows the - managed `replace` directive to the committed copy. - -### Added (pre-existing unreleased entries) - **Cargo support (`cargo` is now a default feature).** `apply` patches a Rust dependency **in place** wherever the crawler finds it — the project `vendor/` @@ -89,6 +79,15 @@ and regression tests were added throughout (the lib + integration suites grow by ~10k lines of mostly tests). The audit harness used to drive the review lives in `scripts/study-crates.ts`. +### Fixed + +- **VEX now attests Go `replace`-redirect patches.** `socket-patch vex` + previously verified golang patches against the pristine module cache + instead of the patched `.socket/go-patches/` copy, so redirect-applied + patches were silently omitted from the document (reported `not_applied`, + or `package_not_found` on cache-less CI). Verification now follows the + managed `replace` directive to the committed copy. + ### Security - **Path-traversal in archive extraction.** `read_archive_to_map` From 8df9beb876b8bf52577c054ee7bd0497572d8ab6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 20:19:27 -0400 Subject: [PATCH 13/31] docs: place VEX golang fix under Unreleased, not 3.2.0 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52605e8..2c8b868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,14 +71,6 @@ in this file — see `.github/workflows/release.yml` (`version` job). command exit non-zero even when the apply/scan itself succeeded, surfacing a stable error code in the envelope. -## [3.2.0] — 2026-05-29 - -A repo-wide correctness, security, and filesystem-safety hardening pass: every -source file in both crates was reviewed line by line, the bugs found were fixed, -and regression tests were added throughout (the lib + integration suites grow by -~10k lines of mostly tests). The audit harness used to drive the review lives in -`scripts/study-crates.ts`. - ### Fixed - **VEX now attests Go `replace`-redirect patches.** `socket-patch vex` @@ -88,6 +80,14 @@ and regression tests were added throughout (the lib + integration suites grow by or `package_not_found` on cache-less CI). Verification now follows the managed `replace` directive to the committed copy. +## [3.2.0] — 2026-05-29 + +A repo-wide correctness, security, and filesystem-safety hardening pass: every +source file in both crates was reviewed line by line, the bugs found were fixed, +and regression tests were added throughout (the lib + integration suites grow by +~10k lines of mostly tests). The audit harness used to drive the review lives in +`scripts/study-crates.ts`. + ### Security - **Path-traversal in archive extraction.** `read_archive_to_map` From 54561300cef3c416b4a3c672bef072a2cf1a3b64 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 20:43:43 -0400 Subject: [PATCH 14/31] feat(vendor): all six ecosystem backends + CLI wiring + VEX integration Backends (each spike-validated against the real package manager): - npm: deterministic patched tarball + package-lock.json resolved/integrity rewrite (npm ci-verified; nested instances; v2 legacy mirror) - cargo: resurrected [patch.crates-io] config engine + new Cargo.lock source/checksum surgery (cargo build --locked --offline proven) - golang: ReplaceOwner::Vendor wrapper over the redirect engine with go-patches takeover - composer: lock-only dist->path surgery (content-hash unaffected) - gem: Gemfile + Gemfile.lock pair edit in bundler's byte-exact canonical form - pypi (uv-first): RECORD-driven wheel rebuild; uv pyproject+uv.lock pair surgery byte-matching uv's own serializer (override-dependencies for transitive deps); requirements.txt wiring for pip/uv pip VEX: applied_patches_with_vendor verifies vendored artifacts file-level (no installed-tree fallback either direction), '(vendored)' impact phrasing, Property-7 exemption, and fixes the latent golang go-patches attestation hole. CLI: vendor + --revert pipelines wired (lock, staging, reconcile, variant probe, per-package state persistence, orphan sweep, --vex embed). 285 new backend/VEX tests; full sweep green: core 1239 (composer)/983 (no-default), CLI lib 272, all 90 integration binaries pass. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/commands/mod.rs | 1 + .../socket-patch-cli/src/commands/vendor.rs | 10 +- crates/socket-patch-cli/src/commands/vex.rs | 181 +- crates/socket-patch-cli/src/lib.rs | 5 + crates/socket-patch-cli/src/main.rs | 1 + .../socket-patch-cli/tests/cli_global_args.rs | 3 +- .../socket-patch-cli/tests/e2e_vex_vendor.rs | 439 ++++ .../socket-patch-core/src/patch/copy_tree.rs | 2 +- .../src/patch/vendor/cargo.rs | 1123 ++++++++++- .../src/patch/vendor/cargo_config.rs | 699 ++++++- .../src/patch/vendor/cargo_lock.rs | 457 ++++- .../src/patch/vendor/composer_lock.rs | 1101 +++++++++- .../socket-patch-core/src/patch/vendor/gem.rs | 1585 ++++++++++++++- .../src/patch/vendor/golang.rs | 748 ++++++- .../src/patch/vendor/npm_lock.rs | 1645 ++++++++++++++- .../src/patch/vendor/npm_pack.rs | 311 ++- .../src/patch/vendor/pypi.rs | 826 +++++++- .../src/patch/vendor/pypi_requirements.rs | 990 ++++++++- .../src/patch/vendor/pypi_uv.rs | 1784 ++++++++++++++++- .../src/patch/vendor/pypi_wheel.rs | 1147 ++++++++++- crates/socket-patch-core/src/vex/build.rs | 144 +- crates/socket-patch-core/src/vex/mod.rs | 6 +- crates/socket-patch-core/src/vex/verify.rs | 379 ++++ 23 files changed, 13560 insertions(+), 27 deletions(-) create mode 100644 crates/socket-patch-cli/tests/e2e_vex_vendor.rs diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 9e1a825..1fa97c9 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -9,4 +9,5 @@ pub mod rollback; pub mod scan; pub mod setup; pub mod unlock; +pub mod vendor; pub mod vex; diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 2e03521..d34afa6 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -557,7 +557,15 @@ async fn run_vendor(args: &VendorArgs, manifest_path: &Path, env: &mut Envelope) for w in &warnings { record_warning(env, candidate, w, common); } - if let Some(entry) = entry { + if let Some(mut entry) = entry { + // A re-vendor run re-derives the entry from current + // disk state, where the takeover already happened — + // preserve the prior flag or the revert-time + // "takeover_not_restored" hint is lost. + if let Some(prev) = state.entries.get(candidate) { + entry.took_over_go_patches = + entry.took_over_go_patches || prev.took_over_go_patches; + } state.entries.insert(candidate.clone(), entry); // Persist per-package so a crash mid-run leaves a // ledger that matches what's already wired. diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 575f587..49920fc 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -22,7 +22,8 @@ use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::utils::telemetry::{track_vex_failed, track_vex_generated}; use socket_patch_core::vex::{ - build_document, detect_product, BuildOptions, Document, FailedPatch, VerifyOutcome, + build_document_with_vendored, detect_product, BuildOptions, Document, FailedPatch, + VendorContext, VerifyOutcome, }; use crate::args::{apply_env_toggles, GlobalArgs}; @@ -290,17 +291,28 @@ pub(crate) async fn generate_vex( let mut outcome = if params.no_verify { VerifyOutcome { applied: manifest.patches.keys().cloned().collect(), - failed: Vec::new(), + ..Default::default() } } else { let package_paths = resolve_package_paths(common, manifest).await; - socket_patch_core::vex::applied_patches(manifest, &package_paths).await + let vendor = load_vendor_context(common, manifest).await; + socket_patch_core::vex::applied_patches_with_vendor( + manifest, + &package_paths, + vendor.as_ref(), + ) + .await }; // Property 7: attest a patch only for an ecosystem that is actually set up — // or explicitly declared `manual` in the manifest. Patches for an ecosystem // that is neither are dropped regardless of verification mode (so even // `--no-verify` won't attest an un-set-up ecosystem's patches). + // Exemption: VENDORED patches bypass the filter — the committed + // `.socket/vendor/` artifact + lockfile wiring IS the persistence + // mechanism, so no install hook exists (or is needed) by construction. + let vendored_set: std::collections::HashSet = + outcome.vendored.iter().cloned().collect(); let mut allowed = crate::commands::setup::configured_ecosystems(common).await; if let Some(s) = &manifest.setup { for name in &s.manual { @@ -311,9 +323,10 @@ pub(crate) async fn generate_vex( } let before = outcome.applied.len(); outcome.applied.retain(|purl| { - Ecosystem::from_purl(purl) - .map(|e| allowed.contains(&e)) - .unwrap_or(false) + vendored_set.contains(purl) + || Ecosystem::from_purl(purl) + .map(|e| allowed.contains(&e)) + .unwrap_or(false) }); if outcome.applied.len() != before && !common.silent && !common.json { eprintln!( @@ -342,7 +355,8 @@ pub(crate) async fn generate_vex( tooling: Some(format!("socket-patch {}", env!("CARGO_PKG_VERSION"))), }; - let doc = match build_document(manifest, &outcome.applied, &opts) { + let doc = match build_document_with_vendored(manifest, &outcome.applied, &outcome.vendored, &opts) + { Some(doc) => doc, None => { track_vex_failed( @@ -467,6 +481,133 @@ async fn resolve_product_id(common: &GlobalArgs, product: Option<&str>) -> Resul }) } +/// Build the [`VendorContext`] for verification: the committed +/// `.socket/vendor/state.json` ledger plus synthesized entries for the +/// legacy `.socket/go-patches/` redirect backend. +/// +/// The go-patches synthesis fixes a latent bug: an apply-redirected Go +/// patch leaves the module cache pristine (the `replace` directive routes +/// the build at the copy dir), so verifying against the crawler-resolved +/// cache path reported `not_applied`/`package_not_found` and the patch was +/// silently omitted from the VEX document. The redirect copy dir holds the +/// bytes the build actually consumes, so it is what verification must hash. +/// +/// An unreadable/corrupt vendor ledger degrades to "no vendor entries" +/// (with a stderr warning): vendored PURLs then fall through to the +/// installed tree, fail verification there, and are omitted — fail-closed, +/// never falsely attested. Returns `None` when there is nothing vendored +/// and no redirect to synthesize (the common case). +async fn load_vendor_context( + common: &GlobalArgs, + manifest: &PatchManifest, +) -> Option { + let entries = match socket_patch_core::patch::vendor::load_state(&common.cwd).await { + Ok(state) => state.entries, + Err(e) => { + if !common.silent { + eprintln!( + "Warning: unreadable vendor state ({e}); vendored patches will be \ + omitted from VEX" + ); + } + HashMap::new() + } + }; + + let go_patches = { + #[cfg(feature = "golang")] + { + synthesize_go_patches(common, manifest, &entries).await + } + #[cfg(not(feature = "golang"))] + { + let _ = manifest; + HashMap::new() + } + }; + + if entries.is_empty() && go_patches.is_empty() { + return None; + } + Some(VendorContext { + project_root: common.cwd.clone(), + entries, + go_patches, + }) +} + +/// Synthesize go-patches redirect targets for [`load_vendor_context`]: for +/// every socket-owned (`.socket/go-patches/`) `replace` in `go.mod` whose +/// module+version maps to a manifest golang PURL with no explicit vendor +/// entry, record the absolute redirect copy dir for dir-hash verification. +#[cfg(feature = "golang")] +async fn synthesize_go_patches( + common: &GlobalArgs, + manifest: &PatchManifest, + entries: &HashMap, +) -> HashMap { + use socket_patch_core::patch::go_mod_edit::{ + read_replace_entries, ReplaceOwner, GO_PATCHES_DIR, + }; + use socket_patch_core::utils::purl::build_golang_purl; + + let mut go_patches = HashMap::new(); + for entry in read_replace_entries(&common.cwd).await { + if entry.owner != Some(ReplaceOwner::GoPatches) { + continue; + } + let Some(version) = entry.version.as_deref() else { + continue; + }; + let purl = build_golang_purl(&entry.module, version); + if !manifest.patches.contains_key(&purl) { + continue; + } + // Explicit vendor entries take precedence over the synthesis + // (vendor may have taken over an apply redirect). + if entries.contains_key(&purl) || entries.values().any(|e| e.base_purl == purl) { + continue; + } + // SECURITY: module/version come from a committed (tamper-able) + // go.mod and are about to key a path we hash. Validate with the + // same per-segment rules `go_redirect::are_safe_redirect_coords` + // applies (it is crate-private to core) before building the + // copy-dir path. + if !are_safe_go_redirect_coords(&entry.module, version) { + continue; + } + go_patches.insert( + purl, + common + .cwd + .join(GO_PATCHES_DIR) + .join(format!("{}@{version}", entry.module)), + ); + } + go_patches +} + +/// Local mirror of core's `go_redirect::are_safe_redirect_coords` (which is +/// `pub(crate)` there): a module path is `/`-separated segments, each +/// non-empty and not `.`/`..`, no leading `/`, no backslash/NUL; a version +/// is a single such segment. Fail-closed before any disk access. +#[cfg(feature = "golang")] +fn are_safe_go_redirect_coords(module: &str, version: &str) -> bool { + fn safe_segment(seg: &str) -> bool { + !seg.is_empty() && seg != "." && seg != ".." + } + let module_ok = !module.is_empty() + && !module.starts_with('/') + && !module.contains('\\') + && !module.contains('\0') + && module.split('/').all(safe_segment); + let version_ok = safe_segment(version) + && !version.contains('/') + && !version.contains('\\') + && !version.contains('\0'); + module_ok && version_ok +} + /// Walk the ecosystem dispatch to build the PURL -> on-disk-path map /// used by `vex::verify::applied_patches`. async fn resolve_package_paths( @@ -623,6 +764,32 @@ mod tests { } } + /// The local mirror of core's `are_safe_redirect_coords` must enforce + /// the same accept/reject set (cases lifted from core's pinned tests) — + /// a divergence would let a tampered go.mod `replace` key an + /// out-of-tree path into the go-patches verification map. + #[cfg(feature = "golang")] + #[test] + fn go_redirect_coord_guard_matches_core_rules() { + assert!(are_safe_go_redirect_coords("github.com/foo/bar", "v1.4.2")); + assert!(are_safe_go_redirect_coords("gopkg.in/inf.v0", "v0.9.1")); + assert!(are_safe_go_redirect_coords( + "github.com/foo/bar/v2", + "v2.0.0-20210101000000-abcdef123456" + )); + assert!(!are_safe_go_redirect_coords("../../../etc", "v1.0.0")); + assert!(!are_safe_go_redirect_coords("github.com/../../../etc", "v1.0.0")); + assert!(!are_safe_go_redirect_coords("/abs/path", "v1.0.0")); + assert!(!are_safe_go_redirect_coords("github.com//bar", "v1.0.0")); + assert!(!are_safe_go_redirect_coords("foo/./bar", "v1.0.0")); + assert!(!are_safe_go_redirect_coords("foo\\bar", "v1.0.0")); + assert!(!are_safe_go_redirect_coords("", "v1.0.0")); + assert!(!are_safe_go_redirect_coords("github.com/foo/bar", "../../../evil")); + assert!(!are_safe_go_redirect_coords("github.com/foo/bar", "v1/0/0")); + assert!(!are_safe_go_redirect_coords("github.com/foo/bar", "..")); + assert!(!are_safe_go_redirect_coords("github.com/foo/bar", "")); + } + #[derive(Parser)] struct Wrap { #[command(subcommand)] diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index e33b472..755b7d5 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -69,6 +69,11 @@ pub enum Commands { /// lock file when it is free. Unlock(commands::unlock::UnlockArgs), + /// Eject patched dependencies into committable `.socket/vendor/` + /// and rewire lockfiles so fresh checkouts build with the patches + /// (no socket-patch or Socket API needed). `--revert` undoes it. + Vendor(commands::vendor::VendorArgs), + /// Generate an OpenVEX 0.2.0 attestation describing the /// vulnerabilities mitigated by the applied patches. Vex(commands::vex::VexArgs), diff --git a/crates/socket-patch-cli/src/main.rs b/crates/socket-patch-cli/src/main.rs index 99222d3..bceaa27 100644 --- a/crates/socket-patch-cli/src/main.rs +++ b/crates/socket-patch-cli/src/main.rs @@ -24,6 +24,7 @@ async fn main() { Commands::Setup(args) => commands::setup::run(args).await, Commands::Repair(args) => commands::repair::run(args).await, Commands::Unlock(args) => commands::unlock::run(args).await, + Commands::Vendor(args) => commands::vendor::run(args).await, Commands::Vex(args) => commands::vex::run(args).await, }; diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index 6b509ee..c817cdf 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -27,7 +27,7 @@ use socket_patch_cli::Cli; /// being listed here — closing the "someone forgot the flatten on a new /// command and nobody noticed" gap this file claims to guard. const SUBCOMMANDS_NO_POSITIONAL: &[&str] = &[ - "apply", "list", "scan", "setup", "repair", "rollback", "unlock", "vex", + "apply", "list", "scan", "setup", "repair", "rollback", "unlock", "vendor", "vex", ]; /// Subcommands that require a positional identifier. @@ -100,6 +100,7 @@ fn common_of(cli: &Cli) -> &GlobalArgs { Setup(a) => &a.common, Repair(a) => &a.common, Unlock(a) => &a.common, + Vendor(a) => &a.common, Vex(a) => &a.common, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs new file mode 100644 index 0000000..c76ac11 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -0,0 +1,439 @@ +//! End-to-end tests for vendored-patch awareness in `socket-patch vex`. +//! +//! A `socket-patch vendor` run ejects the patched package into a committed +//! `.socket/vendor///` recorded in +//! `.socket/vendor/state.json` — after which the installed tree is expected +//! to be UN-patched (the lockfile consumes the vendored copy). `vex` must +//! attest those patches from the committed artifact: +//! +//! 1. vendored PURL attested with NO installed tree, impact statement +//! carries the "(vendored)" marker +//! 2. tampered vendored artifact → omitted, envelope skip reason +//! `vendor_hash_mismatch` +//! 3. Property-7 exemption: a vendored patch needs no install hook by +//! construction, so it bypasses the configured/manual ecosystem filter +//! 4. legacy `.socket/go-patches/` redirect regression: an apply-redirected +//! Go patch verifies against the redirect copy dir, not the (pristine) +//! module cache + +use std::collections::HashMap; +use std::path::Path; +use std::process::Command; + +use serde_json::Value; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use socket_patch_core::manifest::schema::{ + PatchFileInfo, PatchManifest, PatchRecord, SetupConfig, VulnerabilityInfo, +}; +use socket_patch_core::patch::vendor::state::{VendorArtifact, VendorEntry, VendorState}; + +/// Canonical-grammar patch UUID — the vendored-artifact verifier validates +/// the uuid path level, so fixtures must use the real shape. +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + +/// Every setup-supported ecosystem, declared `manual` so the property-7 +/// filter doesn't interfere with the tests that aren't about it. +const ALL_MANUAL: &[&str] = &["npm", "pypi", "cargo", "golang", "gem", "composer"]; + +fn binary() -> &'static str { + env!("CARGO_BIN_EXE_socket-patch") +} + +/// CLI invocation with the ambient `SOCKET_*` environment scrubbed (same +/// rationale as `e2e_vex.rs`: explicit flags must be the sole source of +/// truth). +fn cli() -> Command { + let mut cmd = Command::new(binary()); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") { + cmd.env_remove(key); + } + } + cmd +} + +/// Write `manifest` to `/.socket/manifest.json`, optionally declaring +/// every ecosystem `manual` (tests of the property-7 exemption pass `false`). +fn write_manifest(cwd: &Path, manifest: &PatchManifest, declare_manual: bool) { + let dir = cwd.join(".socket"); + std::fs::create_dir_all(&dir).unwrap(); + let mut m = manifest.clone(); + if declare_manual { + m.setup = Some(SetupConfig { + exclude: Vec::new(), + manual: ALL_MANUAL.iter().map(|s| s.to_string()).collect(), + }); + } + std::fs::write( + dir.join("manifest.json"), + serde_json::to_string_pretty(&m).unwrap(), + ) + .unwrap(); +} + +/// Patch record with one file and one vulnerability. +fn make_record( + uuid: &str, + file_name: &str, + after_hash: &str, + vuln_id: &str, + cves: &[&str], +) -> PatchRecord { + let mut files = HashMap::new(); + files.insert( + file_name.to_string(), + PatchFileInfo { + before_hash: "a".repeat(64), + after_hash: after_hash.to_string(), + }, + ); + let mut vulns = HashMap::new(); + vulns.insert( + vuln_id.to_string(), + VulnerabilityInfo { + cves: cves.iter().map(|s| s.to_string()).collect(), + summary: "test summary".to_string(), + severity: "high".to_string(), + description: "test description".to_string(), + }, + ); + PatchRecord { + uuid: uuid.to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: vulns, + description: format!("Patch {uuid}"), + license: "MIT".to_string(), + tier: "free".to_string(), + } +} + +/// Write a `.socket/vendor/state.json` ledger with one cargo-style +/// (dir-shaped) entry for `purl` whose artifact lives at `rel_path`. +fn write_vendor_state(cwd: &Path, purl: &str, rel_path: &str) { + let mut state = VendorState::new(); + state.entries.insert( + purl.to_string(), + VendorEntry { + ecosystem: "cargo".to_string(), + base_purl: purl.to_string(), + uuid: UUID.to_string(), + artifact: VendorArtifact { + path: rel_path.to_string(), + sha256: String::new(), + size: None, + platform_locked: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + flavor: None, + uv: None, + }, + ); + let dir = cwd.join(".socket/vendor"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("state.json"), + serde_json::to_string_pretty(&state).unwrap(), + ) + .unwrap(); +} + +/// Lay down a vendored cargo-style dir artifact containing `src/lib.rs` +/// with `content`; returns the project-relative artifact path. +fn write_vendored_dir(cwd: &Path, content: &[u8]) -> String { + let rel = format!(".socket/vendor/cargo/{UUID}/serde-1.0.0"); + let dir = cwd.join(&rel).join("src"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("lib.rs"), content).unwrap(); + rel +} + +// ────────────────────────────────────────────────────────────────────── +// 1. vendored attestation with NO installed tree +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn vendored_purl_attested_with_no_installed_tree() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let purl = "pkg:cargo/serde@1.0.0"; + + let patched = b"patched vendored source\n"; + let after_hash = compute_git_sha256_from_bytes(patched); + let rel = write_vendored_dir(cwd, patched); + write_vendor_state(cwd, purl, &rel); + + // No Cargo.toml, no target/, no registry copy — the vendored artifact + // is the ONLY evidence on disk. + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + purl.to_string(), + make_record(UUID, "src/lib.rs", &after_hash, "GHSA-vend-aaaa", &["CVE-2024-1"]), + ); + write_manifest(cwd, &manifest, true); + + let out = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--product", + "pkg:cargo/app@1.0.0", + ]) + .output() + .expect("invoke vex"); + assert!( + out.status.success(), + "vendored patch must verify with no installed tree. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let doc: Value = serde_json::from_slice(&out.stdout).expect("VEX JSON on stdout"); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!(stmts.len(), 1, "the vendored patch must be attested"); + assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-vend-aaaa"); + assert_eq!(stmts[0]["status"], "not_affected"); + let subs = stmts[0]["products"][0]["subcomponents"].as_array().unwrap(); + assert_eq!(subs[0]["@id"], purl); + let impact = stmts[0]["impact_statement"].as_str().unwrap(); + assert_eq!( + impact, + format!("Patched via Socket patch {UUID} (vendored)"), + "vendored attestation must carry the (vendored) marker" + ); +} + +// ────────────────────────────────────────────────────────────────────── +// 2. tampered vendored artifact → omitted with vendor_hash_mismatch +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn tampered_vendored_artifact_omitted_with_vendor_hash_mismatch() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let purl = "pkg:cargo/serde@1.0.0"; + + // The artifact on disk does NOT hash to the manifest's afterHash. + let after_hash = compute_git_sha256_from_bytes(b"what the patch should contain\n"); + let rel = write_vendored_dir(cwd, b"tampered bytes\n"); + write_vendor_state(cwd, purl, &rel); + + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + purl.to_string(), + make_record(UUID, "src/lib.rs", &after_hash, "GHSA-vend-bbbb", &["CVE-2024-2"]), + ); + write_manifest(cwd, &manifest, true); + + let vex_path = cwd.join("out.vex.json"); + let out = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--json", + "--output", + vex_path.to_str().unwrap(), + "--product", + "pkg:cargo/app@1.0.0", + ]) + .output() + .expect("invoke vex"); + + // The only patch failed verification → soft "nothing to attest". + assert_eq!( + out.status.code(), + Some(1), + "tampered vendored artifact must not be attested. stdout:\n{}", + String::from_utf8_lossy(&out.stdout) + ); + let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); + assert_eq!(env["status"], "error"); + assert_eq!(env["error"]["code"], "no_applicable_patches"); + // The omission surfaces as a skipped event whose errorCode carries the + // vendor routing tag (same surfacing shape as installed-tree failures). + let events = env["events"].as_array().unwrap(); + let skipped = events + .iter() + .find(|e| e["action"] == "skipped" && e["purl"] == purl) + .expect("expected a skipped event for the tampered vendored patch"); + assert_eq!( + skipped["errorCode"], "vendor_hash_mismatch", + "the vendor verification reason must land in errorCode. event:\n{skipped}" + ); + assert!(!vex_path.exists(), "no VEX doc may be written when nothing attests"); +} + +// ────────────────────────────────────────────────────────────────────── +// 3. Property-7 exemption — vendored patches need no install hook +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn property7_vendored_purl_bypasses_setup_manual_filter() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let vendored_purl = "pkg:cargo/serde@1.0.0"; + + let patched = b"patched vendored source\n"; + let after_hash = compute_git_sha256_from_bytes(patched); + let rel = write_vendored_dir(cwd, patched); + write_vendor_state(cwd, vendored_purl, &rel); + + // Control: an npm patch that VERIFIES against node_modules but whose + // ecosystem is neither set up (no postinstall hook anywhere) nor manual + // — property 7 must drop it, proving the filter ran while the vendored + // patch sailed through. + let nm_pkg = cwd.join("node_modules/applied-pkg"); + std::fs::create_dir_all(&nm_pkg).unwrap(); + std::fs::write( + nm_pkg.join("package.json"), + r#"{"name":"applied-pkg","version":"1.0.0"}"#, + ) + .unwrap(); + let npm_patched = b"patched npm index"; + let npm_after = compute_git_sha256_from_bytes(npm_patched); + std::fs::write(nm_pkg.join("index.js"), npm_patched).unwrap(); + + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + vendored_purl.to_string(), + make_record(UUID, "src/lib.rs", &after_hash, "GHSA-vend-cccc", &["CVE-2024-3"]), + ); + manifest.patches.insert( + "pkg:npm/applied-pkg@1.0.0".to_string(), + make_record( + "11111111-1111-4111-8111-111111111111", + "package/index.js", + &npm_after, + "GHSA-npm-control", + &["CVE-2024-4"], + ), + ); + // NO setup section: nothing configured, nothing manual. + write_manifest(cwd, &manifest, false); + + let out = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--product", + "pkg:cargo/app@1.0.0", + ]) + .output() + .expect("invoke vex"); + assert!( + out.status.success(), + "the vendored patch must be attested without any setup/manual config. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let stdout = String::from_utf8(out.stdout).unwrap(); + let doc: Value = serde_json::from_str(&stdout).unwrap(); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!( + stmts.len(), + 1, + "only the vendored patch bypasses property 7; the unconfigured npm \ + control must be dropped. doc:\n{stdout}" + ); + assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-vend-cccc"); + assert!( + !stdout.contains("GHSA-npm-control"), + "the non-vendored, non-configured npm patch must be filtered:\n{stdout}" + ); +} + +// ────────────────────────────────────────────────────────────────────── +// 4. legacy go-patches redirect regression — an apply-redirected Go patch +// must verify against the `.socket/go-patches/` copy dir (the bytes the +// build consumes), not the pristine module cache. Without the redirect +// synthesis the crawler resolves nothing here (empty GOMODCACHE) and the +// patch is silently dropped as package_not_found → exit 1. +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn golang_go_patches_redirect_attested_without_module_cache() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let module = "github.com/foo/bar"; + let version = "v1.4.2"; + let purl = format!("pkg:golang/{module}@{version}"); + + // A real go.mod (required by ensure_replace_entry) + the socket-owned + // replace directive exactly as `apply`'s redirect backend writes it. + std::fs::write( + cwd.join("go.mod"), + format!("module example.com/app\n\ngo 1.21\n\nrequire {module} {version}\n"), + ) + .unwrap(); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(socket_patch_core::patch::go_mod_edit::ensure_replace_entry( + cwd, + module, + version, + socket_patch_core::patch::go_mod_edit::GO_PATCHES_DIR, + false, + )) + .expect("write go.mod replace"); + + // The patched copy dir the redirect points at. + let patched = b"package bar // patched\n"; + let after_hash = compute_git_sha256_from_bytes(patched); + let copy_dir = cwd.join(format!(".socket/go-patches/{module}@{version}")); + std::fs::create_dir_all(©_dir).unwrap(); + std::fs::write(copy_dir.join("bar.go"), patched).unwrap(); + + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + purl.clone(), + make_record( + "22222222-2222-4222-8222-222222222222", + "bar.go", + &after_hash, + "GHSA-go-redirect", + &["CVE-2024-5"], + ), + ); + write_manifest(cwd, &manifest, true); + + // Hermetic, EMPTY module cache: the pristine module is nowhere on disk, + // exactly like a fresh checkout that only ran the redirect apply. + let empty_cache = tmp.path().join("empty-gomodcache"); + std::fs::create_dir_all(&empty_cache).unwrap(); + + let out = cli() + .env("GOMODCACHE", &empty_cache) + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--product", + "pkg:golang/example.com/app@v0.0.1", + ]) + .output() + .expect("invoke vex"); + assert!( + out.status.success(), + "an apply-redirected go patch must be attested from the go-patches \ + copy dir even with no module cache. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let doc: Value = serde_json::from_slice(&out.stdout).unwrap(); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!(stmts.len(), 1, "the redirected go patch must be attested"); + assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-go-redirect"); + let subs = stmts[0]["products"][0]["subcomponents"].as_array().unwrap(); + assert_eq!(subs[0]["@id"], purl); + // Redirect copies are applied (machine-local), NOT vendored — the + // phrasing must stay the plain form. + let impact = stmts[0]["impact_statement"].as_str().unwrap(); + assert!( + !impact.contains("(vendored)"), + "a go-patches redirect is not a vendored artifact: {impact}" + ); +} diff --git a/crates/socket-patch-core/src/patch/copy_tree.rs b/crates/socket-patch-core/src/patch/copy_tree.rs index 629f945..c54ef84 100644 --- a/crates/socket-patch-core/src/patch/copy_tree.rs +++ b/crates/socket-patch-core/src/patch/copy_tree.rs @@ -95,7 +95,7 @@ pub(crate) fn force_remove_dir_all(dir: &Path) -> std::io::Result<()> { } /// Async wrapper over [`force_remove_dir_all`]. -pub(crate) async fn remove_tree(dir: &Path) -> std::io::Result<()> { +pub async fn remove_tree(dir: &Path) -> std::io::Result<()> { let dir = dir.to_path_buf(); tokio::task::spawn_blocking(move || force_remove_dir_all(&dir)) .await diff --git a/crates/socket-patch-core/src/patch/vendor/cargo.rs b/crates/socket-patch-core/src/patch/vendor/cargo.rs index ed2edb3..839d2e3 100644 --- a/crates/socket-patch-core/src/patch/vendor/cargo.rs +++ b/crates/socket-patch-core/src/patch/vendor/cargo.rs @@ -1 +1,1122 @@ -//! (stub — implementation lands with its backend phase) +//! The cargo vendor backend: committable `[patch.crates-io]` vendoring. +//! +//! Materialises a patched copy of the crate under +//! `.socket/vendor/cargo//-/`, points cargo at it +//! with a `[patch.crates-io]` path entry in `.cargo/config.toml` +//! ([`super::cargo_config`]), and surgically detaches the crate's +//! `Cargo.lock` entry from the registry ([`super::cargo_lock`]) — without the +//! lock edit, `cargo build --locked` fails closed on the un-relocked `[patch]` +//! (spike-verified; the whole wiring is proven offline-from-Socket on a fresh +//! checkout with an empty `CARGO_HOME` — `spikes/PHASE0-FINDINGS.txt`). +//! +//! The copy is produced by **delegating to the hardened +//! [`apply_package_patch`] pipeline** pointed at the fresh copy, so all the +//! verify → package/diff/blob → atomic-write machinery is reused unchanged. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::manifest::schema::{PatchFileInfo, PatchRecord}; +use crate::patch::apply::{ + apply_package_patch, normalize_file_path, ApplyResult, PatchSources, VerifyResult, VerifyStatus, +}; +use crate::patch::copy_tree::{fresh_copy, remove_tree}; +use crate::patch::file_hash::compute_file_git_sha256; +use crate::patch::path_safety::is_safe_single_segment; +use crate::utils::purl::{parse_cargo_purl, strip_purl_qualifiers}; + +use super::cargo_config::{self, LEGACY_CARGO_PATCHES_DIR}; +use super::cargo_lock::{self, LockEditError, LockEntryOriginal}; +use super::path::vendor_uuid_dir_rel; +use super::state::{ + write_marker, CargoLockOriginal, VendorArtifact, VendorEntry, VendorMarker, WiringAction, + WiringRecord, +}; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +/// True if a crate is vendored under `/vendor/` (in either the +/// `-/` or bare `/` layout the cargo crawler probes). A +/// real `cargo vendor` tree already provides committed, project-owned bytes +/// for the crate, so the `[patch]`+lock wiring would conflict with the +/// `[source]` replacement that tree implies — refuse upstream instead. +async fn is_vendored(project_root: &Path, name: &str, version: &str) -> bool { + let vendor = project_root.join("vendor"); + for candidate in [vendor.join(format!("{name}-{version}")), vendor.join(name)] { + if tokio::fs::metadata(&candidate) + .await + .map(|m| m.is_dir()) + .unwrap_or(false) + { + return true; + } + } + false +} + +/// True iff a config-entry path points into the retired redirect backend's +/// `.socket/cargo-patches/` tree (vendor takes such entries over and reports +/// the takeover, rather than treating them as a silent refresh). +fn is_legacy_redirect_path(path: &str) -> bool { + let norm = path.replace('\\', "/"); + let norm = norm.strip_prefix("./").unwrap_or(&norm); + norm.starts_with(&format!("{LEGACY_CARGO_PATCHES_DIR}/")) +} + +/// True when the lock entry for `name`+`version` no longer needs detaching: +/// either there is no lockfile (nothing to edit — the first build generates a +/// path-form lock), or the entry exists with no `source` (already detached). +/// Probed via a dry-run detach: `NotRegistry` *is* the detached shape. +async fn lock_entry_detached(project_root: &Path, name: &str, version: &str) -> bool { + matches!( + cargo_lock::detach_lock_entry(project_root, name, version, true).await, + Err(LockEditError::NotRegistry) | Err(LockEditError::NoLockfile) + ) +} + +/// True if the copy exists, every patched file in it already hashes to its +/// `afterHash`, the config entry points at this copy, and the lock entry is +/// already detached — i.e. a re-run has nothing to do. Touch nothing then, so +/// cargo's source fingerprint and the committed bytes stay stable. +async fn vendor_in_sync( + copy_dir: &Path, + files: &HashMap, + project_root: &Path, + name: &str, + version: &str, + copy_rel: &str, +) -> bool { + if tokio::fs::metadata(copy_dir).await.is_err() { + return false; + } + for (file_name, info) in files { + let path = copy_dir.join(normalize_file_path(file_name)); + match compute_file_git_sha256(&path).await { + Ok(h) if h == info.after_hash => {} + _ => return false, + } + } + let entries = cargo_config::read_patch_entries(project_root).await; + if entries.get(name).and_then(|i| i.path.as_deref()) != Some(copy_rel) { + return false; + } + lock_entry_detached(project_root, name, version).await +} + +fn synthesized_result( + package_key: &str, + copy_dir: &Path, + files_verified: Vec, + success: bool, + error: Option, +) -> ApplyResult { + ApplyResult { + package_key: package_key.to_string(), + package_path: copy_dir.display().to_string(), + success, + files_verified, + files_patched: Vec::new(), + applied_via: HashMap::new(), + error, + sidecar: None, + } +} + +fn already_patched_verify(file: &str) -> VerifyResult { + VerifyResult { + file: file.to_string(), + status: VerifyStatus::AlreadyPatched, + message: None, + current_hash: None, + expected_hash: None, + target_hash: None, + } +} + +fn done(result: ApplyResult, entry: Option, warnings: Vec) -> VendorOutcome { + VendorOutcome::Done { + result, + entry, + warnings, + } +} + +/// Vendor one cargo crate: patched copy + `[patch.crates-io]` entry + +/// `Cargo.lock` surgery + marker, returning the ledger entry to persist. +/// +/// * `pristine_src` — the pristine registry/vendor source dir (the crawler's +/// `pkg_path`). It is copied, never mutated. +/// * `vendored_at` — caller-formatted RFC3339 timestamp for the marker. +/// +/// `dry_run` writes nothing (it verifies against `pristine_src` for an +/// accurate report). On the in-sync hot path (re-run with everything already +/// wired) `entry` is `None` — the lock originals are only recoverable from +/// the existing ledger entry, so the caller must keep it, not overwrite it. +#[allow(clippy::too_many_arguments)] +pub async fn vendor_cargo_crate( + purl: &str, + pristine_src: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, +) -> VendorOutcome { + // ── coordinate validation (fail-closed, before any disk access) ────── + let Some((name, version)) = parse_cargo_purl(purl) else { + return VendorOutcome::Refused { + code: "unsafe_coordinates", + detail: format!("not a cargo purl: {purl}"), + }; + }; + // SECURITY: `name`/`version` key the on-disk copy dir + // (`.socket/vendor/cargo//-/`) and the `[patch]` + // path. A `..`/separator from a tampered manifest PURL would let the copy + // and the apply pipeline escape `.socket/vendor/` — refuse before any + // disk access. + if !is_safe_single_segment(name) || !is_safe_single_segment(version) { + return VendorOutcome::Refused { + code: "unsafe_coordinates", + detail: format!( + "refusing to vendor unsafe cargo coordinates `{name}`/`{version}` \ + (a path separator or `..` would escape .socket/vendor/cargo/)" + ), + }; + } + // SECURITY: the uuid is a dedicated path level created here and deleted by + // `--revert`; anything but the canonical UUID grammar is rejected. + let Some(base_rel) = vendor_uuid_dir_rel("cargo", &record.uuid) else { + return VendorOutcome::Refused { + code: "unsafe_coordinates", + detail: format!( + "refusing to vendor {purl}: patch uuid `{}` is not a canonical uuid", + record.uuid + ), + }; + }; + + // ── pre-flight refusals (read-only) ─────────────────────────────────── + // (a) A real `cargo vendor` tree already provides this crate. + if is_vendored(project_root, name, version).await { + return VendorOutcome::Refused { + code: "already_vendored_in_tree", + detail: format!( + "{name}@{version} is provided by the project's `vendor/` tree \ + (cargo vendor); patch it in place with `apply` instead" + ), + }; + } + // (b) The lock must resolve this exact version, or the `[patch]` would be + // unused and an unlocked build would silently re-lock (spike claim 6). + if let Some(locked) = cargo_lock::read_locked_versions(project_root).await { + match locked.get(name) { + Some(versions) if versions.contains(version) => {} + Some(versions) => { + let mut sorted: Vec<&str> = versions.iter().map(String::as_str).collect(); + sorted.sort_unstable(); + return VendorOutcome::Refused { + code: "locked_version_mismatch", + detail: format!( + "Cargo.lock resolves `{name}` to {} but the patch targets {version}", + sorted.join(", ") + ), + }; + } + None => { + return VendorOutcome::Refused { + code: "locked_version_mismatch", + detail: format!( + "`{name}` is not present in Cargo.lock (patch targets {version})" + ), + }; + } + } + } + // (c) A user-authored same-name `[patch.crates-io]` entry is never + // overwritten. (`ensure_patch_entry` would also refuse, but pre-flighting + // it keeps the refusal ahead of any write.) + let prior_entry = cargo_config::read_patch_entries(project_root) + .await + .remove(name); + if let Some(info) = &prior_entry { + if !info.socket_owned { + return VendorOutcome::Refused { + code: "user_authored_patch_entry", + detail: format!( + "`patch.crates-io.{name}` in .cargo/config.toml is user-authored \ + ({}); refusing to overwrite", + info.path.as_deref().unwrap_or("non-path source") + ), + }; + } + } + + let copy_rel = format!("{base_rel}/{name}-{version}"); + let uuid_dir = project_root.join(&base_rel); + let copy_dir = project_root.join(©_rel); + + // A patch with no files is meaningless: no-op success, nothing wired. + if record.files.is_empty() { + return done( + synthesized_result(purl, ©_dir, Vec::new(), true, None), + None, + Vec::new(), + ); + } + + if dry_run { + // Verify (read-only) against the pristine source — apply_package_patch + // never writes when dry_run — for an accurate "would patch" report, + // without creating the copy or editing config/lock. + let mut result = + apply_package_patch(purl, pristine_src, &record.files, sources, Some(&record.uuid), true, force).await; + result.package_path = copy_dir.display().to_string(); + result.sidecar = None; + return done(result, None, Vec::new()); + } + + // Hot path: already in sync → touch nothing (entry stays with the caller's + // existing ledger record, which holds the unrecoverable lock originals). + if vendor_in_sync(©_dir, &record.files, project_root, name, version, ©_rel).await { + let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + return done( + synthesized_result(purl, ©_dir, verified, true, None), + None, + Vec::new(), + ); + } + + // ── materialise the patched copy ────────────────────────────────────── + // Skip any `.cargo-checksum.json`: cargo 1.93 registry/src dirs no longer + // carry one (spike surprise), but older layouts do and a path-dep copy + // must never include it (its presence would re-enable checksum fixups). + if let Err(e) = fresh_copy(pristine_src, ©_dir, Some(".cargo-checksum.json")).await { + // Clear the whole uuid dir, not just the copy: a partial copy (or an + // empty `/` husk) under .socket/vendor/ would be misjudged by + // verify/sweep. + let _ = remove_tree(&uuid_dir).await; + return done( + synthesized_result( + purl, + ©_dir, + Vec::new(), + false, + Some(format!("failed to copy pristine source: {e}")), + ), + None, + Vec::new(), + ); + } + + // Delegate to the hardened pipeline, pointed at the copy. + let mut result = + apply_package_patch(purl, ©_dir, &record.files, sources, Some(&record.uuid), false, force).await; + result.package_path = copy_dir.display().to_string(); + + if !result.success { + // Don't leave a half-built copy (or an empty uuid husk) that + // verify/sweep would misjudge. + let _ = remove_tree(&uuid_dir).await; + return done(result, None, Vec::new()); + } + + // A path-dep copy must never carry a checksum sidecar. The fresh copy + // excluded it; enforce the invariant defensively in case the patch itself + // recreated the file. + let _ = tokio::fs::remove_file(copy_dir.join(".cargo-checksum.json")).await; + debug_assert!( + result.sidecar.is_none(), + "vendor copy must not produce a cargo sidecar" + ); + result.sidecar = None; + + // ── wire the config entry ───────────────────────────────────────────── + if let Err(e) = cargo_config::ensure_patch_entry(project_root, name, ©_rel, false).await { + // The config was left untouched on refusal; unwind the copy so no + // unwired artifact lingers under .socket/vendor/. + let _ = remove_tree(&uuid_dir).await; + result.success = false; + result.error = Some(format!("failed to update .cargo/config.toml: {e}")); + return done(result, None, Vec::new()); + } + + let mut warnings = Vec::new(); + let prior_path = prior_entry.as_ref().and_then(|i| i.path.clone()); + if prior_path.as_deref().is_some_and(is_legacy_redirect_path) { + warnings.push(VendorWarning::new( + "vendor_takeover", + format!( + "took over the legacy `.socket/cargo-patches/` [patch] entry for `{name}`" + ), + )); + } + + // ── detach the lock entry ───────────────────────────────────────────── + let lock_original: Option = + match cargo_lock::detach_lock_entry(project_root, name, version, false).await { + Ok(orig) => Some(orig), + Err(LockEditError::NoLockfile) => { + // No lock to edit: the first `cargo build`/`generate-lockfile` + // records the path patch directly (no source/checksum). + warnings.push(VendorWarning::new( + "no_lockfile", + "no Cargo.lock found; the first build will generate a path-form lock", + )); + None + } + Err(e) => { + // Without the lock edit, `--locked` builds fail closed on the + // [patch] we just wired — a half-vendored state. UNWIND the + // config entry + copy so the project is back where it started. + let _ = cargo_config::drop_patch_entry(project_root, name, false).await; + let _ = remove_tree(&uuid_dir).await; + result.success = false; + result.error = Some(format!( + "failed to detach the Cargo.lock entry for {name}@{version}: {e} \ + (config entry and copy were unwound; nothing was vendored)" + )); + return done(result, None, warnings); + } + }; + + // ── marker + ledger entry ───────────────────────────────────────────── + let base_purl = strip_purl_qualifiers(purl).to_string(); + let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); + vulnerabilities.sort(); + let marker = VendorMarker { + schema_version: 1, + purl: base_purl.clone(), + patch_uuid: record.uuid.clone(), + ecosystem: "cargo".to_string(), + vulnerabilities, + vendored_at: vendored_at.to_string(), + }; + if let Err(e) = write_marker(&uuid_dir, &marker).await { + // The marker is belt-and-braces metadata (never a trust input); a + // failed write must not undo a fully-wired vendor — surface it. + warnings.push(VendorWarning::new( + "marker_write_failed", + format!("could not write the vendor marker: {e}"), + )); + } + + let mut wiring = vec![WiringRecord { + file: ".cargo/config.toml".to_string(), + kind: "cargo_patch_entry".to_string(), + action: if prior_path.is_some() { + WiringAction::Rewritten + } else { + WiringAction::Added + }, + key: Some(name.to_string()), + original: prior_path.map(serde_json::Value::from), + new: Some(serde_json::Value::from(copy_rel.clone())), + }]; + if let Some(orig) = &lock_original { + wiring.push(WiringRecord { + file: "Cargo.lock".to_string(), + kind: "cargo_lock_entry".to_string(), + action: WiringAction::Rewritten, + key: Some(format!("{name}@{version}")), + original: Some(serde_json::json!({ + "source": orig.source, + "checksum": orig.checksum, + })), + new: None, + }); + } + + let entry = VendorEntry { + ecosystem: "cargo".to_string(), + base_purl, + uuid: record.uuid.clone(), + artifact: VendorArtifact { + path: copy_rel, + sha256: String::new(), // dir-shaped: integrity is per-file afterHashes + size: None, + platform_locked: None, + }, + wiring, + lock: lock_original.map(|o| CargoLockOriginal { + source: o.source, + checksum: o.checksum, + }), + took_over_go_patches: false, + flavor: None, + uv: None, + }; + + done(result, Some(entry), warnings) +} + +/// Revert one vendored cargo crate: restore the lock entry's original +/// `source`/`checksum`, drop the `[patch.crates-io]` entry, and remove the +/// uuid dir. +pub async fn revert_cargo_vendor( + entry: &VendorEntry, + project_root: &Path, + dry_run: bool, +) -> RevertOutcome { + // SECURITY: the coordinates and uuid come from a committed, tamper-able + // state.json and key a directory we are about to delete — re-validate + // fail-closed before any disk access (mirrors the vendor-side guard). + let Some((name, version)) = parse_cargo_purl(&entry.base_purl) else { + return RevertOutcome::failed(format!("not a cargo purl: {}", entry.base_purl)); + }; + if !is_safe_single_segment(name) || !is_safe_single_segment(version) { + return RevertOutcome::failed(format!( + "refusing to revert unsafe cargo coordinates `{name}`/`{version}`" + )); + } + let Some(base_rel) = vendor_uuid_dir_rel("cargo", &entry.uuid) else { + return RevertOutcome::failed(format!( + "refusing to revert: `{}` is not a canonical patch uuid", + entry.uuid + )); + }; + + let mut out = RevertOutcome::ok(); + + if let Some(lock) = &entry.lock { + let original = LockEntryOriginal { + source: lock.source.clone(), + checksum: lock.checksum.clone(), + }; + match cargo_lock::restore_lock_entry(project_root, name, version, &original, dry_run).await + { + Ok(true) => {} + Ok(false) => out.warnings.push(VendorWarning::new( + "lock_restore_skipped", + format!( + "the Cargo.lock entry for {name}@{version} is no longer in the \ + detached form (re-resolved or removed); left as-is" + ), + )), + Err(LockEditError::NoLockfile) => out.warnings.push(VendorWarning::new( + "lock_restore_skipped", + "Cargo.lock no longer exists; nothing to restore".to_string(), + )), + // Fail-closed on a corrupt/unwritable lock BEFORE touching the + // config entry — a half-revert (entry dropped, lock still + // path-form) would break every --locked build with no breadcrumb. + Err(e) => { + return RevertOutcome { + success: false, + warnings: out.warnings, + error: Some(format!("failed to restore the Cargo.lock entry: {e}")), + } + } + } + } + + if let Err(e) = cargo_config::drop_patch_entry(project_root, name, dry_run).await { + return RevertOutcome { + success: false, + warnings: out.warnings, + error: Some(format!("failed to update .cargo/config.toml: {e}")), + }; + } + + if !dry_run { + let uuid_dir: PathBuf = project_root.join(&base_rel); + let _ = remove_tree(&uuid_dir).await; // ignore NotFound + // Best-effort: prune the now-empty `.socket/vendor/cargo/` level so a + // fully-reverted project carries no vendor residue (`save_state` then + // prunes `.socket/vendor/` itself). `remove_dir` fails on non-empty. + if let Some(eco_dir) = uuid_dir.parent() { + let _ = tokio::fs::remove_dir(eco_dir).await; + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::VulnerabilityInfo; + use crate::patch::vendor::state::VENDOR_MARKER_FILE; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const PURL: &str = "pkg:cargo/cfg-if@1.0.4"; + const PRISTINE: &[u8] = b"pub fn cfg() {}\n"; + const PATCHED: &[u8] = b"pub fn cfg() { /* patched */ }\n"; + const SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; + const CHECKSUM: &str = "9d8f4e3bd2c8f1f5d1a3f5e7c9b1d3f5e7a9b1c3d5f7e9a1b3c5d7e9f1a3b5c7"; + + fn git_sha(bytes: &[u8]) -> String { + compute_git_sha256_from_bytes(bytes) + } + + fn copy_rel() -> String { + format!(".socket/vendor/cargo/{UUID}/cfg-if-1.0.4") + } + + fn lock_body() -> String { + format!( + "# This file is automatically @generated by Cargo.\n\ + # It is not intended for manual editing.\n\ + version = 4\n\ + \n\ + [[package]]\n\ + name = \"app\"\n\ + version = \"0.1.0\"\n\ + dependencies = [\n \"cfg-if\",\n]\n\ + \n\ + [[package]]\n\ + name = \"cfg-if\"\n\ + version = \"1.0.4\"\n\ + source = \"{SOURCE}\"\n\ + checksum = \"{CHECKSUM}\"\n" + ) + } + + fn record_with(files: HashMap) -> PatchRecord { + let mut vulnerabilities = HashMap::new(); + vulnerabilities.insert( + "GHSA-xxxx-yyyy-zzzz".to_string(), + VulnerabilityInfo { + cves: vec!["CVE-2026-0001".into()], + summary: "s".into(), + severity: "high".into(), + description: "d".into(), + }, + ); + PatchRecord { + uuid: UUID.into(), + exported_at: "t".into(), + files, + vulnerabilities, + description: String::new(), + license: String::new(), + tier: String::new(), + } + } + + /// Build a pristine registry-style crate dir (with a legacy checksum + /// sidecar to prove the skip), a blobs dir carrying the patched bytes, and + /// a consumer project (Cargo.toml + handwritten v4 Cargo.lock). Returns + /// (project_tmp, blobs, pristine_src, record). + async fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PatchRecord) { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + + let pristine = root.join("registry/cfg-if-1.0.4"); + tokio::fs::create_dir_all(pristine.join("src")).await.unwrap(); + tokio::fs::write(pristine.join("src/lib.rs"), PRISTINE).await.unwrap(); + tokio::fs::write( + pristine.join("Cargo.toml"), + "[package]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n", + ) + .await + .unwrap(); + // Older registry layouts carry this; the copy must skip it. + tokio::fs::write(pristine.join(".cargo-checksum.json"), "{\"files\":{}}") + .await + .unwrap(); + + let after = git_sha(PATCHED); + let blobs = root.join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + tokio::fs::write(blobs.join(&after), PATCHED).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "package/src/lib.rs".to_string(), + PatchFileInfo { + before_hash: git_sha(PRISTINE), + after_hash: after, + }, + ); + + tokio::fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\ncfg-if = \"1\"\n", + ) + .await + .unwrap(); + tokio::fs::write(root.join("Cargo.lock"), lock_body()).await.unwrap(); + + (dir, blobs, pristine, record_with(files)) + } + + async fn run_vendor( + purl: &str, + root: &Path, + blobs: &Path, + pristine: &Path, + record: &PatchRecord, + dry_run: bool, + ) -> VendorOutcome { + let sources = PatchSources::blobs_only(blobs); + vendor_cargo_crate( + purl, + pristine, + root, + record, + &sources, + "2026-06-09T00:00:00Z", + dry_run, + false, + ) + .await + } + + fn expect_done(outcome: VendorOutcome) -> (ApplyResult, Option, Vec) { + match outcome { + VendorOutcome::Done { + result, + entry, + warnings, + } => (result, entry, warnings), + VendorOutcome::Refused { code, detail } => { + panic!("expected Done, got Refused({code}): {detail}") + } + } + } + + fn expect_refused(outcome: VendorOutcome, want_code: &str) -> String { + match outcome { + VendorOutcome::Refused { code, detail } => { + assert_eq!(code, want_code, "refusal code: {detail}"); + detail + } + VendorOutcome::Done { result, .. } => { + panic!("expected Refused({want_code}), got Done (success={})", result.success) + } + } + } + + #[tokio::test] + async fn test_happy_path_wires_copy_config_lock_and_marker() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + // A qualified PURL must collapse to the base in the ledger/marker. + let qualified = format!("{PURL}?repository_url=https://crates.io"); + let (result, entry, warnings) = + expect_done(run_vendor(&qualified, root, &blobs, &pristine, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + // Copy holds the patched bytes and NO checksum sidecar. + let copy = root.join(copy_rel()); + assert_eq!(tokio::fs::read(copy.join("src/lib.rs")).await.unwrap(), PATCHED); + assert!(!copy.join(".cargo-checksum.json").exists()); + // The registry pristine is untouched. + assert_eq!(tokio::fs::read(pristine.join("src/lib.rs")).await.unwrap(), PRISTINE); + + // Config entry points at the uuid-level copy. + let entries = cargo_config::read_patch_entries(root).await; + assert_eq!(entries["cfg-if"].path.as_deref(), Some(copy_rel().as_str())); + + // The lock entry is detached (source+checksum gone), rest preserved. + let lock = tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(); + assert!(!lock.contains("source =")); + assert!(!lock.contains("checksum =")); + assert!(lock.contains("name = \"cfg-if\"\nversion = \"1.0.4\"\n")); + + // Marker sits in the uuid dir, carrying the vuln + uuid + base purl. + let marker = tokio::fs::read_to_string( + root.join(format!(".socket/vendor/cargo/{UUID}/{VENDOR_MARKER_FILE}")), + ) + .await + .unwrap(); + assert!(marker.contains(UUID)); + assert!(marker.contains("GHSA-xxxx-yyyy-zzzz")); + assert!(marker.contains(&format!("\"purl\": \"{PURL}\"")), "{marker}"); + + // Ledger entry shape. + let entry = entry.expect("entry on success"); + assert_eq!(entry.ecosystem, "cargo"); + assert_eq!(entry.base_purl, PURL, "qualifiers stripped"); + assert_eq!(entry.uuid, UUID); + assert_eq!(entry.artifact.path, copy_rel()); + assert_eq!(entry.artifact.sha256, "", "dir-shaped artifact"); + assert_eq!( + entry.lock, + Some(CargoLockOriginal { + source: SOURCE.into(), + checksum: Some(CHECKSUM.into()), + }) + ); + assert!(!entry.took_over_go_patches); + assert_eq!(entry.wiring.len(), 2); + let cfg = &entry.wiring[0]; + assert_eq!((cfg.file.as_str(), cfg.kind.as_str()), (".cargo/config.toml", "cargo_patch_entry")); + assert_eq!(cfg.action, WiringAction::Added); + assert_eq!(cfg.key.as_deref(), Some("cfg-if")); + assert_eq!(cfg.new, Some(serde_json::Value::from(copy_rel()))); + let lockw = &entry.wiring[1]; + assert_eq!((lockw.file.as_str(), lockw.kind.as_str()), ("Cargo.lock", "cargo_lock_entry")); + assert_eq!(lockw.action, WiringAction::Rewritten); + assert_eq!(lockw.key.as_deref(), Some("cfg-if@1.0.4")); + assert_eq!( + lockw.original, + Some(serde_json::json!({ "source": SOURCE, "checksum": CHECKSUM })) + ); + } + + #[tokio::test] + async fn test_refuses_locked_version_mismatch() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + // Lock resolves a different version → the [patch] would be unused. + tokio::fs::write( + root.join("Cargo.lock"), + format!("version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.5\"\nsource = \"{SOURCE}\"\n"), + ) + .await + .unwrap(); + let detail = expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "locked_version_mismatch", + ); + assert!(detail.contains("1.0.5") && detail.contains("1.0.4"), "{detail}"); + // Refused before any write. + assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); + assert!(!root.join(".cargo").exists()); + + // A crate absent from the lock entirely is equally refused. (A lock + // with no [[package]] array at all reads as "no usable lock" and + // skips the cross-check, so give it one unrelated package.) + tokio::fs::write( + root.join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .await + .unwrap(); + expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "locked_version_mismatch", + ); + } + + #[tokio::test] + async fn test_refuses_user_authored_patch_entry() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + tokio::fs::create_dir_all(root.join(".cargo")).await.unwrap(); + let user_cfg = "[patch.crates-io]\ncfg-if = { path = \"../my-fork\" }\n"; + tokio::fs::write(root.join(".cargo/config.toml"), user_cfg).await.unwrap(); + + expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "user_authored_patch_entry", + ); + // Nothing written: config byte-identical, no copy, lock untouched. + assert_eq!( + tokio::fs::read_to_string(root.join(".cargo/config.toml")).await.unwrap(), + user_cfg + ); + assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + lock_body() + ); + } + + #[tokio::test] + async fn test_refuses_cargo_vendor_tree() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + tokio::fs::create_dir_all(root.join("vendor/cfg-if-1.0.4")).await.unwrap(); + expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "already_vendored_in_tree", + ); + assert!(!root.join(".cargo").exists(), "refused before any write"); + } + + #[tokio::test] + async fn test_no_lockfile_proceeds_with_warning() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + tokio::fs::remove_file(root.join("Cargo.lock")).await.unwrap(); + + let (result, entry, warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(result.success, "{:?}", result.error); + assert!( + warnings.iter().any(|w| w.code == "no_lockfile"), + "warnings: {warnings:?}" + ); + let entry = entry.unwrap(); + assert_eq!(entry.lock, None, "nothing was detached"); + assert_eq!(entry.wiring.len(), 1, "only the config wire is recorded"); + // The copy + config still landed. + assert!(root.join(copy_rel()).join("src/lib.rs").exists()); + assert!(cargo_config::read_patch_entries(root).await["cfg-if"].socket_owned); + } + + #[tokio::test] + async fn test_half_build_rolls_back_copy() { + let (dir, _blobs, pristine, record) = fixture().await; + let root = dir.path(); + // Empty blobs dir → the blob read fails mid-apply. + let empty = root.join(".socket/empty-blobs"); + tokio::fs::create_dir_all(&empty).await.unwrap(); + + let (result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &empty, &pristine, &record, false).await); + assert!(!result.success); + assert!(entry.is_none()); + assert!( + !root.join(format!(".socket/vendor/cargo/{UUID}")).join("cfg-if-1.0.4").exists(), + "half-built copy must be rolled back" + ); + // No config entry, lock untouched. + assert!(cargo_config::read_patch_entries(root).await.is_empty()); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + lock_body() + ); + } + + #[tokio::test] + async fn test_lock_detach_failure_unwinds_config_and_copy() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + // The lock entry exists at the right version but is NOT registry-shaped + // (no `source` — e.g. an existing user path-dep): pre-flight passes, + // detach errs with NotRegistry AFTER the config write → must unwind. + tokio::fs::write( + root.join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n", + ) + .await + .unwrap(); + + let (result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(!result.success); + assert!(entry.is_none()); + assert!( + result.error.as_deref().unwrap_or("").contains("Cargo.lock"), + "error names the lock: {:?}", + result.error + ); + // Unwound: config entry gone (file pruned), copy gone, lock unchanged. + assert!(cargo_config::read_patch_entries(root).await.is_empty()); + assert!(!root.join(copy_rel()).exists()); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n" + ); + } + + #[tokio::test] + async fn test_in_sync_rerun_is_byte_stable() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + + let copy = root.join(copy_rel()).join("src/lib.rs"); + let cfg = root.join(".cargo/config.toml"); + let lock = root.join("Cargo.lock"); + let copy1 = tokio::fs::read(©).await.unwrap(); + let cfg1 = tokio::fs::read(&cfg).await.unwrap(); + let lock1 = tokio::fs::read(&lock).await.unwrap(); + + let (result, entry, warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(result.success); + assert!(result.files_patched.is_empty(), "in-sync re-run patches nothing"); + assert!( + entry.is_none(), + "hot path must not emit a fresh entry (it would clobber the ledger's lock originals)" + ); + assert!(warnings.is_empty()); + assert_eq!(tokio::fs::read(©).await.unwrap(), copy1, "copy unchanged"); + assert_eq!(tokio::fs::read(&cfg).await.unwrap(), cfg1, "config unchanged"); + assert_eq!(tokio::fs::read(&lock).await.unwrap(), lock1, "lock unchanged"); + } + + #[tokio::test] + async fn test_dry_run_writes_nothing() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let (result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, true).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none(), "dry-run emits no entry"); + assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); + assert!(!root.join(".cargo").exists()); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + lock_body() + ); + } + + #[tokio::test] + async fn test_revert_round_trip_restores_everything() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let (_result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + let entry = entry.unwrap(); + + let out = revert_cargo_vendor(&entry, root, false).await; + assert!(out.success, "{:?}", out.error); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + + // Lock byte-identical to the pristine fixture. + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + lock_body() + ); + // Config entry gone — and the socket-created file + .cargo/ pruned. + assert!(cargo_config::read_patch_entries(root).await.is_empty()); + assert!(!root.join(".cargo").exists()); + // The uuid dir is gone, and the empty eco level pruned with it. + assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); + assert!(!root.join(".socket/vendor/cargo").exists()); + } + + #[tokio::test] + async fn test_revert_warns_when_lock_re_resolved() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let (_result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + let entry = entry.unwrap(); + // A third party re-resolved the lock (source back) after vendoring. + tokio::fs::write(root.join("Cargo.lock"), lock_body()).await.unwrap(); + + let out = revert_cargo_vendor(&entry, root, false).await; + assert!(out.success, "{:?}", out.error); + assert!( + out.warnings.iter().any(|w| w.code == "lock_restore_skipped"), + "{:?}", + out.warnings + ); + // The re-resolved lock is left alone, the rest still reverted. + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + lock_body() + ); + assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); + } + + #[tokio::test] + async fn test_legacy_redirect_entry_is_taken_over() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + // Residue from the retired redirect backend: a legacy-path entry. + tokio::fs::create_dir_all(root.join(".cargo")).await.unwrap(); + tokio::fs::write( + root.join(".cargo/config.toml"), + "[patch.crates-io]\ncfg-if = { path = \".socket/cargo-patches/cfg-if-1.0.4\" }\n", + ) + .await + .unwrap(); + + let (result, entry, warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(result.success, "{:?}", result.error); + assert!( + warnings.iter().any(|w| w.code == "vendor_takeover"), + "legacy takeover surfaced: {warnings:?}" + ); + let entry = entry.unwrap(); + let cfg = &entry.wiring[0]; + assert_eq!(cfg.action, WiringAction::Rewritten); + assert_eq!( + cfg.original, + Some(serde_json::Value::from(".socket/cargo-patches/cfg-if-1.0.4")) + ); + // The live entry now points at the vendor copy. + assert_eq!( + cargo_config::read_patch_entries(root).await["cfg-if"].path.as_deref(), + Some(copy_rel().as_str()) + ); + } + + // ── filesystem-safety: coordinate traversal ────────────────────────── + + /// SECURITY regression: a tampered manifest PURL with `..` in the crate + /// name must NOT let vendor copy + write the patched tree outside + /// `.socket/vendor/cargo/`. + #[tokio::test] + async fn test_refuses_traversal_coordinates() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let escaped = root.parent().unwrap().join("escape-1.0.0"); + let _ = remove_tree(&escaped).await; + + expect_refused( + run_vendor("pkg:cargo/../../../escape@1.0.0", root, &blobs, &pristine, &record, false) + .await, + "unsafe_coordinates", + ); + expect_refused( + run_vendor("pkg:cargo/cfg-if@../../../evil", root, &blobs, &pristine, &record, false) + .await, + "unsafe_coordinates", + ); + expect_refused( + run_vendor("pkg:npm/not-cargo@1.0.0", root, &blobs, &pristine, &record, false).await, + "unsafe_coordinates", + ); + assert!(!escaped.exists(), "no copy outside the project"); + assert!(!root.join(".cargo").exists(), "no wiring written"); + let _ = remove_tree(&escaped).await; + } + + /// SECURITY regression: a poisoned uuid (`..`, uppercase, traversal) must + /// be refused — it keys the on-disk dir vendor creates and revert deletes. + #[tokio::test] + async fn test_refuses_poisoned_uuid() { + let (dir, blobs, pristine, mut record) = fixture().await; + let root = dir.path(); + for bad in ["..", "../../../etc", "9F6B2C4E-1D3A-4F6B-8C2D-7E5A9B1C3D5F"] { + record.uuid = bad.to_string(); + let detail = expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "unsafe_coordinates", + ); + assert!(detail.contains("uuid"), "{detail}"); + } + assert!(!root.join(".cargo").exists()); + } + + /// SECURITY regression: revert re-validates the (tamper-able) ledger entry + /// fail-closed rather than `remove_tree`-ing a poisoned path. + #[tokio::test] + async fn test_revert_refuses_traversal_entry() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let (_result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + let good = entry.unwrap(); + + let mut bad_uuid = good.clone(); + bad_uuid.uuid = "../../../precious".to_string(); + assert!(!revert_cargo_vendor(&bad_uuid, root, false).await.success); + + let mut bad_purl = good.clone(); + bad_purl.base_purl = "pkg:cargo/../../../escape@1.0.0".to_string(); + assert!(!revert_cargo_vendor(&bad_purl, root, false).await.success); + + // The refusals deleted nothing: the vendored state is fully intact. + assert!(root.join(copy_rel()).exists()); + assert!(cargo_config::read_patch_entries(root).await["cfg-if"].socket_owned); + } + + #[tokio::test] + async fn test_empty_files_is_noop() { + let (dir, blobs, pristine, mut record) = fixture().await; + let root = dir.path(); + record.files = HashMap::new(); + let (result, entry, warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(result.success); + assert!(entry.is_none()); + assert!(warnings.is_empty()); + assert!(!root.join(".cargo").exists()); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + lock_body() + ); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/cargo_config.rs b/crates/socket-patch-core/src/patch/vendor/cargo_config.rs index ed2edb3..5325718 100644 --- a/crates/socket-patch-core/src/patch/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/patch/vendor/cargo_config.rs @@ -1 +1,698 @@ -//! (stub — implementation lands with its backend phase) +//! Read / write `/.cargo/config.toml` for the cargo vendor +//! backend's `[patch.crates-io]` wiring. +//! +//! Mirrors the contract style of [`crate::pth_hook::edit`]: pure +//! `fn(&str) -> Result, String>` transforms (`Some(new)` = +//! changed, `None` = already in the desired state) wrapped by async +//! read-or-create / write helpers that honour `dry_run` and preserve the +//! user's existing formatting + comments via `toml_edit`. +//! +//! ## Ownership model (no sidecar manifest) +//! A `[patch.crates-io]` entry is *socket-owned* iff its `path` value lies +//! under `.socket/vendor/cargo/` (this backend's committed copies) **or** the +//! legacy `.socket/cargo-patches/` (the retired `[patch]`-redirect backend) — +//! recognising the legacy prefix lets vendor take over / clean up entries left +//! by old releases instead of refusing them as user-authored. Anything else — +//! a `git`/`registry` source, or a `path` pointing elsewhere — is +//! user-authored and is never modified or removed. The path prefix is the +//! entire ownership signal; there is no `managed.json`. +//! +//! ## Relative-path semantics +//! A relative `path` in a config-file `[patch]` entry is resolved by cargo +//! relative to the **parent of the `.cargo/` directory** (i.e. the project +//! root), so the committed `/.socket/vendor/cargo//-` +//! copy is found on any clone (spike-verified, including builds invoked from a +//! subdirectory — see `spikes/PHASE0-FINDINGS.txt` cargo claim 7). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use tokio::fs; +use toml_edit::{DocumentMut, InlineTable, Item, Table, Value}; + +use crate::utils::fs::atomic_write_bytes; + +/// Project-relative root of the vendor backend's committed crate copies. An +/// entry whose `path` is under this prefix is socket-owned. +pub const CARGO_VENDOR_DIR: &str = ".socket/vendor/cargo"; + +/// Project-relative root of the retired `[patch]`-redirect backend's copies. +/// Entries under this prefix are still recognised as socket-owned so vendor +/// can rewrite (take over) or drop residue from old releases rather than +/// refusing it as user-authored. +pub const LEGACY_CARGO_PATCHES_DIR: &str = ".socket/cargo-patches"; + +/// Info about one `[patch.crates-io]` entry, for vendor pre-flight / verify. +#[derive(Debug, Clone)] +pub struct PatchEntryInfo { + /// The `path` value as written (verbatim), or `None` for a non-path + /// source (e.g. `git`/`registry`). + pub path: Option, + /// True iff `path` is under [`CARGO_VENDOR_DIR`] or + /// [`LEGACY_CARGO_PATCHES_DIR`]. + pub socket_owned: bool, +} + +// ── public async API ───────────────────────────────────────────────────────── + +/// Upsert `[patch.crates-io]. = { path = "" }`, where +/// `rel_path` is the project-relative copy path +/// (`.socket/vendor/cargo//-`). Idempotent. A +/// socket-owned same-name entry (either prefix) is refreshed in place — the +/// legacy-prefix rewrite is how vendor takes over an old redirect entry. +/// Returns whether the file changed. Errors (without writing) if a same-name +/// entry exists but is user-authored. +pub async fn ensure_patch_entry( + project_root: &Path, + name: &str, + rel_path: &str, + dry_run: bool, +) -> Result { + edit_config(project_root, dry_run, |c| { + upsert_patch_entry(c, name, rel_path) + }) + .await +} + +/// Remove a *socket-owned* `[patch.crates-io].` entry, cleaning up empty +/// `[patch.crates-io]` / `[patch]` tables. A user-authored or absent entry is a +/// no-op. Returns whether the file changed. +pub async fn drop_patch_entry( + project_root: &Path, + name: &str, + dry_run: bool, +) -> Result { + edit_config(project_root, dry_run, |c| remove_patch_entry(c, name)).await +} + +/// Read all `[patch.crates-io]` entries. Read-only; a missing or malformed +/// config yields an empty map (callers treat that as "no managed entries"). +pub async fn read_patch_entries(project_root: &Path) -> HashMap { + let path = config_path(project_root).await; + match fs::read_to_string(&path).await { + Ok(content) => parse_patch_entries(&content), + Err(_) => HashMap::new(), + } +} + +// ── config-file resolution + read-or-create write ──────────────────────────── + +/// Resolve the config file under `/.cargo/`. Prefers an existing +/// `config.toml`, then an existing legacy `config`, else `config.toml` (created +/// on first write). +async fn config_path(project_root: &Path) -> PathBuf { + let dir = project_root.join(".cargo"); + let toml = dir.join("config.toml"); + if fs::metadata(&toml).await.is_ok() { + return toml; + } + let legacy = dir.join("config"); + if fs::metadata(&legacy).await.is_ok() { + return legacy; + } + toml +} + +/// Apply a pure transform to the config file, writing only if it changed and +/// `!dry_run`. A missing file is treated as empty (and created on write). +async fn edit_config( + project_root: &Path, + dry_run: bool, + transform: impl FnOnce(&str) -> Result, String>, +) -> Result { + let path = config_path(project_root).await; + let content = match fs::read_to_string(&path).await { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(format!("read {}: {e}", path.display())), + }; + match transform(&content)? { + None => Ok(false), + Some(new) => { + if !dry_run { + if new.trim().is_empty() { + // The edit emptied the file (all socket-owned content + // removed and no user content — comments / other tables — + // remained). Delete it, and prune the now-empty `.cargo/` + // dir, so a full revert restores the exact pre-vendor tree + // rather than leaving an empty `.cargo/config.toml` + // behind. A file with surviving user content never trims + // to empty, so this only fires for a config that was + // entirely socket's. + match fs::remove_file(&path).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("remove {}: {e}", path.display())), + } + if let Some(parent) = path.parent() { + // Best-effort: `remove_dir` only succeeds when the dir + // is empty, so a `.cargo/` holding other files (e.g. + // credentials) is left intact. + let _ = fs::remove_dir(parent).await; + } + } else { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| format!("create {}: {e}", parent.display()))?; + } + // `.cargo/config.toml` is a *user-owned* file — it can hold + // `[build]`, `[net]`, credentials-adjacent settings, and + // comments alongside our `[patch]` entries. Commit + // atomically (stage + fsync + rename) so a crash mid-write + // can never truncate content we only meant to add one + // entry to. + atomic_write_bytes(&path, new.as_bytes()) + .await + .map_err(|e| format!("write {}: {e}", path.display()))?; + } + } + Ok(true) + } + } +} + +// ── pure transforms ────────────────────────────────────────────────────────── + +/// True if a `[patch]` `path` value lies under a socket-owned prefix +/// ([`CARGO_VENDOR_DIR`] or the legacy [`LEGACY_CARGO_PATCHES_DIR`]). +fn path_is_socket_owned(path: &str) -> bool { + let norm = path.replace('\\', "/"); + for dir in [CARGO_VENDOR_DIR, LEGACY_CARGO_PATCHES_DIR] { + let prefix = format!("{dir}/"); + if norm.starts_with(&prefix) || norm.contains(&format!("/{prefix}")) { + return true; + } + } + false +} + +/// The `path` string of a `[patch]` entry (inline table or sub-table), if any. +fn entry_path(item: &Item) -> Option<&str> { + item.as_table_like() + .and_then(|t| t.get("path")) + .and_then(Item::as_str) +} + +/// Ensure `parent[key]` is a table, creating it if absent. Errors if present +/// but a non-table. Mirrors `pth_hook::edit::ensure_table`. +fn ensure_table<'a>( + parent: &'a mut Table, + key: &str, + implicit: bool, +) -> Result<&'a mut Table, String> { + if !parent.contains_key(key) { + let mut t = Table::new(); + t.set_implicit(implicit); + parent.insert(key, Item::Table(t)); + } + parent + .get_mut(key) + .and_then(Item::as_table_mut) + .ok_or_else(|| format!("`{key}` is not a table")) +} + +fn upsert_patch_entry(content: &str, name: &str, rel_path: &str) -> Result, String> { + let mut doc = content + .parse::() + .map_err(|e| format!("Invalid .cargo/config.toml: {e}"))?; + + let root = doc.as_table_mut(); + // `[patch]` is a parent table that only ever holds `[patch.crates-io]`, so + // keep it implicit; `[patch.crates-io]` is the explicit one we write into. + let patch = ensure_table(root, "patch", true)?; + let crates_io = ensure_table(patch, "crates-io", false)?; + + if let Some(existing) = crates_io.get(name) { + match entry_path(existing) { + Some(p) if p == rel_path => return Ok(None), // already correct + Some(p) if path_is_socket_owned(p) => {} // socket-owned, refresh + _ => { + return Err(format!( + "`patch.crates-io.{name}` is user-authored; refusing to overwrite" + )); + } + } + } + + let mut it = InlineTable::new(); + it.insert("path", Value::from(rel_path)); + crates_io.insert(name, Item::Value(Value::InlineTable(it))); + Ok(Some(doc.to_string())) +} + +fn remove_patch_entry(content: &str, name: &str) -> Result, String> { + let mut doc = content + .parse::() + .map_err(|e| format!("Invalid .cargo/config.toml: {e}"))?; + + let mut removed = false; + if let Some(patch) = doc.get_mut("patch").and_then(Item::as_table_mut) { + let mut crates_io_empty = false; + if let Some(crates_io) = patch.get_mut("crates-io").and_then(Item::as_table_mut) { + if matches!(crates_io.get(name).and_then(entry_path), Some(p) if path_is_socket_owned(p)) + { + crates_io.remove(name); + removed = true; + crates_io_empty = crates_io.is_empty(); + } + } + if crates_io_empty { + patch.remove("crates-io"); + } + } + if !removed { + return Ok(None); + } + if doc + .get("patch") + .and_then(Item::as_table) + .map(Table::is_empty) + .unwrap_or(false) + { + doc.as_table_mut().remove("patch"); + } + Ok(Some(doc.to_string())) +} + +fn parse_patch_entries(content: &str) -> HashMap { + let mut out = HashMap::new(); + let doc = match content.parse::() { + Ok(d) => d, + Err(_) => return out, + }; + let crates_io = doc + .get("patch") + .and_then(Item::as_table) + .and_then(|t| t.get("crates-io")) + .and_then(Item::as_table); + if let Some(tbl) = crates_io { + for (name, item) in tbl.iter() { + let path = entry_path(item).map(str::to_string); + let socket_owned = path.as_deref().map(path_is_socket_owned).unwrap_or(false); + out.insert(name.to_string(), PatchEntryInfo { path, socket_owned }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + fn vendor_path(name: &str, version: &str) -> String { + format!("{CARGO_VENDOR_DIR}/{UUID}/{name}-{version}") + } + + fn parse(s: &str) -> DocumentMut { + s.parse::().unwrap() + } + + // ── path ownership ─────────────────────────────────────────────── + #[test] + fn test_is_socket_owned() { + assert!(path_is_socket_owned(&vendor_path("cfg-if", "1.0.4"))); + assert!(path_is_socket_owned("./.socket/vendor/cargo/u/x-1.0.0")); // contains "/.socket/…" + assert!(path_is_socket_owned("sub/.socket/vendor/cargo/u/x-1.0.0")); + assert!(path_is_socket_owned(r".socket\vendor\cargo\u\x-1.0.0")); // backslash normalised + // Legacy redirect copies are recognised as ours (takeover / cleanup). + assert!(path_is_socket_owned(".socket/cargo-patches/cfg-if-1.0.0")); + assert!(path_is_socket_owned("./.socket/cargo-patches/x-1.0.0")); + // User paths are not. + assert!(!path_is_socket_owned("vendor/cfg-if")); + assert!(!path_is_socket_owned("../cfg-if")); + assert!(!path_is_socket_owned("/abs/.socketX/vendor/cargo/x")); + // Other ecosystems' vendor dirs are not cargo-owned entries. + assert!(!path_is_socket_owned(".socket/vendor/npm/u/x.tgz")); + } + + // ── upsert ─────────────────────────────────────────────────────── + #[test] + fn test_upsert_into_empty_creates_entry() { + let want = vendor_path("cfg-if", "1.0.4"); + let out = upsert_patch_entry("", "cfg-if", &want).unwrap().unwrap(); + let doc = parse(&out); + assert_eq!( + entry_path(&doc["patch"]["crates-io"]["cfg-if"]), + Some(want.as_str()) + ); + // Idempotent: a second upsert is a no-op. + assert!(upsert_patch_entry(&out, "cfg-if", &want).unwrap().is_none()); + } + + #[test] + fn test_upsert_preserves_user_content() { + let toml = "# my config\n[build]\njobs = 4\n\n[patch.crates-io]\nother = { git = \"https://example.com/o.git\" }\n"; + let want = vendor_path("cfg-if", "1.0.4"); + let out = upsert_patch_entry(toml, "cfg-if", &want).unwrap().unwrap(); + assert!(out.contains("# my config")); + assert!(out.contains("jobs = 4")); + let doc = parse(&out); + // The user's git entry survives alongside ours. + assert_eq!( + doc["patch"]["crates-io"]["other"] + .as_table_like() + .and_then(|t| t.get("git")) + .and_then(Item::as_str), + Some("https://example.com/o.git") + ); + assert_eq!( + entry_path(&doc["patch"]["crates-io"]["cfg-if"]), + Some(want.as_str()) + ); + } + + #[test] + fn test_upsert_refuses_user_authored_same_name() { + let toml = "[patch.crates-io]\ncfg-if = { git = \"https://example.com/c.git\" }\n"; + assert!(upsert_patch_entry(toml, "cfg-if", &vendor_path("cfg-if", "1.0.4")).is_err()); + // A user path entry (not under a socket prefix) is equally protected. + let toml = "[patch.crates-io]\ncfg-if = { path = \"../my-fork\" }\n"; + assert!(upsert_patch_entry(toml, "cfg-if", &vendor_path("cfg-if", "1.0.4")).is_err()); + } + + #[test] + fn test_upsert_refreshes_socket_owned_uuid_bump() { + // A patch update changes the uuid level of the path; the entry is + // refreshed in place. + let old = format!("{CARGO_VENDOR_DIR}/11111111-2222-3333-4444-555555555555/cfg-if-1.0.4"); + let toml = format!("[patch.crates-io]\ncfg-if = {{ path = \"{old}\" }}\n"); + let want = vendor_path("cfg-if", "1.0.4"); + let out = upsert_patch_entry(&toml, "cfg-if", &want).unwrap().unwrap(); + let doc = parse(&out); + assert_eq!( + entry_path(&doc["patch"]["crates-io"]["cfg-if"]), + Some(want.as_str()) + ); + } + + #[test] + fn test_upsert_takes_over_legacy_redirect_entry() { + // An entry left by the retired redirect backend is socket-owned → + // rewritten to the vendor copy, never refused. + let toml = + "[patch.crates-io]\ncfg-if = { path = \".socket/cargo-patches/cfg-if-1.0.4\" }\n"; + let want = vendor_path("cfg-if", "1.0.4"); + let out = upsert_patch_entry(toml, "cfg-if", &want).unwrap().unwrap(); + let doc = parse(&out); + assert_eq!( + entry_path(&doc["patch"]["crates-io"]["cfg-if"]), + Some(want.as_str()) + ); + assert!(!out.contains("cargo-patches"), "legacy path gone"); + } + + // ── remove ─────────────────────────────────────────────────────── + #[test] + fn test_remove_socket_owned_cleans_empty_tables() { + let toml = format!( + "[patch.crates-io]\ncfg-if = {{ path = \"{}\" }}\n", + vendor_path("cfg-if", "1.0.4") + ); + let out = remove_patch_entry(&toml, "cfg-if").unwrap().unwrap(); + assert!(!out.contains("cfg-if")); + // Empty [patch.crates-io] and [patch] are pruned. + assert!(!out.contains("[patch")); + } + + #[test] + fn test_remove_legacy_entry_is_socket_owned() { + let toml = + "[patch.crates-io]\ncfg-if = { path = \".socket/cargo-patches/cfg-if-1.0.4\" }\n"; + let out = remove_patch_entry(toml, "cfg-if").unwrap().unwrap(); + assert!(!out.contains("cfg-if"), "legacy entry removable: {out}"); + } + + #[test] + fn test_remove_leaves_user_entry_and_table() { + let toml = format!( + "[patch.crates-io]\ncfg-if = {{ path = \"{}\" }}\nother = {{ git = \"https://example.com/o.git\" }}\n", + vendor_path("cfg-if", "1.0.4") + ); + let out = remove_patch_entry(&toml, "cfg-if").unwrap().unwrap(); + let doc = parse(&out); + assert!(doc["patch"]["crates-io"].get("cfg-if").is_none()); + assert!(doc["patch"]["crates-io"].get("other").is_some()); + } + + #[test] + fn test_remove_user_authored_same_name_is_noop() { + let toml = "[patch.crates-io]\ncfg-if = { git = \"https://example.com/c.git\" }\n"; + assert!(remove_patch_entry(toml, "cfg-if").unwrap().is_none()); + let toml = "[patch.crates-io]\ncfg-if = { path = \"../my-fork\" }\n"; + assert!(remove_patch_entry(toml, "cfg-if").unwrap().is_none()); + } + + #[test] + fn test_remove_absent_is_noop() { + assert!(remove_patch_entry("[build]\njobs = 2\n", "cfg-if") + .unwrap() + .is_none()); + } + + // ── read_patch_entries / parse ─────────────────────────────────── + #[test] + fn test_parse_entries_classifies_ownership() { + let toml = format!( + "[patch.crates-io]\nmine = {{ path = \"{}\" }}\nlegacy = {{ path = \".socket/cargo-patches/legacy-1.0.0\" }}\nyours = {{ git = \"https://example.com/y.git\" }}\ntheirs = {{ path = \"vendor/theirs\" }}\n", + vendor_path("mine", "1.0.0") + ); + let entries = parse_patch_entries(&toml); + assert!(entries["mine"].socket_owned); + assert!(entries["legacy"].socket_owned, "legacy prefix is ours"); + assert!(!entries["yours"].socket_owned); + assert_eq!(entries["yours"].path, None); + assert!(!entries["theirs"].socket_owned); + assert_eq!(entries["theirs"].path.as_deref(), Some("vendor/theirs")); + } + + #[test] + fn test_parse_entries_handles_subtable_form() { + let toml = format!( + "[patch.crates-io.mine]\npath = \"{}\"\n", + vendor_path("mine", "1.0.0") + ); + let entries = parse_patch_entries(&toml); + assert!(entries["mine"].socket_owned); + } + + #[test] + fn test_parse_malformed_is_empty() { + assert!(parse_patch_entries("this is = = not toml [[[").is_empty()); + } + + // ── formatting preservation ────────────────────────────────────── + #[test] + fn test_comments_and_indentation_preserved() { + let toml = "# socket-managed config\n[net]\nretry = 3 # keep retries\n"; + let out = upsert_patch_entry(toml, "cfg-if", &vendor_path("cfg-if", "1.0.4")) + .unwrap() + .unwrap(); + assert!(out.contains("# socket-managed config")); + assert!(out.contains("retry = 3 # keep retries")); + assert!(parse(&out)["patch"]["crates-io"].get("cfg-if").is_some()); + } + + // ── async wrappers ─────────────────────────────────────────────── + #[tokio::test] + async fn test_ensure_dry_run_does_not_create() { + let dir = tempfile::tempdir().unwrap(); + let changed = ensure_patch_entry(dir.path(), "cfg-if", &vendor_path("cfg-if", "1.0.4"), true) + .await + .unwrap(); + assert!(changed, "dry-run reports the change it would make"); + assert!( + !dir.path().join(".cargo/config.toml").exists(), + "dry-run must not create the file" + ); + } + + #[tokio::test] + async fn test_ensure_then_read_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let want = vendor_path("cfg-if", "1.0.4"); + assert!(ensure_patch_entry(dir.path(), "cfg-if", &want, false) + .await + .unwrap()); + let entries = read_patch_entries(dir.path()).await; + assert!(entries["cfg-if"].socket_owned); + assert_eq!(entries["cfg-if"].path.as_deref(), Some(want.as_str())); + // Re-running is a no-op (idempotent on disk). + assert!(!ensure_patch_entry(dir.path(), "cfg-if", &want, false) + .await + .unwrap()); + // Drop it. + assert!(drop_patch_entry(dir.path(), "cfg-if", false).await.unwrap()); + assert!(read_patch_entries(dir.path()).await.is_empty()); + } + + #[tokio::test] + async fn test_prefers_existing_legacy_config() { + let dir = tempfile::tempdir().unwrap(); + let cargo_dir = dir.path().join(".cargo"); + fs::create_dir_all(&cargo_dir).await.unwrap(); + // Only a legacy `config` (no extension) exists. + fs::write(cargo_dir.join("config"), "[build]\njobs = 2\n") + .await + .unwrap(); + assert!( + ensure_patch_entry(dir.path(), "cfg-if", &vendor_path("cfg-if", "1.0.4"), false) + .await + .unwrap() + ); + // We wrote into the legacy file, not a fresh config.toml. + assert!(!cargo_dir.join("config.toml").exists()); + let body = fs::read_to_string(cargo_dir.join("config")).await.unwrap(); + assert!(body.contains("cfg-if")); + assert!(body.contains("jobs = 2")); + } + + // ── exact-restore: emptied socket-created config is deleted ────── + #[tokio::test] + async fn test_drop_deletes_socket_created_config_and_dir() { + let dir = tempfile::tempdir().unwrap(); + // No `.cargo/` before vendoring. + assert!(!dir.path().join(".cargo").exists()); + assert!( + ensure_patch_entry(dir.path(), "cfg-if", &vendor_path("cfg-if", "1.0.4"), false) + .await + .unwrap() + ); + assert!(dir.path().join(".cargo/config.toml").exists()); + // Revert empties it → both the file and the now-empty `.cargo/` go. + assert!(drop_patch_entry(dir.path(), "cfg-if", false).await.unwrap()); + assert!( + !dir.path().join(".cargo/config.toml").exists(), + "an emptied socket-created config must be deleted, not left empty" + ); + assert!( + !dir.path().join(".cargo").exists(), + "the now-empty .cargo/ dir must be pruned" + ); + } + + #[tokio::test] + async fn test_drop_keeps_config_with_user_content() { + let dir = tempfile::tempdir().unwrap(); + let cargo_dir = dir.path().join(".cargo"); + fs::create_dir_all(&cargo_dir).await.unwrap(); + fs::write( + cargo_dir.join("config.toml"), + format!( + "[build]\njobs = 4\n\n[patch.crates-io]\ncfg-if = {{ path = \"{}\" }}\n", + vendor_path("cfg-if", "1.0.4") + ), + ) + .await + .unwrap(); + assert!(drop_patch_entry(dir.path(), "cfg-if", false).await.unwrap()); + // The file survives (user content remains); only our entry is gone. + let body = fs::read_to_string(cargo_dir.join("config.toml")).await.unwrap(); + assert!(body.contains("jobs = 4"), "user [build] table preserved"); + assert!(!body.contains("cfg-if")); + } + + #[tokio::test] + async fn test_drop_keeps_nonempty_cargo_dir() { + let dir = tempfile::tempdir().unwrap(); + let cargo_dir = dir.path().join(".cargo"); + fs::create_dir_all(&cargo_dir).await.unwrap(); + // A sibling file (e.g. credentials) means `.cargo/` must survive even + // though our config is emptied + deleted. + fs::write(cargo_dir.join("credentials.toml"), "[registry]\ntoken = \"x\"\n") + .await + .unwrap(); + assert!( + ensure_patch_entry(dir.path(), "cfg-if", &vendor_path("cfg-if", "1.0.4"), false) + .await + .unwrap() + ); + assert!(drop_patch_entry(dir.path(), "cfg-if", false).await.unwrap()); + assert!( + !cargo_dir.join("config.toml").exists(), + "emptied config is deleted" + ); + assert!( + cargo_dir.exists() && cargo_dir.join("credentials.toml").exists(), + ".cargo/ is kept because it still holds the user's credentials file" + ); + } + + // ── atomic-commit: stage+rename leaves no litter, never truncates ─ + /// List socket stage-file litter left under `.cargo/` after a commit. The + /// atomic writer stages a sibling and renames it over the target; if any + /// stage file survives, the commit aborted mid-flight (or the rename was + /// actually a copy) — both are litter the user would have to clean. + async fn stage_litter(cargo_dir: &Path) -> Vec { + let mut names = Vec::new(); + let mut rd = fs::read_dir(cargo_dir).await.unwrap(); + while let Some(e) = rd.next_entry().await.unwrap() { + let n = e.file_name().to_string_lossy().into_owned(); + if n.contains("socket-stage") { + names.push(n); + } + } + names + } + + #[tokio::test] + async fn test_commit_leaves_no_stage_litter() { + let dir = tempfile::tempdir().unwrap(); + assert!( + ensure_patch_entry(dir.path(), "cfg-if", &vendor_path("cfg-if", "1.0.4"), false) + .await + .unwrap() + ); + let cargo_dir = dir.path().join(".cargo"); + assert!( + stage_litter(&cargo_dir).await.is_empty(), + "create-path commit must rename the stage file away, not leave it" + ); + // A second, mutating upsert (uuid bump) must also clean up. + let bumped = format!("{CARGO_VENDOR_DIR}/11111111-2222-3333-4444-555555555555/cfg-if-1.0.4"); + assert!(ensure_patch_entry(dir.path(), "cfg-if", &bumped, false) + .await + .unwrap()); + assert!( + stage_litter(&cargo_dir).await.is_empty(), + "overwrite-path commit must rename the stage file away, not leave it" + ); + } + + #[tokio::test] + async fn test_commit_overwrites_existing_user_config_in_place() { + // The dangerous case the atomic writer protects: an existing user + // config we must edit in place. A non-atomic truncate-then-write would + // risk leaving this empty on a crash; here we assert the user content + // survives and the new entry lands, with no stage file left behind. + let dir = tempfile::tempdir().unwrap(); + let cargo_dir = dir.path().join(".cargo"); + fs::create_dir_all(&cargo_dir).await.unwrap(); + fs::write( + cargo_dir.join("config.toml"), + "# user comment\n[build]\njobs = 7\n\n[net]\nretry = 5\n", + ) + .await + .unwrap(); + + assert!( + ensure_patch_entry(dir.path(), "cfg-if", &vendor_path("cfg-if", "1.0.4"), false) + .await + .unwrap() + ); + + let body = fs::read_to_string(cargo_dir.join("config.toml")) + .await + .unwrap(); + assert!(body.contains("# user comment"), "comment preserved"); + assert!(body.contains("jobs = 7"), "[build] preserved"); + assert!(body.contains("retry = 5"), "[net] preserved"); + assert!(body.contains("cfg-if"), "our entry was added"); + assert!( + stage_litter(&cargo_dir).await.is_empty(), + "in-place overwrite must not leave a stage file" + ); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs b/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs index ed2edb3..ea29e1c 100644 --- a/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs @@ -1 +1,456 @@ -//! (stub — implementation lands with its backend phase) +//! Surgical `Cargo.lock` edits for the cargo vendor backend. +//! +//! A `[patch.crates-io]` path entry alone does NOT survive `cargo build +//! --locked`: the lock still records the crate's registry `source` + +//! `checksum`, so cargo wants to re-lock and `--locked` fails closed with a +//! generic error (spike-verified — `spikes/PHASE0-FINDINGS.txt` cargo claim +//! 1). Deleting exactly the `source` and `checksum` keys from the crate's +//! `[[package]]` entry makes cargo accept the path patch as the lock's sole +//! provider; the edited lock is **byte-stable across builds** (locked and +//! unlocked, claims 2/4) and the `dependencies` arrays reference the crate by +//! plain name, so nothing else needs rewriting (claim 8). +//! +//! The lock is generated-but-committed, so edits are text-preserving +//! (`toml_edit`): untouched entries, the `@generated` header comment, and the +//! `version = 4` line keep their exact bytes — zero formatting churn in the +//! committed diff. +//! +//! The removed `source`/`checksum` pair is not recoverable offline (the +//! checksum is the sha256 of the registry `.crate` tarball, not of the +//! extracted tree), so [`detach_lock_entry`] returns it for the vendor ledger +//! ([`super::state::CargoLockOriginal`]) and [`restore_lock_entry`] writes it +//! back on revert. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use toml_edit::{DocumentMut, Item, Table}; + +use crate::utils::fs::atomic_write_bytes; + +/// The original lock fields removed by [`detach_lock_entry`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LockEntryOriginal { + pub source: String, + pub checksum: Option, +} + +/// Why a lock edit could not be performed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LockEditError { + /// `Cargo.lock` does not exist (callers proceed with a warning — the + /// first build generates a path-form lock). + NoLockfile, + /// No `[[package]]` entry matches the name+version. + EntryMissing, + /// The entry has no `source` (a workspace/path/git dependency) — there is + /// nothing registry-shaped to detach; callers refuse upstream. + NotRegistry, + Io(String), + Parse(String), +} + +impl std::fmt::Display for LockEditError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoLockfile => write!(f, "Cargo.lock not found"), + Self::EntryMissing => write!(f, "no matching [[package]] entry in Cargo.lock"), + Self::NotRegistry => write!( + f, + "the Cargo.lock entry is not a registry dependency (no `source`)" + ), + Self::Io(e) => write!(f, "Cargo.lock I/O error: {e}"), + Self::Parse(e) => write!(f, "Cargo.lock parse error: {e}"), + } + } +} + +/// Read + parse `/Cargo.lock`, mapping errors to [`LockEditError`]. +async fn read_lock(project_root: &Path) -> Result<(std::path::PathBuf, DocumentMut), LockEditError> { + let path = project_root.join("Cargo.lock"); + let content = match tokio::fs::read_to_string(&path).await { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(LockEditError::NoLockfile) + } + Err(e) => return Err(LockEditError::Io(e.to_string())), + }; + let doc = content + .parse::() + .map_err(|e| LockEditError::Parse(e.to_string()))?; + Ok((path, doc)) +} + +/// Find the index of the `[[package]]` table matching `name`+`version`. +fn find_package_index(doc: &DocumentMut, name: &str, version: &str) -> Option { + let pkgs = doc.get("package")?.as_array_of_tables()?; + pkgs.iter().position(|t| { + t.get("name").and_then(Item::as_str) == Some(name) + && t.get("version").and_then(Item::as_str) == Some(version) + }) +} + +fn package_table_mut(doc: &mut DocumentMut, idx: usize) -> Result<&mut Table, LockEditError> { + doc.get_mut("package") + .and_then(Item::as_array_of_tables_mut) + .and_then(|a| a.get_mut(idx)) + .ok_or(LockEditError::EntryMissing) +} + +/// Commit the edited lock atomically (stage + fsync + rename). The lock is a +/// committed file shared with cargo itself; a torn write would corrupt the +/// whole project's resolution, so never truncate-in-place. +async fn write_lock(path: &Path, doc: &DocumentMut) -> Result<(), LockEditError> { + atomic_write_bytes(path, doc.to_string().as_bytes()) + .await + .map_err(|e| LockEditError::Io(e.to_string())) +} + +/// Detach the `[[package]]` entry for `name`+`version` from the registry: +/// remove ONLY its `source` and `checksum` keys, returning the verbatim +/// originals for the vendor ledger. Everything else in the lock — including +/// the entry's own `name`/`version`/`dependencies` — keeps its exact bytes. +/// +/// `dry_run` performs the full lookup (so refusals are accurate) but writes +/// nothing. +pub async fn detach_lock_entry( + project_root: &Path, + name: &str, + version: &str, + dry_run: bool, +) -> Result { + let (path, mut doc) = read_lock(project_root).await?; + let idx = find_package_index(&doc, name, version).ok_or(LockEditError::EntryMissing)?; + let table = package_table_mut(&mut doc, idx)?; + + // A workspace/path/git dependency has no `source` — vendoring it would be + // wrong (the user already controls those bytes); refuse. + let source = match table.get("source").and_then(Item::as_str) { + Some(s) => s.to_string(), + None => return Err(LockEditError::NotRegistry), + }; + let checksum = table + .get("checksum") + .and_then(Item::as_str) + .map(str::to_string); + + table.remove("source"); + table.remove("checksum"); + + if !dry_run { + write_lock(&path, &doc).await?; + } + Ok(LockEntryOriginal { source, checksum }) +} + +/// Re-attach the original `source`/`checksum` to the `name`+`version` entry on +/// revert. Returns `Ok(false)` when the entry is no longer in the detached +/// form — it is absent (the dependency was dropped) or already carries a +/// `source` (cargo/the user re-resolved it) — in which case the lock is left +/// alone and the caller warns instead of clobbering a newer resolution. +pub async fn restore_lock_entry( + project_root: &Path, + name: &str, + version: &str, + original: &LockEntryOriginal, + dry_run: bool, +) -> Result { + let (path, mut doc) = read_lock(project_root).await?; + let Some(idx) = find_package_index(&doc, name, version) else { + return Ok(false); + }; + let table = package_table_mut(&mut doc, idx)?; + if table.get("source").is_some() { + return Ok(false); + } + + table.insert("source", toml_edit::value(original.source.as_str())); + if let Some(checksum) = &original.checksum { + table.insert("checksum", toml_edit::value(checksum.as_str())); + } + // `insert` appends, but cargo's canonical key order is + // name/version/source/checksum/dependencies — restore it so the reverted + // lock is byte-identical to what cargo originally generated (no diff + // churn, and the round-trip is verifiable in tests). + let rank = |k: &str| match k { + "name" => 0, + "version" => 1, + "source" => 2, + "checksum" => 3, + _ => 4, // dependencies / replace / anything else stays after + }; + table.sort_values_by(|k1, _, k2, _| rank(k1.get()).cmp(&rank(k2.get()))); + + if !dry_run { + write_lock(&path, &doc).await?; + } + Ok(true) +} + +/// Parse `/Cargo.lock` into `name -> {resolved versions}`. Returns +/// `None` when the lockfile is absent, unreadable, unparseable, or missing the +/// `[[package]]` array — in every such case the caller's version cross-check +/// is skipped (a malformed lock would itself break a real `cargo build`). +/// Multi-version aware: a v4 lock may resolve the same name at several +/// versions. Reads only the project lockfile: no registry, no network. +pub async fn read_locked_versions( + project_root: &Path, +) -> Option>> { + let content = tokio::fs::read_to_string(project_root.join("Cargo.lock")) + .await + .ok()?; + let doc = content.parse::().ok()?; + let pkgs = doc.get("package")?.as_array_of_tables()?; + let mut map: HashMap> = HashMap::new(); + for t in pkgs.iter() { + let name = t.get("name").and_then(Item::as_str); + let ver = t.get("version").and_then(Item::as_str); + if let (Some(n), Some(v)) = (name, ver) { + map.entry(n.to_string()).or_default().insert(v.to_string()); + } + } + Some(map) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; + const CHECKSUM: &str = "9d8f4e3bd2c8f1f5d1a3f5e7c9b1d3f5e7a9b1c3d5f7e9a1b3c5d7e9f1a3b5c7"; + + /// A realistic cargo-1.93-shaped v4 lock (header comment, version line, + /// plain-name dependencies array — spike claim 8). + fn lock_body() -> String { + format!( + "# This file is automatically @generated by Cargo.\n\ + # It is not intended for manual editing.\n\ + version = 4\n\ + \n\ + [[package]]\n\ + name = \"app\"\n\ + version = \"0.1.0\"\n\ + dependencies = [\n \"cfg-if\",\n]\n\ + \n\ + [[package]]\n\ + name = \"cfg-if\"\n\ + version = \"1.0.4\"\n\ + source = \"{SOURCE}\"\n\ + checksum = \"{CHECKSUM}\"\n" + ) + } + + async fn fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("Cargo.lock"), lock_body()) + .await + .unwrap(); + dir + } + + #[tokio::test] + async fn detach_removes_only_source_and_checksum() { + let dir = fixture().await; + let orig = detach_lock_entry(dir.path(), "cfg-if", "1.0.4", false) + .await + .unwrap(); + assert_eq!(orig.source, SOURCE); + assert_eq!(orig.checksum.as_deref(), Some(CHECKSUM)); + + let body = tokio::fs::read_to_string(dir.path().join("Cargo.lock")) + .await + .unwrap(); + assert!(!body.contains("source ="), "source line gone"); + assert!(!body.contains("checksum ="), "checksum line gone"); + // Everything else is byte-preserved: header, version line, the app + // entry with its dependencies array, and cfg-if's name/version pair. + assert!(body.starts_with("# This file is automatically @generated by Cargo.\n")); + assert!(body.contains("version = 4\n")); + assert!(body.contains("name = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \"cfg-if\",\n]\n")); + assert!(body.contains("[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n")); + } + + #[tokio::test] + async fn detach_restore_round_trip_is_byte_identical() { + let dir = fixture().await; + let before = tokio::fs::read(dir.path().join("Cargo.lock")).await.unwrap(); + + let orig = detach_lock_entry(dir.path(), "cfg-if", "1.0.4", false) + .await + .unwrap(); + assert!(restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, false) + .await + .unwrap()); + + let after = tokio::fs::read(dir.path().join("Cargo.lock")).await.unwrap(); + assert_eq!( + String::from_utf8_lossy(&before), + String::from_utf8_lossy(&after), + "restored lock must be byte-identical to the pristine fixture" + ); + } + + #[tokio::test] + async fn detach_missing_lock_is_no_lockfile() { + let dir = tempfile::tempdir().unwrap(); + let err = detach_lock_entry(dir.path(), "cfg-if", "1.0.4", false) + .await + .unwrap_err(); + assert_eq!(err, LockEditError::NoLockfile); + } + + #[tokio::test] + async fn detach_missing_entry_and_wrong_version() { + let dir = fixture().await; + let err = detach_lock_entry(dir.path(), "nope", "1.0.4", false) + .await + .unwrap_err(); + assert_eq!(err, LockEditError::EntryMissing); + // Version is part of the key — a different version must not match. + let err = detach_lock_entry(dir.path(), "cfg-if", "9.9.9", false) + .await + .unwrap_err(); + assert_eq!(err, LockEditError::EntryMissing); + // The refusals wrote nothing. + assert_eq!( + tokio::fs::read_to_string(dir.path().join("Cargo.lock")).await.unwrap(), + lock_body() + ); + } + + #[tokio::test] + async fn detach_path_dep_is_not_registry() { + let dir = fixture().await; + // `app` is the workspace member: no `source` key. + let err = detach_lock_entry(dir.path(), "app", "0.1.0", false) + .await + .unwrap_err(); + assert_eq!(err, LockEditError::NotRegistry); + } + + #[tokio::test] + async fn detach_dry_run_reports_but_does_not_write() { + let dir = fixture().await; + let orig = detach_lock_entry(dir.path(), "cfg-if", "1.0.4", true) + .await + .unwrap(); + assert_eq!(orig.source, SOURCE); + assert_eq!( + tokio::fs::read_to_string(dir.path().join("Cargo.lock")).await.unwrap(), + lock_body(), + "dry-run must not write" + ); + } + + #[tokio::test] + async fn detach_unparseable_lock_is_parse_error() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("Cargo.lock"), "not = = toml [[[") + .await + .unwrap(); + let err = detach_lock_entry(dir.path(), "cfg-if", "1.0.4", false) + .await + .unwrap_err(); + assert!(matches!(err, LockEditError::Parse(_))); + } + + #[tokio::test] + async fn restore_skips_re_resolved_and_absent_entries() { + let dir = fixture().await; + let orig = LockEntryOriginal { + source: SOURCE.to_string(), + checksum: Some(CHECKSUM.to_string()), + }; + // The entry still has its registry source (the user/cargo re-resolved + // it after a hand-revert) — restoring would clobber it: Ok(false). + assert!(!restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, false) + .await + .unwrap()); + // The entry is gone entirely (the dependency was dropped): Ok(false). + assert!(!restore_lock_entry(dir.path(), "gone", "1.0.0", &orig, false) + .await + .unwrap()); + // Neither skip touched the file. + assert_eq!( + tokio::fs::read_to_string(dir.path().join("Cargo.lock")).await.unwrap(), + lock_body() + ); + } + + #[tokio::test] + async fn restore_dry_run_does_not_write() { + let dir = fixture().await; + let orig = detach_lock_entry(dir.path(), "cfg-if", "1.0.4", false) + .await + .unwrap(); + let detached = tokio::fs::read_to_string(dir.path().join("Cargo.lock")) + .await + .unwrap(); + assert!(restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, true) + .await + .unwrap()); + assert_eq!( + tokio::fs::read_to_string(dir.path().join("Cargo.lock")).await.unwrap(), + detached, + "dry-run restore must not write" + ); + } + + #[tokio::test] + async fn restore_entry_without_checksum() { + // Some sources (git pins) have no checksum; restore must not invent one. + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write( + dir.path().join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"1.0.0\"\nsource = \"git+https://example.com/x#abc\"\n", + ) + .await + .unwrap(); + let orig = detach_lock_entry(dir.path(), "x", "1.0.0", false).await.unwrap(); + assert_eq!(orig.checksum, None); + assert!(restore_lock_entry(dir.path(), "x", "1.0.0", &orig, false) + .await + .unwrap()); + let body = tokio::fs::read_to_string(dir.path().join("Cargo.lock")) + .await + .unwrap(); + assert!(body.contains("source = \"git+https://example.com/x#abc\"")); + assert!(!body.contains("checksum")); + } + + #[tokio::test] + async fn locked_versions_is_multi_version_aware() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write( + dir.path().join("Cargo.lock"), + "version = 4\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\n", + ) + .await + .unwrap(); + let map = read_locked_versions(dir.path()).await.unwrap(); + let versions = &map["cfg-if"]; + assert!(versions.contains("1.0.4") && versions.contains("0.1.10")); + + // Absent / unparseable lock → None (cross-check skipped). + let empty = tempfile::tempdir().unwrap(); + assert!(read_locked_versions(empty.path()).await.is_none()); + tokio::fs::write(empty.path().join("Cargo.lock"), "[[[ nope") + .await + .unwrap(); + assert!(read_locked_versions(empty.path()).await.is_none()); + } + + #[tokio::test] + async fn edits_leave_no_stage_litter() { + let dir = fixture().await; + detach_lock_entry(dir.path(), "cfg-if", "1.0.4", false) + .await + .unwrap(); + for e in std::fs::read_dir(dir.path()).unwrap() { + let name = e.unwrap().file_name().to_string_lossy().into_owned(); + assert!(!name.contains("socket-stage"), "stage litter: {name}"); + } + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs index ed2edb3..0b589bc 100644 --- a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs @@ -1 +1,1100 @@ -//! (stub — implementation lands with its backend phase) +//! Composer vendor backend: lock-only `dist` surgery pointing at a committed +//! patched copy. +//! +//! Spike-verified mechanism (composer 2.10 — `spikes/PHASE0-FINDINGS.txt`): +//! edit ONLY `composer.lock`. `composer.json` is never touched, and the lock's +//! `content-hash` covers composer.json alone, so the surgery triggers no +//! "lock file out of date" warning. The package's lock entry is rewritten to: +//! +//! * `dist` → `{"type": "path", "url": "", "reference": null}` +//! (replaced IN ITS ORIGINAL SLOT so the entry's key order is stable); +//! * `source` REMOVED entirely — left in place, `--prefer-source` could +//! git-clone the unpatched upstream; with it removed the spike confirmed +//! `--prefer-source` falls back to the path dist cleanly; +//! * `"transport-options": {"symlink": false}` inserted right after `dist` — +//! LOAD-BEARING: composer's default path-repo strategy symlinks, and a +//! symlink into `.socket/vendor/` would defeat the real-copy guarantee. +//! `symlink: false` forces the 'Mirroring' (copy) strategy. +//! +//! Lock names are matched CASE-INSENSITIVELY (locks are normally lowercase, +//! but hand-written mixed-case locks exist and install fine) while the dist +//! URL we write always uses the lowercase canonical `/` — the +//! casing of the directory this backend creates. Versions are matched through +//! the leading-`v` normalization (locks carry the pretty `v6.4.1`, PURLs the +//! bare `6.4.1`) but the lock's own `version` string is never rewritten. +//! +//! Serialization mirrors composer's own writer: 4-space indent +//! (`JSON_PRETTY_PRINT`) + trailing newline; serde_json does not escape `/` +//! (matching `JSON_UNESCAPED_SLASHES`). + +use std::collections::HashMap; +use std::path::Path; + +use serde::Serialize; +use serde_json::{json, Map, Value}; + +use crate::manifest::schema::{PatchFileInfo, PatchRecord}; +use crate::patch::apply::{ + apply_package_patch, is_safe_relative_subpath, normalize_file_path, ApplyResult, PatchSources, + VerifyResult, VerifyStatus, +}; +use crate::patch::copy_tree::{fresh_copy, remove_tree}; +use crate::patch::file_hash::compute_file_git_sha256; +use crate::patch::path_safety::{is_safe_multi_segment, is_safe_single_segment}; +use crate::utils::fs::atomic_write_bytes; +use crate::utils::purl::{build_composer_purl, parse_composer_purl}; + +use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; +use super::state::{ + write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, +}; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +/// Project-relative lockfile this backend wires. +const COMPOSER_LOCK: &str = "composer.lock"; + +/// Wiring-record discriminator. The record's `key` is +/// `"
:/"` where `
` is `packages` or +/// `packages-dev` (the lock array holding the entry) and `/` is +/// the lowercase canonical package name — `:` cannot appear in a composer +/// package name, so the encoding is unambiguous. +const WIRING_KIND: &str = "composer_lock_package"; + +/// Marker schema version written into `socket-patch.vendor.json`. +const MARKER_SCHEMA_VERSION: u32 = 1; + +/// Normalize a composer version for identity comparison: strip a single +/// leading `v`/`V` when it directly precedes a digit (`v6.4.1` → `6.4.1`). +/// Local twin of the private `crawlers::composer_crawler::normalize_version` +/// (not visible from here); keep the two in sync. +fn normalize_version(version: &str) -> &str { + let mut chars = version.chars(); + if matches!(chars.next(), Some('v') | Some('V')) + && chars.next().map(|c| c.is_ascii_digit()).unwrap_or(false) + { + return &version[1..]; + } + version +} + +/// Vendor a composer package: materialize a patched copy under +/// `.socket/vendor/composer///@` and rewire the +/// matching `composer.lock` entry at it (see the module doc for the surgery). +/// +/// `installed_dir` is the crawler's package dir (`vendor//` — the same +/// root `apply` patches, so the manifest file keys resolve relative to it). +/// The lock edit runs LAST: any copy/patch failure removes the copy and +/// leaves the lock untouched. +#[allow(clippy::too_many_arguments)] +pub async fn vendor_composer( + purl: &str, + installed_dir: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, +) -> VendorOutcome { + // ── coordinates ────────────────────────────────────────────────────── + let Some(((vendor, name), version)) = parse_composer_purl(purl) else { + return refused( + "unsafe_coordinates", + format!("not a composer purl: {purl}"), + ); + }; + // Canonical (packagist) lowercase form keys the on-disk copy dir and the + // dist URL; the lock's own pretty casing is preserved untouched. + let vendor = vendor.to_lowercase(); + let name = name.to_lowercase(); + let pkg = format!("{vendor}/{name}"); + + // SECURITY: `uuid`, `vendor/name` and `version` come from committed, + // tamper-able manifest data and key the copy dir that vendor creates and + // `--revert` deletes. A `..` segment, separator, or non-canonical uuid + // would escape `.socket/vendor/composer/` — reject fail-closed before any + // disk access. + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("composer", &record.uuid) else { + return refused( + "unsafe_coordinates", + format!("non-canonical patch uuid {:?}", record.uuid), + ); + }; + if !is_safe_multi_segment(&pkg) || !is_safe_single_segment(version) { + return refused( + "unsafe_coordinates", + format!("unsafe composer coordinates `{pkg}` @ `{version}`"), + ); + } + + let copy_rel = format!("{uuid_dir_rel}/{pkg}@{version}"); + let uuid_dir = project_root.join(&uuid_dir_rel); + let copy_dir = project_root.join(©_rel); + + // A patch with no files is meaningless to vendor: no-op success, no edits. + if record.files.is_empty() { + return VendorOutcome::Done { + result: synthesized_result(purl, ©_dir, Vec::new(), true, None), + entry: None, + warnings: Vec::new(), + }; + } + + // ── lock presence + entry ──────────────────────────────────────────── + let lock_path = project_root.join(COMPOSER_LOCK); + let lock_text = match tokio::fs::read_to_string(&lock_path).await { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return refused( + "vendor_lockfile_missing", + format!("no composer.lock at {}", lock_path.display()), + ); + } + Err(e) => { + return refused( + "vendor_lockfile_missing", + format!("unreadable composer.lock: {e}"), + ); + } + }; + // An unparseable lock is as unusable as a missing one — same refusal code. + let mut lock: Value = match serde_json::from_str(&lock_text) { + Ok(v) => v, + Err(e) => { + return refused( + "vendor_lockfile_missing", + format!("unparseable composer.lock: {e}"), + ); + } + }; + let Some((section, idx)) = find_lock_entry(&lock, &pkg, version) else { + return refused( + "vendor_lock_entry_not_found", + format!("{pkg}@{version} is in neither packages[] nor packages-dev[] of composer.lock"), + ); + }; + + // ── idempotent hot path ────────────────────────────────────────────── + // Copy already carries every afterHash and the lock entry already points + // at the uuid path → touch nothing, report AlreadyPatched. `entry` stays + // `None`: the first run's ledger entry holds the only copy of the + // verbatim pre-vendor original, and re-recording here would clobber it. + if entry_is_wired(&lock[section][idx], ©_rel) + && copy_matches_after_hashes(©_dir, &record.files).await + { + let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + return VendorOutcome::Done { + result: synthesized_result(purl, ©_dir, verified, true, None), + entry: None, + warnings: Vec::new(), + }; + } + + // ── dry run: verify-only against the installed dir, no writes ──────── + if dry_run { + let mut result = + apply_package_patch(purl, installed_dir, &record.files, sources, Some(&record.uuid), true, force) + .await; + result.package_path = copy_dir.display().to_string(); + return VendorOutcome::Done { + result, + entry: None, + warnings: Vec::new(), + }; + } + + // ── copy + patch (wiring last) ─────────────────────────────────────── + if let Err(e) = fresh_copy(installed_dir, ©_dir, None).await { + return VendorOutcome::Done { + result: synthesized_result( + purl, + ©_dir, + Vec::new(), + false, + Some(format!("failed to copy installed package: {e}")), + ), + entry: None, + warnings: Vec::new(), + }; + } + let mut result = + apply_package_patch(purl, ©_dir, &record.files, sources, Some(&record.uuid), false, force) + .await; + result.package_path = copy_dir.display().to_string(); + if !result.success { + // Don't leave a half-built copy; the lock was never touched. + let _ = remove_tree(&uuid_dir).await; + return VendorOutcome::Done { + result, + entry: None, + warnings: Vec::new(), + }; + } + + // ── lock rewrite ───────────────────────────────────────────────────── + let original_entry = lock[section][idx].clone(); + let Some(original_obj) = original_entry.as_object() else { + // find_lock_entry only matches objects; defensive. + let _ = remove_tree(&uuid_dir).await; + result.success = false; + result.error = Some("composer.lock entry is not a JSON object".to_string()); + return VendorOutcome::Done { result, entry: None, warnings: Vec::new() }; + }; + let rewritten = rewrite_lock_entry(original_obj, ©_rel); + lock[section][idx] = Value::Object(rewritten.clone()); + let write_result = match composer_json_bytes(&lock) { + Ok(bytes) => atomic_write_bytes(&lock_path, &bytes).await, + Err(e) => Err(e), + }; + if let Err(e) = write_result { + let _ = remove_tree(&uuid_dir).await; + result.success = false; + result.error = Some(format!("failed to write composer.lock: {e}")); + return VendorOutcome::Done { result, entry: None, warnings: Vec::new() }; + } + + // ── marker + ledger entry ──────────────────────────────────────────── + let mut warnings = Vec::new(); + let base_purl = build_composer_purl(&vendor, &name, version); + let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); + vulnerabilities.sort(); + let marker = VendorMarker { + schema_version: MARKER_SCHEMA_VERSION, + purl: base_purl.clone(), + patch_uuid: record.uuid.clone(), + ecosystem: "composer".to_string(), + vulnerabilities, + vendored_at: vendored_at.to_string(), + }; + if let Err(e) = write_marker(&uuid_dir, &marker).await { + // The marker is informational only (state.json is the ledger of + // record), so its failure must not fail an otherwise-wired vendor. + warnings.push(VendorWarning::new( + "vendor_marker_write_failed", + format!("could not write {}: {e}", super::state::VENDOR_MARKER_FILE), + )); + } + + let entry = VendorEntry { + ecosystem: "composer".to_string(), + base_purl, + uuid: record.uuid.clone(), + artifact: VendorArtifact { + path: copy_rel, + sha256: String::new(), // dir-shaped: integrity is per-file afterHashes + size: None, + platform_locked: None, + }, + wiring: vec![WiringRecord { + file: COMPOSER_LOCK.to_string(), + kind: WIRING_KIND.to_string(), + action: WiringAction::Rewritten, + key: Some(format!("{section}:{pkg}")), + original: Some(original_entry), + new: Some(Value::Object(rewritten)), + }], + lock: None, + took_over_go_patches: false, + flavor: None, + uv: None, + }; + + VendorOutcome::Done { + result, + entry: Some(entry), + warnings, + } +} + +/// Revert a composer vendor entry: restore the verbatim original lock entry +/// (when the live entry still points into our uuid dir) and remove the +/// validated uuid dir. A drifted live entry — rewritten by a `composer +/// update`, a hand edit, or a newer vendor run — is left alone with a +/// `vendor_lock_entry_drifted` warning. +/// +/// Note: the *installed* `vendor//` keeps the patched bytes until the +/// next `composer install` re-mirrors from the registry; revert surfaces that +/// as the `vendor_installed_copy_stale` advisory. +pub async fn revert_composer( + entry: &VendorEntry, + project_root: &Path, + dry_run: bool, +) -> RevertOutcome { + // SECURITY: state.json is committed and tamper-able; the uuid keys the + // directory we are about to delete. Anything but the canonical uuid + // grammar is rejected fail-closed before any disk access. + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("composer", &entry.uuid) else { + return RevertOutcome::failed(format!( + "refusing revert: non-canonical patch uuid {:?}", + entry.uuid + )); + }; + let uuid_dir = project_root.join(&uuid_dir_rel); + let lock_path = project_root.join(COMPOSER_LOCK); + let mut warnings = Vec::new(); + + // Wiring is restored in reverse application order (one record today). + for w in entry.wiring.iter().rev() { + if w.kind != WIRING_KIND { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("unrecognized wiring kind {:?}; fragment left alone", w.kind), + )); + continue; + } + match restore_lock_entry(&lock_path, w, &entry.uuid, dry_run).await { + Ok(true) => {} + Ok(false) => warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "composer.lock entry for {} no longer points into .socket/vendor/composer/; left alone", + w.key.as_deref().unwrap_or("") + ), + )), + Err(e) => { + return RevertOutcome { + success: false, + warnings, + error: Some(e), + }; + } + } + } + + if !dry_run { + if let Err(e) = remove_tree(&uuid_dir).await { + return RevertOutcome { + success: false, + warnings, + error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), + }; + } + } + + warnings.push(VendorWarning::new( + "vendor_installed_copy_stale", + format!( + "the installed vendor/{} copy keeps the patched bytes until the next `composer install`", + entry + .wiring + .first() + .and_then(|w| w.key.as_deref()) + .and_then(|k| k.split_once(':').map(|(_, p)| p)) + .unwrap_or("") + ), + )); + + RevertOutcome { + success: true, + warnings, + error: None, + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn refused(code: &'static str, detail: impl Into) -> VendorOutcome { + VendorOutcome::Refused { + code, + detail: detail.into(), + } +} + +/// Locate the package's entry: `packages[]` first, then `packages-dev[]`. +/// Names are compared case-insensitively, versions through the `v`-prefix +/// normalization (see module doc). +fn find_lock_entry(lock: &Value, pkg_lc: &str, version: &str) -> Option<(&'static str, usize)> { + for section in ["packages", "packages-dev"] { + let Some(arr) = lock.get(section).and_then(Value::as_array) else { + continue; + }; + for (i, e) in arr.iter().enumerate() { + let Some(name) = e.get("name").and_then(Value::as_str) else { + continue; + }; + if !name.eq_ignore_ascii_case(pkg_lc) { + continue; + } + let Some(v) = e.get("version").and_then(Value::as_str) else { + continue; + }; + if normalize_version(v) == normalize_version(version) { + return Some((section, i)); + } + } + } + None +} + +/// True when the live entry already carries our path dist. +fn entry_is_wired(entry: &Value, dist_url: &str) -> bool { + let dist = entry.get("dist"); + dist.and_then(|d| d.get("type")).and_then(Value::as_str) == Some("path") + && dist.and_then(|d| d.get("url")).and_then(Value::as_str) == Some(dist_url) +} + +/// Rebuild the lock entry for the path dist (see module doc): every original +/// key is preserved in order, `source` is dropped, `dist` is replaced in its +/// original slot with `transport-options` inserted right after it. A +/// pre-existing `transport-options` is superseded by ours (never duplicated). +/// A source-only entry without `dist` gets both appended at the end. +fn rewrite_lock_entry(original: &Map, dist_url: &str) -> Map { + let dist = json!({ "type": "path", "url": dist_url, "reference": null }); + let transport = json!({ "symlink": false }); + let mut out = Map::new(); + let mut replaced_dist = false; + for (k, v) in original { + match k.as_str() { + "source" => {} + "transport-options" => {} + "dist" => { + out.insert("dist".to_string(), dist.clone()); + out.insert("transport-options".to_string(), transport.clone()); + replaced_dist = true; + } + _ => { + out.insert(k.clone(), v.clone()); + } + } + } + if !replaced_dist { + out.insert("dist".to_string(), dist); + out.insert("transport-options".to_string(), transport); + } + out +} + +/// Serialize the lock the way composer writes it: 4-space indent +/// (`JSON_PRETTY_PRINT`) + trailing newline. serde_json never escapes `/`, +/// matching `JSON_UNESCAPED_SLASHES`. +fn composer_json_bytes(value: &Value) -> std::io::Result> { + let mut buf = Vec::new(); + let fmt = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, fmt); + value.serialize(&mut ser).map_err(std::io::Error::other)?; + buf.push(b'\n'); + Ok(buf) +} + +/// True when the copy exists and every patched file in it already hashes to +/// its `afterHash` (the vendor twin of `go_redirect::redirect_in_sync`). +async fn copy_matches_after_hashes( + copy_dir: &Path, + files: &HashMap, +) -> bool { + if tokio::fs::metadata(copy_dir).await.is_err() { + return false; + } + for (file_name, info) in files { + let normalized = normalize_file_path(file_name); + // SECURITY: never hash through a manifest key that escapes the copy + // dir — fail the sync check instead (the full pipeline would refuse + // the key anyway). + if !is_safe_relative_subpath(normalized) { + return false; + } + match compute_file_git_sha256(©_dir.join(normalized)).await { + Ok(h) if h == info.after_hash => {} + _ => return false, + } + } + true +} + +/// Restore one `composer_lock_package` wiring record. `Ok(true)` = restored +/// (or would be, on dry run); `Ok(false)` = drifted, left alone; `Err` = a +/// real I/O / serialization failure. +async fn restore_lock_entry( + lock_path: &Path, + w: &WiringRecord, + uuid: &str, + dry_run: bool, +) -> Result { + let Some(key) = w.key.as_deref() else { + return Ok(false); + }; + let Some((section, pkg)) = key.split_once(':') else { + return Ok(false); + }; + if section != "packages" && section != "packages-dev" { + return Ok(false); + } + let Some(original) = w.original.clone() else { + return Ok(false); + }; + + let lock_text = match tokio::fs::read_to_string(lock_path).await { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(format!("unreadable composer.lock: {e}")), + }; + let mut lock: Value = serde_json::from_str(&lock_text) + .map_err(|e| format!("unparseable composer.lock: {e}"))?; + + let Some(arr) = lock.get(section).and_then(Value::as_array) else { + return Ok(false); + }; + let Some(idx) = arr.iter().position(|e| { + e.get("name") + .and_then(Value::as_str) + .is_some_and(|n| n.eq_ignore_ascii_case(pkg)) + }) else { + return Ok(false); + }; + + // Ownership gate: only restore when the live dist still points into OUR + // uuid dir. A registry dist (composer update reverted it) or a different + // uuid (a newer vendor run owns the entry) is third-party state — never + // clobber it. + let live = &lock[section][idx]; + let wired_to_us = live + .get("dist") + .and_then(|d| d.get("url")) + .and_then(Value::as_str) + .and_then(parse_vendor_path) + .is_some_and(|p| p.eco == "composer" && p.uuid == uuid); + if !wired_to_us { + return Ok(false); + } + + if !dry_run { + lock[section][idx] = original; + let bytes = composer_json_bytes(&lock).map_err(|e| e.to_string())?; + atomic_write_bytes(lock_path, &bytes) + .await + .map_err(|e| format!("failed to write composer.lock: {e}"))?; + } + Ok(true) +} + +fn synthesized_result( + package_key: &str, + copy_dir: &Path, + files_verified: Vec, + success: bool, + error: Option, +) -> ApplyResult { + ApplyResult { + package_key: package_key.to_string(), + package_path: copy_dir.display().to_string(), + success, + files_verified, + files_patched: Vec::new(), + applied_via: HashMap::new(), + error, + sidecar: None, + } +} + +fn already_patched_verify(file: &str) -> VerifyResult { + VerifyResult { + file: file.to_string(), + status: VerifyStatus::AlreadyPatched, + message: None, + current_hash: None, + expected_hash: None, + target_hash: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use std::path::PathBuf; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const PURL: &str = "pkg:composer/psr/log@3.0.2"; + const PRISTINE: &[u8] = b" String { + format!(".socket/vendor/composer/{UUID}/psr/log@3.0.2") + } + + fn psr_log_entry(name: &str, version: &str) -> Value { + json!({ + "name": name, + "version": version, + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { "php": ">=8.0.0" }, + "type": "library" + }) + } + + fn lock_value(name: &str, version: &str, in_dev: bool) -> Value { + let dev_entry = json!({ + "name": "phpunit/phpunit", + "version": "10.0.0", + "source": {"type": "git", "url": "https://github.com/s/phpunit.git", "reference": "aaa"}, + "dist": {"type": "zip", "url": "https://api.github.com/repos/s/phpunit/zipball/aaa", "reference": "aaa", "shasum": ""}, + "type": "library" + }); + let (packages, packages_dev) = if in_dev { + (json!([dev_entry]), json!([psr_log_entry(name, version)])) + } else { + (json!([psr_log_entry(name, version)]), json!([dev_entry])) + }; + json!({ + "_readme": ["This file locks the dependencies of your project to a known state"], + "content-hash": "7a59d114f58e9b02546b21d7e57430d3", + "packages": packages, + "packages-dev": packages_dev, + "minimum-stability": "stable", + "plugin-api-version": "2.6.0" + }) + } + + /// Fixture project: composer.lock (composer-shaped, written with the same + /// 4-space emitter composer uses), an installed `vendor/psr/log`, and a + /// blobs dir carrying the patched bytes. + async fn fixture(lock: &Value) -> (tempfile::TempDir, PathBuf, PathBuf, PatchRecord) { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + tokio::fs::write(root.join(COMPOSER_LOCK), composer_json_bytes(lock).unwrap()) + .await + .unwrap(); + + let installed = root.join("vendor/psr/log"); + tokio::fs::create_dir_all(installed.join("src")).await.unwrap(); + tokio::fs::write(installed.join("composer.json"), b"{\"name\": \"psr/log\"}\n") + .await + .unwrap(); + tokio::fs::write(installed.join("src/LoggerInterface.php"), PRISTINE) + .await + .unwrap(); + + let before = compute_git_sha256_from_bytes(PRISTINE); + let after = compute_git_sha256_from_bytes(PATCHED); + let blobs = root.join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + tokio::fs::write(blobs.join(&after), PATCHED).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "src/LoggerInterface.php".to_string(), + PatchFileInfo { + before_hash: before, + after_hash: after, + }, + ); + let mut vulnerabilities = HashMap::new(); + vulnerabilities.insert( + "GHSA-xxxx-yyyy-zzzz".to_string(), + crate::manifest::schema::VulnerabilityInfo { + cves: Vec::new(), + summary: String::new(), + severity: String::new(), + description: String::new(), + }, + ); + let record = PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-06-09T00:00:00Z".to_string(), + files, + vulnerabilities, + description: String::new(), + license: String::new(), + tier: String::new(), + }; + (dir, blobs, installed, record) + } + + fn unwrap_done(o: VendorOutcome) -> (ApplyResult, Option, Vec) { + match o { + VendorOutcome::Done { + result, + entry, + warnings, + } => (result, entry, warnings), + VendorOutcome::Refused { code, detail } => panic!("refused: {code}: {detail}"), + } + } + + fn unwrap_refused(o: VendorOutcome) -> (&'static str, String) { + match o { + VendorOutcome::Refused { code, detail } => (code, detail), + VendorOutcome::Done { result, .. } => panic!("not refused: {result:?}"), + } + } + + async fn run_vendor( + root: &Path, + blobs: &Path, + installed: &Path, + record: &PatchRecord, + purl: &str, + dry_run: bool, + ) -> VendorOutcome { + let sources = PatchSources::blobs_only(blobs); + vendor_composer( + purl, + installed, + root, + record, + &sources, + "2026-06-09T00:00:00Z", + dry_run, + false, + ) + .await + } + + #[tokio::test] + async fn test_happy_path_rewrites_lock() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + + // Copy patched at the uuid path; installed dir untouched. + let copy = root.join(copy_rel()); + assert_eq!( + tokio::fs::read(copy.join("src/LoggerInterface.php")).await.unwrap(), + PATCHED + ); + assert_eq!( + tokio::fs::read(installed.join("src/LoggerInterface.php")).await.unwrap(), + PRISTINE + ); + + // Marker present in the uuid dir. + let marker = tokio::fs::read_to_string( + root.join(format!(".socket/vendor/composer/{UUID}/{VENDOR_MARKER_FILE}")), + ) + .await + .unwrap(); + assert!(marker.contains(UUID)); + assert!(marker.contains("GHSA-xxxx-yyyy-zzzz")); + + // Lock surgery: source gone, dist replaced in slot, transport-options + // right after, all other keys in their original order. + let text = tokio::fs::read_to_string(root.join(COMPOSER_LOCK)).await.unwrap(); + let new_lock: Value = serde_json::from_str(&text).unwrap(); + let e = &new_lock["packages"][0]; + let keys: Vec<&str> = e.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!( + keys, + vec!["name", "version", "dist", "transport-options", "require", "type"], + "dist replaced in its original slot, source dropped, transport-options after dist" + ); + assert_eq!(e["dist"]["type"], "path"); + assert_eq!(e["dist"]["url"], copy_rel()); + assert!(e["dist"]["reference"].is_null()); + assert_eq!(e["transport-options"]["symlink"], json!(false)); + // content-hash untouched (it covers composer.json only). + assert_eq!(new_lock["content-hash"], "7a59d114f58e9b02546b21d7e57430d3"); + // 4-space indent + trailing newline + unescaped slashes. + assert!(text.starts_with("{\n \""), "4-space indent: {text}"); + assert!(text.ends_with('\n')); + assert!( + text.contains(&format!("\"url\": \"{}\"", copy_rel())), + "slashes must not be escaped" + ); + + // Ledger entry: verbatim original, our rewrite, the artifact path. + let entry = entry.expect("success must carry a ledger entry"); + assert_eq!(entry.ecosystem, "composer"); + assert_eq!(entry.base_purl, PURL); + assert_eq!(entry.uuid, UUID); + assert_eq!(entry.artifact.path, copy_rel()); + assert_eq!(entry.artifact.sha256, ""); + assert_eq!(entry.wiring.len(), 1); + let w = &entry.wiring[0]; + assert_eq!(w.file, COMPOSER_LOCK); + assert_eq!(w.kind, WIRING_KIND); + assert_eq!(w.action, WiringAction::Rewritten); + assert_eq!(w.key.as_deref(), Some("packages:psr/log")); + assert_eq!(w.original.as_ref().unwrap(), &lock["packages"][0]); + assert_eq!(w.new.as_ref().unwrap(), e); + } + + #[tokio::test] + async fn test_matches_packages_dev_entry() { + let lock = lock_value("psr/log", "3.0.2", true); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + assert_eq!(entry.wiring[0].key.as_deref(), Some("packages-dev:psr/log")); + + let new_lock: Value = serde_json::from_str( + &tokio::fs::read_to_string(root.join(COMPOSER_LOCK)).await.unwrap(), + ) + .unwrap(); + assert_eq!(new_lock["packages-dev"][0]["dist"]["type"], "path"); + // The packages[] sibling (phpunit) is untouched. + assert_eq!(new_lock["packages"][0]["dist"]["type"], "zip"); + } + + #[tokio::test] + async fn test_matches_v_prefixed_lock_version() { + // Lock carries the pretty `v3.0.2`; the PURL is bare `3.0.2`. The + // entry must match, and its own version string must NOT be rewritten. + let lock = lock_value("psr/log", "v3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + + let (result, _e, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success, "{:?}", result.error); + let new_lock: Value = serde_json::from_str( + &tokio::fs::read_to_string(root.join(COMPOSER_LOCK)).await.unwrap(), + ) + .unwrap(); + assert_eq!(new_lock["packages"][0]["version"], "v3.0.2"); + assert_eq!(new_lock["packages"][0]["dist"]["type"], "path"); + } + + #[tokio::test] + async fn test_case_insensitive_name_lowercase_dist_url() { + // Hand-written mixed-case lock name: matched case-insensitively, the + // lock's pretty casing preserved, the dist URL lowercase canonical. + let lock = lock_value("Psr/Log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + + let (result, _e, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success, "{:?}", result.error); + let new_lock: Value = serde_json::from_str( + &tokio::fs::read_to_string(root.join(COMPOSER_LOCK)).await.unwrap(), + ) + .unwrap(); + assert_eq!(new_lock["packages"][0]["name"], "Psr/Log", "pretty casing kept"); + assert_eq!(new_lock["packages"][0]["dist"]["url"], copy_rel(), "dist url lowercase"); + assert!(dir.path().join(copy_rel()).exists(), "copy at the lowercase path"); + } + + #[tokio::test] + async fn test_refuses_missing_lock() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + tokio::fs::remove_file(root.join(COMPOSER_LOCK)).await.unwrap(); + + let (code, _d) = + unwrap_refused(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert_eq!(code, "vendor_lockfile_missing"); + assert!(!root.join(".socket").exists(), "refusal must write nothing"); + } + + #[tokio::test] + async fn test_refuses_entry_not_found() { + let lock = lock_value("monolog/monolog", "2.9.1", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + let before = tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(); + + let (code, _d) = + unwrap_refused(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert_eq!(code, "vendor_lock_entry_not_found"); + assert_eq!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + before, + "lock untouched" + ); + assert!(!root.join(".socket").exists()); + } + + /// SECURITY: traversal coordinates (a tampered manifest) must be refused + /// before any disk access — no copy outside `.socket/vendor/composer/`, + /// no lock edit. + #[tokio::test] + async fn test_refuses_unsafe_coordinates() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + let before = tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(); + + // (a) non-canonical uuid + let mut bad_uuid = record.clone(); + bad_uuid.uuid = "../../escape".to_string(); + let (code, _d) = + unwrap_refused(run_vendor(root, &blobs, &installed, &bad_uuid, PURL, false).await); + assert_eq!(code, "unsafe_coordinates"); + + // (b) traversal in the package name + let (code, _d) = unwrap_refused( + run_vendor(root, &blobs, &installed, &record, "pkg:composer/../evil@1.0.0", false) + .await, + ); + assert_eq!(code, "unsafe_coordinates"); + + assert!(!root.join(".socket").exists(), "nothing written"); + assert!(!root.parent().unwrap().join("escape").exists()); + assert_eq!(tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), before); + } + + #[tokio::test] + async fn test_idempotent_rerun_in_sync() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + + let (r1, e1, _) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(r1.success); + assert!(e1.is_some()); + let lock_bytes = tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(); + let copy_bytes = tokio::fs::read(root.join(copy_rel()).join("src/LoggerInterface.php")) + .await + .unwrap(); + + let (r2, e2, _) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(r2.success); + assert!(r2.files_patched.is_empty(), "in-sync rerun patches nothing"); + assert!( + r2.files_verified.iter().all(|v| v.status == VerifyStatus::AlreadyPatched), + "synthesized AlreadyPatched: {:?}", + r2.files_verified + ); + assert!( + e2.is_none(), + "hot path must not re-record (would clobber the original in the ledger)" + ); + assert_eq!(tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), lock_bytes); + assert_eq!( + tokio::fs::read(root.join(copy_rel()).join("src/LoggerInterface.php")).await.unwrap(), + copy_bytes + ); + } + + #[tokio::test] + async fn test_dry_run_writes_nothing() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + let before = tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(); + + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, true).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none(), "dry run records nothing"); + assert!(!root.join(".socket").exists(), "no copy created"); + assert_eq!(tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), before); + } + + #[tokio::test] + async fn test_partial_failure_removes_copy_lock_untouched() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, _blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + let before = tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(); + // Empty blobs dir → the patch bytes cannot be sourced → apply fails. + let empty = root.join("empty-blobs"); + tokio::fs::create_dir_all(&empty).await.unwrap(); + + let (result, entry, _w) = + unwrap_done(run_vendor(root, &empty, &installed, &record, PURL, false).await); + assert!(!result.success); + assert!(entry.is_none()); + assert!( + !root.join(format!(".socket/vendor/composer/{UUID}")).exists(), + "half-built copy must be removed" + ); + assert_eq!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + before, + "lock untouched on failure (wiring runs last)" + ); + } + + #[tokio::test] + async fn test_revert_round_trip_byte_identical() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + let fixture_bytes = tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(); + + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success); + let entry = entry.unwrap(); + assert_ne!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + fixture_bytes, + "vendor must have rewired the lock" + ); + + let outcome = revert_composer(&entry, root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + !outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + "clean revert must not report drift: {:?}", + outcome.warnings + ); + assert!( + outcome.warnings.iter().any(|w| w.code == "vendor_installed_copy_stale"), + "revert advises about the stale installed copy" + ); + assert_eq!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + fixture_bytes, + "lock restored byte-identically" + ); + assert!( + !root.join(format!(".socket/vendor/composer/{UUID}")).exists(), + "uuid dir removed" + ); + } + + #[tokio::test] + async fn test_revert_drift_warning() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success); + let entry = entry.unwrap(); + + // Third-party drift: `composer update` rewired the entry back to a + // registry zip dist. Revert must leave it alone and warn. + let drifted = lock_value("psr/log", "3.0.2", false); + tokio::fs::write( + root.join(COMPOSER_LOCK), + composer_json_bytes(&drifted).unwrap(), + ) + .await + .unwrap(); + let drifted_bytes = tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(); + + let outcome = revert_composer(&entry, root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + "drift must be reported: {:?}", + outcome.warnings + ); + assert_eq!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + drifted_bytes, + "drifted lock left alone" + ); + assert!( + !root.join(format!(".socket/vendor/composer/{UUID}")).exists(), + "uuid dir still removed" + ); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/gem.rs b/crates/socket-patch-core/src/patch/vendor/gem.rs index ed2edb3..0eb9a61 100644 --- a/crates/socket-patch-core/src/patch/vendor/gem.rs +++ b/crates/socket-patch-core/src/patch/vendor/gem.rs @@ -1 +1,1584 @@ -//! (stub — implementation lands with its backend phase) +//! Gem (Bundler) vendor backend: the Gemfile + Gemfile.lock pair edit. +//! +//! Spike-verified mechanism (bundler 2.5 — `spikes/PHASE0-FINDINGS.txt`): +//! BOTH files must be edited. A lock-only edit is a silent unpatch on the next +//! plain `bundle install` (bundler re-resolves from the Gemfile and rewrites +//! the lock back to a registry GEM source; frozen/CI mode errors with exit 16 +//! but dev machines do not). The pair edit is the form bundler itself +//! regenerates BYTE-IDENTICALLY, so the committed lock stays churn-free: +//! +//! ```text +//! PATH +//! remote: .socket/vendor/gem//- +//! specs: +//! () +//! () # the spec block's dependency sublines move over verbatim +//! ``` +//! +//! * the PATH section sits BEFORE the GEM section; `remote:` is the RELATIVE +//! path — no leading `./`, no trailing slash; +//! * the gem's spec block (its 4-space line plus 6-space dependency sublines) +//! MOVES from GEM/specs into the PATH specs; +//! * the GEM section is retained with the block removed; when its specs run +//! empty the empty `specs:` stanza is KEPT (that is what bundler writes); +//! * the DEPENDENCIES entry becomes ` (= )!` — exact pin plus +//! the `!` path-source marker; PLATFORMS / BUNDLED WITH / everything else is +//! byte-preserved. +//! +//! The Gemfile gains `path:` on the gem's declaration (rewritten in place when +//! it is a statically-parseable single top-level line, quote style preserved) +//! or, for a transitive dependency, a managed block appended at EOF. Anything +//! the conservative line grammar cannot prove safe to rewrite is REFUSED — +//! never guessed at. +//! +//! The stub gemspec from `/specifications/` is copied into the +//! vendored dir as `.gemspec` (a path source needs one; the spike showed +//! the stub works warning-free). Gems whose gemspec declares native +//! extensions are refused: bundler silently skips extension builds for path +//! sources and the missing `.so` only fails at `require` time with a +//! confusing error — refusing up front is the honest failure. + +use std::collections::HashMap; +use std::path::Path; + +use serde_json::Value; + +use crate::manifest::schema::{PatchFileInfo, PatchRecord}; +use crate::patch::apply::{ + apply_package_patch, is_safe_relative_subpath, normalize_file_path, ApplyResult, PatchSources, + VerifyResult, VerifyStatus, +}; +use crate::patch::copy_tree::{fresh_copy, remove_tree}; +use crate::patch::file_hash::compute_file_git_sha256; +use crate::patch::path_safety::is_safe_single_segment; +use crate::utils::fs::atomic_write_bytes; +use crate::utils::purl::{build_gem_purl, parse_gem_purl}; + +use super::path::vendor_uuid_dir_rel; +use super::state::{ + write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, +}; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +const GEMFILE: &str = "Gemfile"; +const GEMFILE_LOCK: &str = "Gemfile.lock"; + +/// Wiring-record discriminators (`key` is the gem name for both). +/// +/// `gemfile_line`: `original`/`new` are verbatim line/block strings. +/// +/// `gemfile_lock_spec`: `original` and `new` are arrays of verbatim lock +/// lines. In `original`, lines indented 4+ spaces are the gem's GEM spec +/// block and the single 2-space line (if any) is the pre-vendor DEPENDENCIES +/// entry — its absence means the gem was transitive and revert deletes the +/// added entry. In `new`, the last element is the DEPENDENCIES entry we wrote +/// and the rest is the emitted PATH section. +const GEMFILE_WIRING_KIND: &str = "gemfile_line"; +const LOCK_WIRING_KIND: &str = "gemfile_lock_spec"; + +/// Managed-block fence for transitive (not-Gemfile-declared) gems. +const MANAGED_OPEN: &str = "# >>> socket-patch vendor (managed) >>>"; +const MANAGED_CLOSE: &str = "# <<< socket-patch vendor (managed) <<<"; + +/// Marker schema version written into `socket-patch.vendor.json`. +const MARKER_SCHEMA_VERSION: u32 = 1; + +/// Vendor a gem: materialize a patched copy (plus its stub gemspec) under +/// `.socket/vendor/gem//-` and pair-edit Gemfile + +/// Gemfile.lock at it (see the module doc). +/// +/// `installed_dir` is the crawler's gem dir (`/gems/-`, +/// the same root `apply` patches — manifest file keys resolve relative to it); +/// the stub gemspec is derived from it +/// (`/specifications/-.gemspec` — `specifications/` +/// is a sibling of `gems/`). +/// +/// Edit order: copy+patch → Gemfile → Gemfile.lock; a lock-edit failure +/// unwinds the Gemfile to its recorded original bytes, so the pair is never +/// left half-wired. +#[allow(clippy::too_many_arguments)] +pub async fn vendor_gem( + purl: &str, + installed_dir: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, +) -> VendorOutcome { + // ── coordinates ────────────────────────────────────────────────────── + let Some((name, version)) = parse_gem_purl(purl) else { + return refused("unsafe_coordinates", format!("not a gem purl: {purl}")); + }; + // SECURITY: `uuid`, `name` and `version` come from committed, tamper-able + // manifest data. They key the copy dir vendor creates and `--revert` + // deletes, and — stricter than the path guard — they are embedded + // VERBATIM into the user's Gemfile (ruby source executed on every + // `bundle`) and into Gemfile.lock's line grammar. A quote, space, paren, + // or newline would be a code/grammar injection, so only the plain gem + // token charset is accepted. Reject fail-closed before any disk access. + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("gem", &record.uuid) else { + return refused( + "unsafe_coordinates", + format!("non-canonical patch uuid {:?}", record.uuid), + ); + }; + if !is_safe_single_segment(name) + || !is_safe_single_segment(version) + || !is_plain_gem_token(name) + || !is_plain_gem_token(version) + { + return refused( + "unsafe_coordinates", + format!("unsafe gem coordinates `{name}` @ `{version}`"), + ); + } + + let leaf = format!("{name}-{version}"); + let copy_rel = format!("{uuid_dir_rel}/{leaf}"); + let uuid_dir = project_root.join(&uuid_dir_rel); + let copy_dir = project_root.join(©_rel); + + // A patch with no files is meaningless to vendor: no-op success, no edits. + if record.files.is_empty() { + return VendorOutcome::Done { + result: synthesized_result(purl, ©_dir, Vec::new(), true, None), + entry: None, + warnings: Vec::new(), + }; + } + + // Platform-suffixed installs (`--x86_64-linux`) ship + // precompiled artifacts that are machine-specific — committing one would + // break every other platform, so they are refused, not guessed at. + let dir_name = installed_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + if dir_name != leaf { + return refused( + "platform_gem_unsupported", + format!( + "installed dir `{dir_name}` does not equal `{leaf}` (platform-specific gem builds cannot be vendored portably)" + ), + ); + } + + // ── project files ──────────────────────────────────────────────────── + let gemfile_path = project_root.join(GEMFILE); + let gemfile_text = match tokio::fs::read_to_string(&gemfile_path).await { + Ok(t) => t, + Err(_) => { + return refused( + "gemfile_missing", + format!("no Gemfile at {}", gemfile_path.display()), + ); + } + }; + let lock_path = project_root.join(GEMFILE_LOCK); + let lock_text = match tokio::fs::read_to_string(&lock_path).await { + Ok(t) => t, + Err(_) => { + return refused( + "vendor_lockfile_missing", + format!("no Gemfile.lock at {} (the pair edit needs the lock)", lock_path.display()), + ); + } + }; + + // ── stub gemspec ───────────────────────────────────────────────────── + // `specifications/` is a sibling of `gems/`; derive it from installed_dir. + let spec_src = installed_dir + .parent() + .and_then(Path::parent) + .map(|home| home.join("specifications").join(format!("{leaf}.gemspec"))); + let spec_text = match &spec_src { + Some(p) => tokio::fs::read_to_string(p).await.ok(), + None => None, + }; + let Some(spec_text) = spec_text else { + return refused( + "gem_spec_missing", + format!( + "no stub gemspec at {} (a path source cannot be wired without one)", + spec_src + .as_deref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "/specifications".to_string()) + ), + ); + }; + // Textual heuristic, deliberately fail-closed on a match: bundler skips + // extension builds for path sources entirely, so a native gem would + // install fine and then fail at `require` time with a missing `.so`. + if gemspec_declares_extensions(&spec_text) { + return refused( + "native_extensions_unsupported", + format!( + "{leaf}.gemspec declares native extensions; bundler does not build extensions for path-sourced gems" + ), + ); + } + + // ── idempotent hot path ────────────────────────────────────────────── + // Copy (incl. the gemspec) already carries every afterHash and both files + // already reference the uuid path → touch nothing. `entry` stays `None`: + // the first run's ledger entry holds the only copy of the pre-vendor + // originals. + let remote_line = format!(" remote: {copy_rel}"); + if copy_matches_after_hashes(©_dir, &record.files).await + && tokio::fs::metadata(copy_dir.join(format!("{name}.gemspec"))).await.is_ok() + && lock_text.split('\n').any(|l| l == remote_line) + && gemfile_text.contains(©_rel) + { + let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + return VendorOutcome::Done { + result: synthesized_result(purl, ©_dir, verified, true, None), + entry: None, + warnings: Vec::new(), + }; + } + + // ── dry run: verify-only against the installed dir, no writes ──────── + if dry_run { + let mut result = + apply_package_patch(purl, installed_dir, &record.files, sources, Some(&record.uuid), true, force) + .await; + result.package_path = copy_dir.display().to_string(); + return VendorOutcome::Done { + result, + entry: None, + warnings: Vec::new(), + }; + } + + // ── Gemfile edit plan (refusals before any write) ──────────────────── + let plan = match plan_gemfile_edit(&gemfile_text, name, version, ©_rel) { + Ok(p) => p, + Err(detail) => return refused("gemfile_declaration_not_editable", detail), + }; + + // ── copy + patch ───────────────────────────────────────────────────── + if let Err(e) = fresh_copy(installed_dir, ©_dir, None).await { + return VendorOutcome::Done { + result: synthesized_result( + purl, + ©_dir, + Vec::new(), + false, + Some(format!("failed to copy installed gem: {e}")), + ), + entry: None, + warnings: Vec::new(), + }; + } + // The vendored dir is freshly created and not yet referenced by anything, + // so a plain write suffices for the gemspec. + if let Err(e) = tokio::fs::write(copy_dir.join(format!("{name}.gemspec")), &spec_text).await { + let _ = remove_tree(&uuid_dir).await; + return VendorOutcome::Done { + result: synthesized_result( + purl, + ©_dir, + Vec::new(), + false, + Some(format!("failed to copy the stub gemspec into the vendored dir: {e}")), + ), + entry: None, + warnings: Vec::new(), + }; + } + let mut result = + apply_package_patch(purl, ©_dir, &record.files, sources, Some(&record.uuid), false, force) + .await; + result.package_path = copy_dir.display().to_string(); + if !result.success { + // Don't leave a half-built copy; neither file was touched. + let _ = remove_tree(&uuid_dir).await; + return VendorOutcome::Done { + result, + entry: None, + warnings: Vec::new(), + }; + } + + // ── Gemfile edit ───────────────────────────────────────────────────── + let new_gemfile = apply_gemfile_plan(&gemfile_text, &plan); + if let Err(e) = atomic_write_bytes(&gemfile_path, new_gemfile.as_bytes()).await { + let _ = remove_tree(&uuid_dir).await; + result.success = false; + result.error = Some(format!("failed to write Gemfile: {e}")); + return VendorOutcome::Done { result, entry: None, warnings: Vec::new() }; + } + + // ── Gemfile.lock edit (a failure here unwinds the Gemfile) ─────────── + let lock_edit = match edit_lock(&lock_text, name, version, ©_rel) { + Ok(edit) => match atomic_write_bytes(&lock_path, edit.text.as_bytes()).await { + Ok(()) => Ok(edit), + Err(e) => Err(format!("failed to write Gemfile.lock: {e}")), + }, + Err(e) => Err(format!("failed to edit Gemfile.lock: {e}")), + }; + let lock_edit = match lock_edit { + Ok(edit) => edit, + Err(mut detail) => { + // Unwind: a Gemfile pointing at a path the lock doesn't agree + // with is exactly the half-wired state the pair edit exists to + // prevent — restore the recorded original bytes. + if let Err(e) = atomic_write_bytes(&gemfile_path, gemfile_text.as_bytes()).await { + detail.push_str(&format!(" (Gemfile unwind also failed: {e})")); + } + let _ = remove_tree(&uuid_dir).await; + result.success = false; + result.error = Some(detail); + return VendorOutcome::Done { result, entry: None, warnings: Vec::new() }; + } + }; + + // ── marker + ledger entry ──────────────────────────────────────────── + let mut warnings = Vec::new(); + let base_purl = build_gem_purl(name, version); + let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); + vulnerabilities.sort(); + let marker = VendorMarker { + schema_version: MARKER_SCHEMA_VERSION, + purl: base_purl.clone(), + patch_uuid: record.uuid.clone(), + ecosystem: "gem".to_string(), + vulnerabilities, + vendored_at: vendored_at.to_string(), + }; + if let Err(e) = write_marker(&uuid_dir, &marker).await { + // Informational only (state.json is the ledger of record) — a marker + // failure must not fail an otherwise-wired vendor. + warnings.push(VendorWarning::new( + "vendor_marker_write_failed", + format!("could not write {}: {e}", super::state::VENDOR_MARKER_FILE), + )); + } + + let gemfile_record = match &plan { + GemfilePlan::Rewrite { original_line, new_line } => WiringRecord { + file: GEMFILE.to_string(), + kind: GEMFILE_WIRING_KIND.to_string(), + action: WiringAction::Rewritten, + key: Some(name.to_string()), + original: Some(Value::String(original_line.clone())), + new: Some(Value::String(new_line.clone())), + }, + GemfilePlan::Append { block } => WiringRecord { + file: GEMFILE.to_string(), + kind: GEMFILE_WIRING_KIND.to_string(), + action: WiringAction::Added, + key: Some(name.to_string()), + original: None, + new: Some(Value::String(block.clone())), + }, + }; + let mut original_lines: Vec = lock_edit + .removed_spec_block + .iter() + .map(|l| Value::String(l.clone())) + .collect(); + if let Some(dep) = &lock_edit.old_dep_line { + original_lines.push(Value::String(dep.clone())); + } + let mut new_lines: Vec = lock_edit + .path_section + .iter() + .map(|l| Value::String(l.clone())) + .collect(); + new_lines.push(Value::String(lock_edit.new_dep_line.clone())); + let lock_record = WiringRecord { + file: GEMFILE_LOCK.to_string(), + kind: LOCK_WIRING_KIND.to_string(), + action: WiringAction::Rewritten, + key: Some(name.to_string()), + original: Some(Value::Array(original_lines)), + new: Some(Value::Array(new_lines)), + }; + + let entry = VendorEntry { + ecosystem: "gem".to_string(), + base_purl, + uuid: record.uuid.clone(), + artifact: VendorArtifact { + path: copy_rel, + sha256: String::new(), // dir-shaped: integrity is per-file afterHashes + size: None, + platform_locked: None, + }, + wiring: vec![gemfile_record, lock_record], + lock: None, + took_over_go_patches: false, + flavor: None, + uv: None, + }; + + VendorOutcome::Done { + result, + entry: Some(entry), + warnings, + } +} + +/// Revert a gem vendor entry: restore the Gemfile line / delete the managed +/// block, splice the lock's spec block back into GEM specs (sorted) and the +/// original DEPENDENCIES entry back in, then remove the validated uuid dir. +/// Each fragment that no longer looks like what vendor wrote — a hand edit, a +/// `bundle update`, a newer vendor run — is left alone with a +/// `vendor_lock_entry_drifted` warning. +pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) -> RevertOutcome { + // SECURITY: state.json is committed and tamper-able; the uuid keys the + // directory we are about to delete. Anything but the canonical uuid + // grammar is rejected fail-closed before any disk access. + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("gem", &entry.uuid) else { + return RevertOutcome::failed(format!( + "refusing revert: non-canonical patch uuid {:?}", + entry.uuid + )); + }; + let uuid_dir = project_root.join(&uuid_dir_rel); + let mut warnings = Vec::new(); + + // Wiring is restored in reverse application order: lock first, Gemfile + // last (the mirror image of vendor's Gemfile-then-lock). + for w in entry.wiring.iter().rev() { + let restored = match w.kind.as_str() { + LOCK_WIRING_KIND => revert_lock_record(&project_root.join(GEMFILE_LOCK), w, dry_run).await, + GEMFILE_WIRING_KIND => revert_gemfile_record(&project_root.join(GEMFILE), w, dry_run).await, + _ => { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("unrecognized wiring kind {:?}; fragment left alone", w.kind), + )); + continue; + } + }; + match restored { + Ok(true) => {} + Ok(false) => warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "{} no longer carries what vendor wrote for {}; left alone", + w.file, + w.key.as_deref().unwrap_or("") + ), + )), + Err(e) => { + return RevertOutcome { + success: false, + warnings, + error: Some(e), + }; + } + } + } + + if !dry_run { + if let Err(e) = remove_tree(&uuid_dir).await { + return RevertOutcome { + success: false, + warnings, + error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), + }; + } + } + + RevertOutcome { + success: true, + warnings, + error: None, + } +} + +// ── Gemfile editing ────────────────────────────────────────────────────────── + +/// The planned Gemfile edit. +enum GemfilePlan { + /// The gem is declared on a safe single top-level line: rewrite it in + /// place (quote style preserved). + Rewrite { original_line: String, new_line: String }, + /// The gem is transitive (not declared): append a fenced managed block. + Append { block: String }, +} + +/// Decide how to edit the Gemfile, or explain why it cannot be edited. +/// +/// Deliberately conservative: only a single, top-level, statically-parseable +/// `gem "" …` line qualifies for rewriting. Anything else — indented +/// (inside a `group`/`platforms`/conditional block), parenthesized, +/// continued onto the next line, conditional, or already carrying a +/// `path:`/`git:`/`github:` source — is refused rather than guessed at: a +/// wrong Gemfile rewrite executes on every `bundle` invocation. +fn plan_gemfile_edit( + text: &str, + name: &str, + version: &str, + rel: &str, +) -> Result { + let lines: Vec<&str> = text.split('\n').collect(); + // (line idx, top-level?, paren-call?, quote, rest-after-name) + let mut found: Vec<(usize, bool, bool, char, String)> = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim_start(); + if trimmed.starts_with('#') { + continue; + } + if let Some((q, rest, paren)) = gem_declaration(trimmed, name) { + found.push((i, trimmed.len() == line.len(), paren, q, rest.to_string())); + } + } + if found.is_empty() { + return Ok(GemfilePlan::Append { + block: format!( + "{MANAGED_OPEN}\ngem \"{name}\", \"{version}\", path: \"{rel}\"\n{MANAGED_CLOSE}\n" + ), + }); + } + if found.len() > 1 { + return Err(format!("`gem \"{name}\"` is declared more than once in the Gemfile")); + } + let (idx, top_level, paren, q, rest) = found.remove(0); + if !top_level { + return Err(format!( + "the `gem \"{name}\"` declaration is indented (inside a group/conditional block)" + )); + } + if paren { + return Err(format!("the `gem \"{name}\"` declaration uses a parenthesized call")); + } + if let Some(reason) = rest_blocks_edit(&rest) { + return Err(format!("the `gem \"{name}\"` declaration is not editable: {reason}")); + } + Ok(GemfilePlan::Rewrite { + original_line: lines[idx].to_string(), + new_line: format!("gem {q}{name}{q}, {q}{version}{q}, path: {q}{rel}{q}"), + }) +} + +/// Match `gem ""` / `gem ''` (or the parenthesized call form) at +/// the start of a trimmed line. Returns the quote char, everything after the +/// closing quote, and whether the call was parenthesized. +fn gem_declaration<'a>(trimmed: &'a str, name: &str) -> Option<(char, &'a str, bool)> { + let rest = trimmed.strip_prefix("gem")?; + let (paren, rest) = match rest.strip_prefix(' ') { + Some(r) => (false, r), + None => (true, rest.strip_prefix('(')?), + }; + let rest = rest.trim_start(); + let q = rest.chars().next()?; + if q != '"' && q != '\'' { + return None; + } + let rest = &rest[1..]; + let end = rest.find(q)?; + if &rest[..end] != name { + return None; + } + Some((q, &rest[end + 1..], paren)) +} + +/// Why the text after the gem name blocks an in-place rewrite (`None` = safe). +/// Only the code before any `#` comment counts — a trailing comment is +/// dropped by the rewrite, which is acceptable because the verbatim original +/// line lives in the ledger for revert. +fn rest_blocks_edit(rest: &str) -> Option { + let code = rest.split('#').next().unwrap_or("").trim(); + if code.is_empty() { + return None; + } + if !code.starts_with(',') { + return Some("unexpected tokens after the gem name".to_string()); + } + if code.ends_with(',') { + return Some("the declaration continues on the next line".to_string()); + } + for tok in ["path:", ":path", "git:", ":git", "github:", ":github"] { + if code.contains(tok) { + return Some(format!( + "the declaration already carries `{tok}` (revert any previous vendoring first)" + )); + } + } + if code.contains(" if ") || code.contains(" unless ") { + return Some("conditional declaration".to_string()); + } + None +} + +fn apply_gemfile_plan(text: &str, plan: &GemfilePlan) -> String { + match plan { + GemfilePlan::Rewrite { original_line, new_line } => { + let mut lines: Vec<&str> = text.split('\n').collect(); + if let Some(i) = lines.iter().position(|l| *l == original_line) { + lines[i] = new_line; + } + lines.join("\n") + } + GemfilePlan::Append { block } => { + let mut out = text.to_string(); + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(block); + out + } + } +} + +// ── Gemfile.lock editing ───────────────────────────────────────────────────── + +/// The applied lock edit plus the verbatim fragments the ledger records. +struct LockEdit { + text: String, + /// The gem's GEM spec block as removed (4-space line + 6-space sublines). + removed_spec_block: Vec, + /// The pre-vendor DEPENDENCIES entry (`None` = the gem was transitive and + /// the entry was added; revert deletes it). + old_dep_line: Option, + /// The emitted PATH section lines. + path_section: Vec, + /// The DEPENDENCIES entry we wrote (` (= )!`). + new_dep_line: String, +} + +/// Produce the pair-edited lock text (see the module doc for the canonical +/// form). Pure string surgery on exact line spans — every byte not +/// deliberately changed is preserved, which is what keeps the result +/// byte-identical to what bundler regenerates. +fn edit_lock(text: &str, name: &str, version: &str, rel: &str) -> Result { + let mut lines: Vec = text.split('\n').map(str::to_string).collect(); + + // 1. Lift the gem's spec block out of GEM/specs. + let (gem_start, gem_end) = + section_span(&lines, "GEM").ok_or_else(|| "Gemfile.lock has no GEM section".to_string())?; + if !(gem_start..gem_end).any(|i| lines[i] == " specs:") { + return Err("Gemfile.lock GEM section has no specs: stanza".to_string()); + } + let target = format!(" {name} ({version})"); + let block_start = (gem_start..gem_end) + .find(|&i| lines[i] == target) + .ok_or_else(|| format!("Gemfile.lock GEM specs has no entry `{name} ({version})`"))?; + let mut block_end = block_start + 1; + while block_end < gem_end && lines[block_end].starts_with(" ") { + block_end += 1; + } + let removed_spec_block: Vec = lines.drain(block_start..block_end).collect(); + + // 2. DEPENDENCIES: exact pin + `!` path-source marker. A transitive gem + // (absent pre-vendor) is inserted at bundler's sorted position — it is a + // Gemfile dependency now. + let (dep_start, dep_end) = section_span(&lines, "DEPENDENCIES") + .ok_or_else(|| "Gemfile.lock has no DEPENDENCIES section".to_string())?; + let new_dep_line = format!(" {name} (= {version})!"); + let mut old_dep_line: Option = None; + let mut insert_at = dep_start + 1; + let mut existing_idx: Option = None; + for (i, line) in lines.iter().enumerate().take(dep_end).skip(dep_start + 1) { + let Some(dep_name) = dep_entry_name(line) else { + continue; + }; + if dep_name == name { + existing_idx = Some(i); + break; + } + if dep_name < name { + insert_at = i + 1; + } + } + match existing_idx { + Some(i) => { + old_dep_line = Some(lines[i].clone()); + lines[i] = new_dep_line.clone(); + } + None => lines.insert(insert_at, new_dep_line.clone()), + } + + // 3. PATH section directly above the GEM section (bundler's canonical + // placement; spike claim 2). `remote:` is the bare relative path. + let mut path_section = vec![ + "PATH".to_string(), + format!(" remote: {rel}"), + " specs:".to_string(), + ]; + path_section.extend(removed_spec_block.iter().cloned()); + let gem_hdr = lines + .iter() + .position(|l| l.as_str() == "GEM") + .ok_or_else(|| "Gemfile.lock lost its GEM section".to_string())?; + let mut insert = path_section.clone(); + insert.push(String::new()); // blank separator before GEM + lines.splice(gem_hdr..gem_hdr, insert); + + Ok(LockEdit { + text: lines.join("\n"), + removed_spec_block, + old_dep_line, + path_section, + new_dep_line, + }) +} + +/// `[start, end)` of a lock section: the column-0 `header` line through (not +/// including) the next column-0 line. Blank separator lines belong to the +/// section they follow. +fn section_span(lines: &[String], header: &str) -> Option<(usize, usize)> { + let start = lines.iter().position(|l| l.as_str() == header)?; + let mut end = start + 1; + while end < lines.len() { + let l = &lines[end]; + if !l.is_empty() && !l.starts_with(' ') { + break; + } + end += 1; + } + Some((start, end)) +} + +/// Name of a 2-space DEPENDENCIES entry (` rack (~> 3.1)` / ` rack!`). +fn dep_entry_name(line: &str) -> Option<&str> { + let rest = line.strip_prefix(" ")?; + if rest.is_empty() || rest.starts_with(' ') { + return None; + } + let end = rest.find([' ', '(', '!']).unwrap_or(rest.len()); + Some(&rest[..end]) +} + +/// Name of a 4-space spec entry (` rack (3.2.6)`). +fn spec_entry_name(line: &str) -> Option<&str> { + let rest = line.strip_prefix(" ")?; + if rest.is_empty() || rest.starts_with(' ') { + return None; + } + Some(rest.split(' ').next().unwrap_or(rest)) +} + +// ── revert helpers ─────────────────────────────────────────────────────────── + +/// Restore one `gemfile_line` record. `Ok(true)` = restored (or would be, on +/// dry run); `Ok(false)` = the written line/block is gone (drift), left alone. +async fn revert_gemfile_record( + gemfile_path: &Path, + w: &WiringRecord, + dry_run: bool, +) -> Result { + let text = match tokio::fs::read_to_string(gemfile_path).await { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(format!("unreadable Gemfile: {e}")), + }; + let Some(written) = w.new.as_ref().and_then(Value::as_str) else { + return Ok(false); + }; + let restored = match w.action { + WiringAction::Rewritten => { + let Some(original) = w.original.as_ref().and_then(Value::as_str) else { + return Ok(false); + }; + let mut lines: Vec<&str> = text.split('\n').collect(); + let Some(i) = lines.iter().position(|l| *l == written) else { + return Ok(false); + }; + lines[i] = original; + lines.join("\n") + } + WiringAction::Added => { + let Some(at) = text.find(written) else { + return Ok(false); + }; + let mut out = String::with_capacity(text.len()); + out.push_str(&text[..at]); + out.push_str(&text[at + written.len()..]); + out + } + }; + if !dry_run { + atomic_write_bytes(gemfile_path, restored.as_bytes()) + .await + .map_err(|e| format!("failed to write Gemfile: {e}"))?; + } + Ok(true) +} + +/// Restore one `gemfile_lock_spec` record. `Ok(true)` = restored (or would +/// be, on dry run); `Ok(false)` = the lock no longer carries what vendor +/// wrote (drift), left alone in full — a partial splice would corrupt it. +async fn revert_lock_record( + lock_path: &Path, + w: &WiringRecord, + dry_run: bool, +) -> Result { + let Some(original_lines) = wiring_string_array(w.original.as_ref()) else { + return Ok(false); + }; + let Some(new_lines) = wiring_string_array(w.new.as_ref()) else { + return Ok(false); + }; + let text = match tokio::fs::read_to_string(lock_path).await { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(format!("unreadable Gemfile.lock: {e}")), + }; + let Some(restored) = revert_lock_text(&text, &original_lines, &new_lines) else { + return Ok(false); + }; + if !dry_run { + atomic_write_bytes(lock_path, restored.as_bytes()) + .await + .map_err(|e| format!("failed to write Gemfile.lock: {e}"))?; + } + Ok(true) +} + +fn wiring_string_array(v: Option<&Value>) -> Option> { + v?.as_array()? + .iter() + .map(|x| x.as_str().map(str::to_string)) + .collect() +} + +/// Pure splice reversing [`edit_lock`]: drop the PATH section vendor emitted, +/// move the spec block back into GEM/specs at its sorted position, and +/// restore (or delete) the DEPENDENCIES entry. All preconditions are checked +/// BEFORE any mutation so drift never yields a half-restored lock; `None` +/// means "drifted, leave the lock alone". +fn revert_lock_text(text: &str, original_lines: &[String], new_lines: &[String]) -> Option { + let (new_dep_line, path_lines) = new_lines.split_last()?; + let remote_line = path_lines.get(1)?; + if !remote_line.starts_with(" remote: ") { + return None; + } + let spec_block: Vec<&String> = original_lines.iter().filter(|l| l.starts_with(" ")).collect(); + let old_dep_line = original_lines + .iter() + .find(|l| l.starts_with(" ") && !l[2..].starts_with(' ')); + let our_name = spec_entry_name(spec_block.first()?)?.to_string(); + + let mut lines: Vec = text.split('\n').map(str::to_string).collect(); + + // Preconditions on the untouched lines. + let (path_start, path_end) = find_path_section(&lines, remote_line)?; + if !lines.iter().any(|l| l == new_dep_line) { + return None; + } + { + let (gs, ge) = section_span(&lines, "GEM")?; + (gs..ge).find(|&i| lines[i] == " specs:")?; + } + + // 1. Drop the PATH section (incl. its trailing blank separator). + lines.drain(path_start..path_end); + + // 2. Spec block back into GEM/specs, sorted by entry name (bundler keeps + // specs alphabetized; the block came out of a sorted list). + let (gs, ge) = section_span(&lines, "GEM")?; + let specs_idx = (gs..ge).find(|&i| lines[i] == " specs:")?; + let mut insert_at = specs_idx + 1; + let mut i = specs_idx + 1; + while i < ge { + let line = &lines[i]; + if line.is_empty() { + break; + } + match spec_entry_name(line) { + Some(n) if n > our_name.as_str() => break, + Some(_) => { + i += 1; + while i < ge && lines[i].starts_with(" ") { + i += 1; + } + insert_at = i; + } + None => i += 1, + } + } + lines.splice(insert_at..insert_at, spec_block.iter().map(|l| (*l).clone())); + + // 3. DEPENDENCIES entry: restore the original line, or delete the one we + // added for a transitive gem. + let dep_idx = lines.iter().position(|l| l == new_dep_line)?; + match old_dep_line { + Some(orig) => lines[dep_idx] = orig.clone(), + None => { + lines.remove(dep_idx); + } + } + + Some(lines.join("\n")) +} + +/// Find the PATH section containing exactly `remote_line` (there may be +/// several PATH sections; only ours is touched). +fn find_path_section(lines: &[String], remote_line: &str) -> Option<(usize, usize)> { + let mut from = 0; + while let Some(off) = lines[from..].iter().position(|l| l.as_str() == "PATH") { + let start = from + off; + let mut end = start + 1; + while end < lines.len() { + let l = &lines[end]; + if !l.is_empty() && !l.starts_with(' ') { + break; + } + end += 1; + } + if lines[start..end].iter().any(|l| l.as_str() == remote_line) { + return Some((start, end)); + } + from = end; + } + None +} + +// ── shared helpers ─────────────────────────────────────────────────────────── + +/// Plain gem-token charset (letters, digits, `.`, `_`, `-`). See the SECURITY +/// note in [`vendor_gem`] — these strings are embedded verbatim into ruby +/// source and lock line grammar, so this is deliberately stricter than the +/// path-level `is_safe_single_segment`. +fn is_plain_gem_token(s: &str) -> bool { + !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) +} + +/// Textual heuristic for `s.extensions = […]` / `spec.extensions << …` style +/// declarations (comment-stripped per line). A match always refuses +/// (fail-closed); a miss — e.g. extensions assigned through interpolation +/// tricks — falls through, which only loses the refusal's nicer error, not +/// safety. Parsing ruby for real would need a ruby. +fn gemspec_declares_extensions(spec_text: &str) -> bool { + for raw in spec_text.lines() { + let line = raw.split('#').next().unwrap_or(""); + if let Some(idx) = line.find(".extensions") { + let after = line[idx + ".extensions".len()..].trim_start(); + if (after.starts_with('=') && !after.starts_with("==")) + || after.starts_with("<<") + || after.starts_with("+=") + || after.starts_with(".push") + || after.starts_with(".concat") + { + return true; + } + } + } + false +} + +/// True when the copy exists and every patched file in it already hashes to +/// its `afterHash` (the vendor twin of `go_redirect::redirect_in_sync`). +async fn copy_matches_after_hashes( + copy_dir: &Path, + files: &HashMap, +) -> bool { + if tokio::fs::metadata(copy_dir).await.is_err() { + return false; + } + for (file_name, info) in files { + let normalized = normalize_file_path(file_name); + // SECURITY: never hash through a manifest key that escapes the copy + // dir — fail the sync check instead (the full pipeline would refuse + // the key anyway). + if !is_safe_relative_subpath(normalized) { + return false; + } + match compute_file_git_sha256(©_dir.join(normalized)).await { + Ok(h) if h == info.after_hash => {} + _ => return false, + } + } + true +} + +fn refused(code: &'static str, detail: impl Into) -> VendorOutcome { + VendorOutcome::Refused { + code, + detail: detail.into(), + } +} + +fn synthesized_result( + package_key: &str, + copy_dir: &Path, + files_verified: Vec, + success: bool, + error: Option, +) -> ApplyResult { + ApplyResult { + package_key: package_key.to_string(), + package_path: copy_dir.display().to_string(), + success, + files_verified, + files_patched: Vec::new(), + applied_via: HashMap::new(), + error, + sidecar: None, + } +} + +fn already_patched_verify(file: &str) -> VerifyResult { + VerifyResult { + file: file.to_string(), + status: VerifyStatus::AlreadyPatched, + message: None, + current_hash: None, + expected_hash: None, + target_hash: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use std::path::PathBuf; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const PURL: &str = "pkg:gem/rack@3.2.6"; + const PRISTINE: &[u8] = b"module Rack\n VERSION = \"3.2.6\"\nend\n"; + const PATCHED: &[u8] = b"module Rack\n SOCKET_PATCHED = true\n VERSION = \"3.2.6\"\nend\n"; + + const GEMSPEC: &str = "Gem::Specification.new do |s|\n s.name = \"rack\"\n s.version = \"3.2.6\"\n s.summary = \"a modular Ruby web server interface\"\n s.require_paths = [\"lib\"]\nend\n"; + + const GEMFILE_DIRECT: &str = "source \"https://rubygems.org\"\n\ngem \"puma\"\ngem \"rack\", \"~> 3.1\"\n"; + const GEMFILE_TRANSITIVE: &str = "source \"https://rubygems.org\"\n\ngem \"puma\"\n"; + + const LOCK_DIRECT: &str = "GEM\n remote: https://rubygems.org/\n specs:\n puma (6.4.2)\n nio4r (~> 2.0)\n rack (3.2.6)\n base64 (>= 0.1.0)\n\nPLATFORMS\n arm64-darwin-23\n ruby\n\nDEPENDENCIES\n puma\n rack (~> 3.1)\n\nBUNDLED WITH\n 2.5.22\n"; + const LOCK_TRANSITIVE: &str = "GEM\n remote: https://rubygems.org/\n specs:\n puma (6.4.2)\n nio4r (~> 2.0)\n rack (3.2.6)\n base64 (>= 0.1.0)\n\nPLATFORMS\n arm64-darwin-23\n ruby\n\nDEPENDENCIES\n puma\n\nBUNDLED WITH\n 2.5.22\n"; + + fn copy_rel() -> String { + format!(".socket/vendor/gem/{UUID}/rack-3.2.6") + } + + /// Fixture: a gem home (gems/ + specifications/ siblings), a bundler + /// project (Gemfile + Gemfile.lock), and a blobs dir with the patched + /// bytes. Returns (tmp, project_root, installed_dir, blobs, record). + async fn fixture( + gemfile: &str, + lock: &str, + ) -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf, PatchRecord) { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let installed = base.join("gem_home/gems/rack-3.2.6"); + tokio::fs::create_dir_all(installed.join("lib")).await.unwrap(); + tokio::fs::write(installed.join("lib/rack.rb"), PRISTINE).await.unwrap(); + let specs = base.join("gem_home/specifications"); + tokio::fs::create_dir_all(&specs).await.unwrap(); + tokio::fs::write(specs.join("rack-3.2.6.gemspec"), GEMSPEC).await.unwrap(); + + let root = base.join("project"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(root.join(GEMFILE), gemfile).await.unwrap(); + tokio::fs::write(root.join(GEMFILE_LOCK), lock).await.unwrap(); + + let before = compute_git_sha256_from_bytes(PRISTINE); + let after = compute_git_sha256_from_bytes(PATCHED); + let blobs = base.join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + tokio::fs::write(blobs.join(&after), PATCHED).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "lib/rack.rb".to_string(), + PatchFileInfo { + before_hash: before, + after_hash: after, + }, + ); + let record = PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-06-09T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }; + (dir, root, installed, blobs, record) + } + + fn unwrap_done(o: VendorOutcome) -> (ApplyResult, Option, Vec) { + match o { + VendorOutcome::Done { + result, + entry, + warnings, + } => (result, entry, warnings), + VendorOutcome::Refused { code, detail } => panic!("refused: {code}: {detail}"), + } + } + + fn unwrap_refused(o: VendorOutcome) -> (&'static str, String) { + match o { + VendorOutcome::Refused { code, detail } => (code, detail), + VendorOutcome::Done { result, .. } => panic!("not refused: {result:?}"), + } + } + + async fn run_vendor( + root: &Path, + blobs: &Path, + installed: &Path, + record: &PatchRecord, + dry_run: bool, + ) -> VendorOutcome { + let sources = PatchSources::blobs_only(blobs); + vendor_gem( + PURL, + installed, + root, + record, + &sources, + "2026-06-09T00:00:00Z", + dry_run, + false, + ) + .await + } + + fn expected_lock_direct() -> String { + format!( + "PATH\n remote: {rel}\n specs:\n rack (3.2.6)\n base64 (>= 0.1.0)\n\nGEM\n remote: https://rubygems.org/\n specs:\n puma (6.4.2)\n nio4r (~> 2.0)\n\nPLATFORMS\n arm64-darwin-23\n ruby\n\nDEPENDENCIES\n puma\n rack (= 3.2.6)!\n\nBUNDLED WITH\n 2.5.22\n", + rel = copy_rel() + ) + } + + #[tokio::test] + async fn test_direct_dep_happy_path() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + + let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + + // Copy patched + gemspec materialized; installed dir untouched. + let copy = root.join(copy_rel()); + assert_eq!(tokio::fs::read(copy.join("lib/rack.rb")).await.unwrap(), PATCHED); + assert_eq!( + tokio::fs::read_to_string(copy.join("rack.gemspec")).await.unwrap(), + GEMSPEC, + "stub gemspec copied in as .gemspec" + ); + assert_eq!(tokio::fs::read(installed.join("lib/rack.rb")).await.unwrap(), PRISTINE); + + // Gemfile: line rewritten in place, double quotes preserved. + let gemfile = tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(); + assert_eq!( + gemfile, + format!( + "source \"https://rubygems.org\"\n\ngem \"puma\"\ngem \"rack\", \"3.2.6\", path: \"{}\"\n", + copy_rel() + ) + ); + + // Lock: the exact bundler-canonical pair-edit form (PATH before GEM, + // bare relative remote, spec block moved with its sublines, exact-pin + // `!` dependency, PLATFORMS/BUNDLED WITH byte-preserved). + let lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + assert_eq!(lock, expected_lock_direct()); + + // Marker present in the uuid dir. + let marker = tokio::fs::read_to_string( + root.join(format!(".socket/vendor/gem/{UUID}/{VENDOR_MARKER_FILE}")), + ) + .await + .unwrap(); + assert!(marker.contains(UUID)); + assert!(marker.contains("\"ecosystem\": \"gem\"")); + + // Ledger entry: artifact + both wiring records with verbatim text. + let entry = entry.expect("success must carry a ledger entry"); + assert_eq!(entry.ecosystem, "gem"); + assert_eq!(entry.base_purl, PURL); + assert_eq!(entry.artifact.path, copy_rel()); + assert_eq!(entry.wiring.len(), 2); + let gf = &entry.wiring[0]; + assert_eq!(gf.file, GEMFILE); + assert_eq!(gf.kind, GEMFILE_WIRING_KIND); + assert_eq!(gf.action, WiringAction::Rewritten); + assert_eq!(gf.key.as_deref(), Some("rack")); + assert_eq!( + gf.original.as_ref().unwrap(), + &Value::String("gem \"rack\", \"~> 3.1\"".to_string()) + ); + let lk = &entry.wiring[1]; + assert_eq!(lk.file, GEMFILE_LOCK); + assert_eq!(lk.kind, LOCK_WIRING_KIND); + assert_eq!(lk.action, WiringAction::Rewritten); + let orig = lk.original.as_ref().unwrap().as_array().unwrap(); + assert_eq!( + orig, + &vec![ + Value::String(" rack (3.2.6)".to_string()), + Value::String(" base64 (>= 0.1.0)".to_string()), + Value::String(" rack (~> 3.1)".to_string()), + ], + "spec block + old DEPENDENCIES line recorded verbatim" + ); + let new = lk.new.as_ref().unwrap().as_array().unwrap(); + assert_eq!( + new.last().unwrap(), + &Value::String(" rack (= 3.2.6)!".to_string()) + ); + } + + #[tokio::test] + async fn test_single_quote_style_preserved() { + let gemfile = "source 'https://rubygems.org'\n\ngem 'rack', '~> 3.1'\n"; + let lock = LOCK_DIRECT.replace(" puma\n", "").replace(" puma (6.4.2)\n nio4r (~> 2.0)\n", ""); + let (_tmp, root, installed, blobs, record) = fixture(gemfile, &lock).await; + + let (result, _e, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + let new_gemfile = tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(); + assert!( + new_gemfile.contains(&format!("gem 'rack', '3.2.6', path: '{}'", copy_rel())), + "single-quote style preserved: {new_gemfile}" + ); + } + + #[tokio::test] + async fn test_transitive_appends_managed_block_and_sorted_dep() { + let (_tmp, root, installed, blobs, record) = + fixture(GEMFILE_TRANSITIVE, LOCK_TRANSITIVE).await; + + let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + + let gemfile = tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(); + assert_eq!( + gemfile, + format!( + "source \"https://rubygems.org\"\n\ngem \"puma\"\n{MANAGED_OPEN}\ngem \"rack\", \"3.2.6\", path: \"{}\"\n{MANAGED_CLOSE}\n", + copy_rel() + ) + ); + + // DEPENDENCIES gains the pin in sorted position (after puma). + let lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + assert!( + lock.contains("DEPENDENCIES\n puma\n rack (= 3.2.6)!\n"), + "sorted insert: {lock}" + ); + + let entry = entry.unwrap(); + assert_eq!(entry.wiring[0].action, WiringAction::Added); + assert!(entry.wiring[0].original.is_none()); + // No old DEPENDENCIES line recorded → revert deletes the added one. + let orig = entry.wiring[1].original.as_ref().unwrap().as_array().unwrap(); + assert!( + orig.iter().all(|l| l.as_str().unwrap().starts_with(" ")), + "transitive: only the spec block is recorded: {orig:?}" + ); + } + + #[tokio::test] + async fn test_refuses_missing_gemfile() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + tokio::fs::remove_file(root.join(GEMFILE)).await.unwrap(); + + let (code, _d) = unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + assert_eq!(code, "gemfile_missing"); + assert!(!root.join(".socket").exists(), "refusal must write nothing"); + } + + #[tokio::test] + async fn test_refuses_missing_lock() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + tokio::fs::remove_file(root.join(GEMFILE_LOCK)).await.unwrap(); + + let (code, _d) = unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + assert_eq!(code, "vendor_lockfile_missing"); + assert!(!root.join(".socket").exists()); + } + + #[tokio::test] + async fn test_refuses_native_extensions() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + let spec = installed + .parent() + .unwrap() + .parent() + .unwrap() + .join("specifications/rack-3.2.6.gemspec"); + tokio::fs::write( + &spec, + "Gem::Specification.new do |s|\n s.name = \"rack\"\n # not this: extensions_dir = \"x\"\n s.extensions = [\"ext/rack/extconf.rb\"]\nend\n", + ) + .await + .unwrap(); + + let (code, detail) = unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + assert_eq!(code, "native_extensions_unsupported"); + assert!(detail.contains("native extensions")); + assert!(!root.join(".socket").exists()); + // Neither file touched. + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_DIRECT + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + LOCK_DIRECT + ); + } + + #[tokio::test] + async fn test_refuses_platform_suffixed_dir() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + // Simulate a precompiled platform install: rack-3.2.6-x86_64-linux. + let platform_dir = installed.parent().unwrap().join("rack-3.2.6-x86_64-linux"); + tokio::fs::rename(&installed, &platform_dir).await.unwrap(); + + let (code, _d) = unwrap_refused(run_vendor(&root, &blobs, &platform_dir, &record, false).await); + assert_eq!(code, "platform_gem_unsupported"); + assert!(!root.join(".socket").exists()); + } + + #[tokio::test] + async fn test_refuses_unparseable_declaration() { + // (a) indented inside a group block + let grouped = "source \"https://rubygems.org\"\n\ngroup :test do\n gem \"rack\", \"~> 3.1\"\nend\n"; + let (_tmp, root, installed, blobs, record) = fixture(grouped, LOCK_DIRECT).await; + let (code, detail) = unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + assert_eq!(code, "gemfile_declaration_not_editable"); + assert!(detail.contains("indented"), "{detail}"); + assert!(!root.join(".socket").exists()); + assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), grouped); + + // (b) multi-line declaration (trailing comma continuation) + let multiline = "source \"https://rubygems.org\"\n\ngem \"rack\",\n \"~> 3.1\"\n"; + let (_tmp2, root2, installed2, blobs2, record2) = fixture(multiline, LOCK_DIRECT).await; + let (code, detail) = unwrap_refused(run_vendor(&root2, &blobs2, &installed2, &record2, false).await); + assert_eq!(code, "gemfile_declaration_not_editable"); + assert!(detail.contains("continues"), "{detail}"); + + // (c) already path-sourced (a previous run / a user fork) + let pathed = "source \"https://rubygems.org\"\n\ngem \"rack\", path: \"../rack-fork\"\n"; + let (_tmp3, root3, installed3, blobs3, record3) = fixture(pathed, LOCK_DIRECT).await; + let (code, detail) = unwrap_refused(run_vendor(&root3, &blobs3, &installed3, &record3, false).await); + assert_eq!(code, "gemfile_declaration_not_editable"); + assert!(detail.contains("path:"), "{detail}"); + } + + #[tokio::test] + async fn test_refuses_missing_spec_file() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + tokio::fs::remove_file( + installed + .parent() + .unwrap() + .parent() + .unwrap() + .join("specifications/rack-3.2.6.gemspec"), + ) + .await + .unwrap(); + + let (code, _d) = unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + assert_eq!(code, "gem_spec_missing"); + assert!(!root.join(".socket").exists()); + } + + /// SECURITY: a traversal uuid (tampered manifest) must be refused before + /// any disk access. + #[tokio::test] + async fn test_refuses_traversal_uuid() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + let mut bad = record.clone(); + bad.uuid = "../../escape".to_string(); + + let (code, _d) = unwrap_refused(run_vendor(&root, &blobs, &installed, &bad, false).await); + assert_eq!(code, "unsafe_coordinates"); + assert!(!root.join(".socket").exists()); + assert!(!root.parent().unwrap().join("escape").exists()); + assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), GEMFILE_DIRECT); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + LOCK_DIRECT + ); + } + + #[tokio::test] + async fn test_empty_gem_specs_stanza_kept() { + // The vendored gem is the ONLY entry: the GEM section must keep its + // empty `specs:` stanza (that is the form bundler regenerates). + let gemfile = "source \"https://rubygems.org\"\n\ngem \"rack\", \"~> 3.1\"\n"; + let lock = "GEM\n remote: https://rubygems.org/\n specs:\n rack (3.2.6)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rack (~> 3.1)\n\nBUNDLED WITH\n 2.5.22\n"; + let (_tmp, root, installed, blobs, record) = fixture(gemfile, lock).await; + + let (result, _e, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + let new_lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + assert_eq!( + new_lock, + format!( + "PATH\n remote: {rel}\n specs:\n rack (3.2.6)\n\nGEM\n remote: https://rubygems.org/\n specs:\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rack (= 3.2.6)!\n\nBUNDLED WITH\n 2.5.22\n", + rel = copy_rel() + ) + ); + } + + #[tokio::test] + async fn test_idempotent_rerun_in_sync() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + + let (r1, e1, _) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(r1.success); + assert!(e1.is_some()); + let gemfile1 = tokio::fs::read(root.join(GEMFILE)).await.unwrap(); + let lock1 = tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(); + + let (r2, e2, _) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(r2.success); + assert!(r2.files_patched.is_empty(), "in-sync rerun patches nothing"); + assert!( + r2.files_verified.iter().all(|v| v.status == VerifyStatus::AlreadyPatched), + "synthesized AlreadyPatched: {:?}", + r2.files_verified + ); + assert!( + e2.is_none(), + "hot path must not re-record (would clobber the originals in the ledger)" + ); + assert_eq!(tokio::fs::read(root.join(GEMFILE)).await.unwrap(), gemfile1); + assert_eq!(tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(), lock1); + } + + #[tokio::test] + async fn test_dry_run_writes_nothing() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + + let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, true).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none(), "dry run records nothing"); + assert!(!root.join(".socket").exists(), "no copy created"); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_DIRECT + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + LOCK_DIRECT + ); + } + + #[tokio::test] + async fn test_unwind_on_lock_edit_failure() { + // The lock has no GEM spec entry for rack@3.2.6 (version skew): the + // lock edit fails AFTER the Gemfile was rewritten, so vendor must + // unwind the Gemfile to its original bytes and drop the copy. + let lock = LOCK_DIRECT.replace(" rack (3.2.6)", " rack (3.1.0)"); + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, &lock).await; + + let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("Gemfile.lock")); + assert!(entry.is_none()); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_DIRECT, + "Gemfile unwound to its original bytes" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + lock, + "lock untouched" + ); + assert!( + !root.join(format!(".socket/vendor/gem/{UUID}")).exists(), + "half-built copy removed" + ); + } + + #[tokio::test] + async fn test_revert_round_trip_direct() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + + let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success); + let entry = entry.unwrap(); + + let outcome = revert_gem(&entry, &root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + !outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + "clean revert must not report drift: {:?}", + outcome.warnings + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_DIRECT, + "Gemfile byte-identical to the fixture" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + LOCK_DIRECT, + "lock byte-identical to the fixture" + ); + assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists(), "uuid dir removed"); + } + + #[tokio::test] + async fn test_revert_round_trip_transitive() { + let (_tmp, root, installed, blobs, record) = + fixture(GEMFILE_TRANSITIVE, LOCK_TRANSITIVE).await; + + let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success); + let entry = entry.unwrap(); + + let outcome = revert_gem(&entry, &root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_TRANSITIVE, + "managed block deleted, Gemfile byte-identical" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + LOCK_TRANSITIVE, + "spec block moved back, added DEPENDENCIES entry deleted" + ); + assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists()); + } + + #[tokio::test] + async fn test_revert_drift_warnings() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + + let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success); + let entry = entry.unwrap(); + + // Third-party drift: a `bundle update` regenerated both files back to + // registry form. Revert must leave them alone, warn per file, and + // still remove the artifact dir. + tokio::fs::write(root.join(GEMFILE), GEMFILE_DIRECT).await.unwrap(); + tokio::fs::write(root.join(GEMFILE_LOCK), LOCK_DIRECT).await.unwrap(); + + let outcome = revert_gem(&entry, &root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + let drift_count = outcome + .warnings + .iter() + .filter(|w| w.code == "vendor_lock_entry_drifted") + .count(); + assert_eq!(drift_count, 2, "one drift warning per file: {:?}", outcome.warnings); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_DIRECT + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + LOCK_DIRECT + ); + assert!( + !root.join(format!(".socket/vendor/gem/{UUID}")).exists(), + "uuid dir still removed" + ); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/golang.rs b/crates/socket-patch-core/src/patch/vendor/golang.rs index ed2edb3..55a4a30 100644 --- a/crates/socket-patch-core/src/patch/vendor/golang.rs +++ b/crates/socket-patch-core/src/patch/vendor/golang.rs @@ -1 +1,747 @@ -//! (stub — implementation lands with its backend phase) +//! The golang vendor backend: committable `replace`-directive vendoring. +//! +//! Wraps the project-local Go redirect engine +//! ([`crate::patch::go_redirect`]) with a vendor copy base: the patched module +//! copy lands under `.socket/vendor/golang//@/` +//! and the `go.mod` `replace` points at it ([`ReplaceOwner::Vendor`]). A +//! directory `replace` target bypasses the module cache, sumdb, and `go.sum` +//! entirely, so a fresh checkout builds the patched module fully offline and +//! survives `go mod tidy` (spike-verified — `spikes/PHASE0-FINDINGS.txt`). +//! +//! ## Takeover of an `apply` redirect +//! `ensure_replace_entry`'s cross-owner upsert rewrites an existing +//! `.socket/go-patches/` (apply-owned) directive in place — one atomic +//! `go.mod` write repoints the build at the vendor copy with no remove+add +//! window. The stale go-patches copy is then deleted and the takeover is +//! recorded ([`VendorEntry::took_over_go_patches`]) so `--revert` can tell +//! the user the redirect is NOT restored (re-run `apply` for that). + +use std::path::Path; + +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::PatchSources; +use crate::patch::copy_tree::remove_tree; +use crate::patch::go_mod_edit::{ + self, read_replace_entries, replace_target_path, ReplaceOwner, GO_PATCHES_DIR, +}; +use crate::patch::go_redirect::{apply_go_redirect, are_safe_redirect_coords}; +use crate::utils::purl::{parse_golang_purl, strip_purl_qualifiers}; + +use super::path::vendor_uuid_dir_rel; +use super::state::{ + write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, +}; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +/// Vendor one Go module: patched copy in the uuid dir + a vendor-owned +/// `replace` directive + marker, returning the ledger entry to persist. +/// +/// * `pristine_src` — the crawler's module-cache dir (case-encoded on disk). +/// It is copied, never mutated. +/// * `vendored_at` — caller-formatted RFC3339 timestamp for the marker. +/// +/// `dry_run` writes nothing (read-only verify against `pristine_src`); +/// `entry` is then `None`. A user-authored `replace` for the same +/// module+version surfaces as a failed result (the engine's `go.mod` editor +/// refuses it), not a refusal — the verify report is still useful. +#[allow(clippy::too_many_arguments)] +pub async fn vendor_go_module( + purl: &str, + pristine_src: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, +) -> VendorOutcome { + // ── coordinate validation (fail-closed, before any disk access) ────── + let Some((module, version)) = parse_golang_purl(purl) else { + return VendorOutcome::Refused { + code: "unsafe_coordinates", + detail: format!("not a golang purl: {purl}"), + }; + }; + // SECURITY: `module`+`version` key the on-disk copy dir + // (`.socket/vendor/golang//@/`) and the `replace` + // target path. A `..` segment / absolute path / backslash from a tampered + // manifest PURL would let the copy escape `.socket/vendor/` — refuse + // before any disk access (same guard the redirect engine applies). + if !are_safe_redirect_coords(module, version) { + return VendorOutcome::Refused { + code: "unsafe_coordinates", + detail: format!( + "refusing to vendor unsafe golang coordinates `{module}`/`{version}` \ + (a `..` segment, absolute path, or separator would escape \ + .socket/vendor/golang/)" + ), + }; + } + // SECURITY: the uuid is a dedicated path level created here and deleted by + // `--revert`; anything but the canonical UUID grammar is rejected. + let Some(base_rel) = vendor_uuid_dir_rel("golang", &record.uuid) else { + return VendorOutcome::Refused { + code: "unsafe_coordinates", + detail: format!( + "refusing to vendor {purl}: patch uuid `{}` is not a canonical uuid", + record.uuid + ), + }; + }; + + // Detect an existing socket-owned directive BEFORE the engine rewrites it: + // a go-patches owner means vendor is taking over an `apply` redirect; any + // prior socket path becomes the wiring record's `original`. + let prior = read_replace_entries(project_root) + .await + .into_iter() + .find(|e| e.module == module && e.socket_owned()); + let takeover = prior + .as_ref() + .is_some_and(|e| e.owner == Some(ReplaceOwner::GoPatches)); + let prior_path = prior.as_ref().and_then(|e| e.path.clone()); + + // The engine does the heavy lifting: fresh copy → hardened apply pipeline + // → `replace` upsert (which refuses a user-authored same-version pin). + let result = apply_go_redirect( + purl, + module, + version, + pristine_src, + project_root, + &base_rel, + &record.files, + sources, + Some(&record.uuid), + dry_run, + force, + ) + .await; + + if dry_run { + return VendorOutcome::Done { + result, + entry: None, + warnings: Vec::new(), + }; + } + if !result.success { + // The engine already rolled back a half-built copy, but its rollback + // removes only the module leaf — clear the whole uuid dir so no empty + // path husks (or a copy left by a failed `replace` upsert) linger + // under `.socket/vendor/golang/`. + let _ = remove_tree(&project_root.join(&base_rel)).await; + return VendorOutcome::Done { + result, + entry: None, + warnings: Vec::new(), + }; + } + // A patch with no files is a no-op success: the engine wrote no copy and + // no `replace`, so there is nothing to record or mark. + if record.files.is_empty() { + return VendorOutcome::Done { + result, + entry: None, + warnings: Vec::new(), + }; + } + + let mut warnings = Vec::new(); + + if takeover { + // The `replace` line was already atomically repointed by the upsert; + // the apply backend's copy is now unreachable — delete it (built from + // OUR validated coordinates, never from the go.mod string). NotFound + // is fine (the user may have cleaned it already). + let stale = project_root + .join(GO_PATCHES_DIR) + .join(format!("{module}@{version}")); + let _ = remove_tree(&stale).await; + warnings.push(VendorWarning::new( + "vendor_takeover", + format!( + "took over the `.socket/go-patches/` redirect for `{module}`; \ + `socket-patch apply` will restore it after `vendor --revert`" + ), + )); + } + + // ── marker + ledger entry ───────────────────────────────────────────── + let base_purl = strip_purl_qualifiers(purl).to_string(); + let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); + vulnerabilities.sort(); + let marker = VendorMarker { + schema_version: 1, + purl: base_purl.clone(), + patch_uuid: record.uuid.clone(), + ecosystem: "golang".to_string(), + vulnerabilities, + vendored_at: vendored_at.to_string(), + }; + if let Err(e) = write_marker(&project_root.join(&base_rel), &marker).await { + // The marker is belt-and-braces metadata (never a trust input); a + // failed write must not undo a fully-wired vendor — surface it. + warnings.push(VendorWarning::new( + "marker_write_failed", + format!("could not write the vendor marker: {e}"), + )); + } + + let entry = VendorEntry { + ecosystem: "golang".to_string(), + base_purl, + uuid: record.uuid.clone(), + artifact: VendorArtifact { + path: format!("{base_rel}/{module}@{version}"), + sha256: String::new(), // dir-shaped: integrity is per-file afterHashes + size: None, + platform_locked: None, + }, + wiring: vec![WiringRecord { + file: "go.mod".to_string(), + kind: "go_replace".to_string(), + // Rewritten whenever ANY socket-owned directive pre-existed (the + // go-patches takeover, or a re-vendor refreshing an older uuid). + action: if prior_path.is_some() { + WiringAction::Rewritten + } else { + WiringAction::Added + }, + key: Some(module.to_string()), + original: prior_path.map(serde_json::Value::from), + new: Some(serde_json::Value::from(replace_target_path( + &base_rel, module, version, + ))), + }], + lock: None, + took_over_go_patches: takeover, + flavor: None, + uv: None, + }; + + VendorOutcome::Done { + result, + entry: Some(entry), + warnings, + } +} + +/// Revert one vendored Go module: drop the vendor-owned `replace` directive +/// and remove the uuid dir. A taken-over go-patches redirect is **not** +/// restored (warned: re-run `socket-patch apply`). +pub async fn revert_go_vendor( + entry: &VendorEntry, + project_root: &Path, + dry_run: bool, +) -> RevertOutcome { + // SECURITY: the coordinates and uuid come from a committed, tamper-able + // state.json and key a directory we are about to delete — re-validate + // fail-closed before any disk access (mirrors the vendor-side guard). + let Some(base_rel) = vendor_uuid_dir_rel("golang", &entry.uuid) else { + return RevertOutcome::failed(format!( + "refusing to revert: `{}` is not a canonical patch uuid", + entry.uuid + )); + }; + let Some((module, version)) = parse_golang_purl(&entry.base_purl) else { + return RevertOutcome::failed(format!("not a golang purl: {}", entry.base_purl)); + }; + if !are_safe_redirect_coords(module, version) { + return RevertOutcome::failed(format!( + "refusing to revert unsafe golang coordinates `{module}`/`{version}`" + )); + } + + let mut out = RevertOutcome::ok(); + + // Owner-filtered: a go-patches or user-authored directive for the same + // module is never touched here. + if let Err(e) = + go_mod_edit::drop_replace_entry(project_root, module, ReplaceOwner::Vendor, dry_run).await + { + return RevertOutcome::failed(format!("failed to update go.mod: {e}")); + } + + if !dry_run { + let uuid_dir = project_root.join(&base_rel); + let _ = remove_tree(&uuid_dir).await; // ignore NotFound + // Best-effort: prune the now-empty `.socket/vendor/golang/` level so a + // fully-reverted project carries no vendor residue (`save_state` then + // prunes `.socket/vendor/` itself). `remove_dir` fails on non-empty. + if let Some(eco_dir) = uuid_dir.parent() { + let _ = tokio::fs::remove_dir(eco_dir).await; + } + } + + if entry.took_over_go_patches { + out.warnings.push(VendorWarning::new( + "takeover_not_restored", + format!( + "the `.socket/go-patches/` redirect for `{module}` that vendoring \ + took over was not restored; run `socket-patch apply` to restore it" + ), + )); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::{PatchFileInfo, VulnerabilityInfo}; + use crate::patch::apply::ApplyResult; + use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use std::collections::HashMap; + use std::path::PathBuf; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const PRISTINE: &[u8] = b"package bar\n\nfunc Hello() string { return \"hi\" }\n"; + const PATCHED: &[u8] = b"package bar\n\nfunc Hello() string { return \"patched\" }\n"; + const MODULE: &str = "github.com/foo/bar"; + const VERSION: &str = "v1.4.2"; + const PURL: &str = "pkg:golang/github.com/foo/bar@v1.4.2"; + + fn git_sha(bytes: &[u8]) -> String { + compute_git_sha256_from_bytes(bytes) + } + + fn copy_rel() -> String { + format!(".socket/vendor/golang/{UUID}/{MODULE}@{VERSION}") + } + + fn record_with(files: HashMap) -> PatchRecord { + let mut vulnerabilities = HashMap::new(); + vulnerabilities.insert( + "GHSA-xxxx-yyyy-zzzz".to_string(), + VulnerabilityInfo { + cves: vec!["CVE-2026-0001".into()], + summary: "s".into(), + severity: "high".into(), + description: "d".into(), + }, + ); + PatchRecord { + uuid: UUID.into(), + exported_at: "t".into(), + files, + vulnerabilities, + description: String::new(), + license: String::new(), + tier: String::new(), + } + } + + /// Build a pristine module-cache-style dir, a blobs dir carrying the + /// patched bytes, and a consumer project go.mod. Returns + /// (tmp, blobs, pristine, record). + async fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PatchRecord) { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + + let pristine = root.join("cache/github.com/foo/bar@v1.4.2"); + tokio::fs::create_dir_all(&pristine).await.unwrap(); + tokio::fs::write(pristine.join("bar.go"), PRISTINE).await.unwrap(); + tokio::fs::write( + pristine.join("go.mod"), + "module github.com/foo/bar\n\ngo 1.21\n", + ) + .await + .unwrap(); + + let after = git_sha(PATCHED); + let blobs = root.join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + tokio::fs::write(blobs.join(&after), PATCHED).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "package/bar.go".to_string(), + PatchFileInfo { + before_hash: git_sha(PRISTINE), + after_hash: after, + }, + ); + + tokio::fs::write( + root.join("go.mod"), + "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n", + ) + .await + .unwrap(); + + (dir, blobs, pristine, record_with(files)) + } + + async fn run_vendor( + purl: &str, + root: &Path, + blobs: &Path, + pristine: &Path, + record: &PatchRecord, + dry_run: bool, + ) -> VendorOutcome { + let sources = PatchSources::blobs_only(blobs); + vendor_go_module( + purl, + pristine, + root, + record, + &sources, + "2026-06-09T00:00:00Z", + dry_run, + false, + ) + .await + } + + fn expect_done(outcome: VendorOutcome) -> (ApplyResult, Option, Vec) { + match outcome { + VendorOutcome::Done { + result, + entry, + warnings, + } => (result, entry, warnings), + VendorOutcome::Refused { code, detail } => { + panic!("expected Done, got Refused({code}): {detail}") + } + } + } + + fn expect_refused(outcome: VendorOutcome, want_code: &str) -> String { + match outcome { + VendorOutcome::Refused { code, detail } => { + assert_eq!(code, want_code, "refusal code: {detail}"); + detail + } + VendorOutcome::Done { result, .. } => { + panic!("expected Refused({want_code}), got Done (success={})", result.success) + } + } + } + + #[tokio::test] + async fn test_happy_path_wires_copy_replace_and_marker() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + // A qualified PURL must collapse to the base in the ledger/marker. + let qualified = format!("{PURL}?type=module"); + let (result, entry, warnings) = + expect_done(run_vendor(&qualified, root, &blobs, &pristine, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + // Copy holds the patched bytes inside the uuid dir. + let copy = root.join(copy_rel()); + assert_eq!(tokio::fs::read(copy.join("bar.go")).await.unwrap(), PATCHED); + assert!(copy.join("go.mod").exists()); + // The module cache pristine is untouched. + assert_eq!(tokio::fs::read(pristine.join("bar.go")).await.unwrap(), PRISTINE); + + // The replace directive is vendor-owned and points at the uuid path. + let entries = read_replace_entries(root).await; + let e = entries.iter().find(|e| e.module == MODULE).unwrap(); + assert_eq!(e.owner, Some(ReplaceOwner::Vendor)); + assert_eq!(e.path.as_deref(), Some(format!("./{}", copy_rel()).as_str())); + assert_eq!(e.version.as_deref(), Some(VERSION)); + + // Marker sits in the uuid dir, carrying the vuln + uuid + base purl. + let marker = tokio::fs::read_to_string( + root.join(format!(".socket/vendor/golang/{UUID}/{VENDOR_MARKER_FILE}")), + ) + .await + .unwrap(); + assert!(marker.contains(UUID)); + assert!(marker.contains("GHSA-xxxx-yyyy-zzzz")); + assert!(marker.contains(&format!("\"purl\": \"{PURL}\"")), "{marker}"); + + // Ledger entry shape. + let entry = entry.expect("entry on success"); + assert_eq!(entry.ecosystem, "golang"); + assert_eq!(entry.base_purl, PURL, "qualifiers stripped"); + assert_eq!(entry.uuid, UUID); + assert_eq!(entry.artifact.path, copy_rel()); + assert_eq!(entry.artifact.sha256, "", "dir-shaped artifact"); + assert!(!entry.took_over_go_patches); + assert_eq!(entry.lock, None); + assert_eq!(entry.wiring.len(), 1); + let w = &entry.wiring[0]; + assert_eq!((w.file.as_str(), w.kind.as_str()), ("go.mod", "go_replace")); + assert_eq!(w.action, WiringAction::Added); + assert_eq!(w.key.as_deref(), Some(MODULE)); + assert_eq!(w.original, None); + assert_eq!(w.new, Some(serde_json::Value::from(format!("./{}", copy_rel())))); + } + + #[tokio::test] + async fn test_takeover_repoints_replace_and_removes_stale_redirect() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + // Pre-seed an `apply` redirect through the engine itself. + let sources = PatchSources::blobs_only(&blobs); + let pre = apply_go_redirect( + PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &record.files, &sources, + Some(UUID), false, false, + ) + .await; + assert!(pre.success, "fixture redirect failed: {:?}", pre.error); + let stale = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2"); + assert!(stale.exists()); + + let (result, entry, warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(result.success, "{:?}", result.error); + assert!( + warnings.iter().any(|w| w.code == "vendor_takeover"), + "takeover surfaced: {warnings:?}" + ); + assert!(!stale.exists(), "stale go-patches copy removed"); + + // Exactly ONE directive for the module, now vendor-owned. + let entries = read_replace_entries(root).await; + let mine: Vec<_> = entries.iter().filter(|e| e.module == MODULE).collect(); + assert_eq!(mine.len(), 1, "single directive after takeover: {entries:?}"); + assert_eq!(mine[0].owner, Some(ReplaceOwner::Vendor)); + + let entry = entry.unwrap(); + assert!(entry.took_over_go_patches); + let w = &entry.wiring[0]; + assert_eq!(w.action, WiringAction::Rewritten); + assert_eq!( + w.original, + Some(serde_json::Value::from( + "./.socket/go-patches/github.com/foo/bar@v1.4.2" + )), + "the old replace target is recorded verbatim" + ); + } + + #[tokio::test] + async fn test_idempotent_rerun_is_byte_stable() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + + let copy = root.join(copy_rel()).join("bar.go"); + let gomod = root.join("go.mod"); + let copy1 = tokio::fs::read(©).await.unwrap(); + let mod1 = tokio::fs::read(&gomod).await.unwrap(); + + let (result, entry, warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(result.success); + assert!(result.files_patched.is_empty(), "in-sync re-run patches nothing"); + assert!(entry.is_some(), "re-run still reports the ledger entry"); + assert!(!entry.unwrap().took_over_go_patches); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(tokio::fs::read(©).await.unwrap(), copy1, "copy unchanged"); + assert_eq!(tokio::fs::read(&gomod).await.unwrap(), mod1, "go.mod byte-stable"); + } + + #[tokio::test] + async fn test_dry_run_writes_nothing() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let gomod_before = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); + + let (result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, true).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none(), "dry-run emits no entry"); + assert!(!root.join(format!(".socket/vendor/golang/{UUID}")).exists()); + assert_eq!( + tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(), + gomod_before, + "go.mod untouched" + ); + } + + #[tokio::test] + async fn test_user_replace_conflict_fails_without_litter() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + // A user-authored replace pins the same module+version: the engine's + // go.mod editor refuses, surfacing as a failed result (not a refusal). + tokio::fs::write( + root.join("go.mod"), + "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n\nreplace github.com/foo/bar v1.4.2 => ../fork\n", + ) + .await + .unwrap(); + let gomod_before = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); + + let (result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(!result.success); + assert!(entry.is_none()); + // go.mod untouched and the failed copy fully unwound (no uuid husks). + assert_eq!( + tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(), + gomod_before + ); + assert!(!root.join(format!(".socket/vendor/golang/{UUID}")).exists()); + } + + #[tokio::test] + async fn test_revert_round_trip() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let (_result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + let entry = entry.unwrap(); + + let out = revert_go_vendor(&entry, root, false).await; + assert!(out.success, "{:?}", out.error); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + + // Directive gone, the user's require survives. + assert!(read_replace_entries(root).await.is_empty()); + assert!(tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap() + .contains("require github.com/foo/bar v1.4.2")); + // The uuid dir is gone, and the empty eco level pruned with it. + assert!(!root.join(format!(".socket/vendor/golang/{UUID}")).exists()); + assert!(!root.join(".socket/vendor/golang").exists()); + } + + #[tokio::test] + async fn test_revert_does_not_restore_go_patches() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + // Vendor takes over an apply redirect, then is reverted. + let sources = PatchSources::blobs_only(&blobs); + apply_go_redirect( + PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &record.files, &sources, + Some(UUID), false, false, + ) + .await; + let (_result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + let entry = entry.unwrap(); + assert!(entry.took_over_go_patches); + + let out = revert_go_vendor(&entry, root, false).await; + assert!(out.success, "{:?}", out.error); + assert!( + out.warnings.iter().any(|w| w.code == "takeover_not_restored"), + "{:?}", + out.warnings + ); + // Neither the vendor directive nor the go-patches one remains: the + // module is back on the pristine cache until `apply` is re-run. + assert!(read_replace_entries(root).await.is_empty()); + assert!(!root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists()); + } + + // ── filesystem-safety: coordinate traversal ────────────────────────── + + /// SECURITY regression: tampered manifest coordinates must be refused + /// before any disk access — no copy outside `.socket/vendor/golang/`, no + /// go.mod edit. + #[tokio::test] + async fn test_refuses_traversal_coordinates() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let gomod_before = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); + let escaped = root.parent().unwrap().join("escape@v1.0.0"); + let _ = remove_tree(&escaped).await; + + expect_refused( + run_vendor( + "pkg:golang/../../../escape@v1.0.0", + root, + &blobs, + &pristine, + &record, + false, + ) + .await, + "unsafe_coordinates", + ); + expect_refused( + run_vendor( + "pkg:golang/github.com/foo/bar@../../../evil", + root, + &blobs, + &pristine, + &record, + false, + ) + .await, + "unsafe_coordinates", + ); + expect_refused( + run_vendor("pkg:cargo/not-golang@1.0.0", root, &blobs, &pristine, &record, false) + .await, + "unsafe_coordinates", + ); + assert!(!escaped.exists(), "no copy outside the project"); + assert_eq!( + tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(), + gomod_before, + "go.mod untouched" + ); + let _ = remove_tree(&escaped).await; + } + + /// SECURITY regression: a poisoned record uuid (`..`, traversal, + /// uppercase) must be refused — it keys the dir vendor creates and + /// `--revert` deletes. + #[tokio::test] + async fn test_refuses_poisoned_uuid() { + let (dir, blobs, pristine, mut record) = fixture().await; + let root = dir.path(); + for bad in ["..", "../../../etc", "9F6B2C4E-1D3A-4F6B-8C2D-7E5A9B1C3D5F"] { + record.uuid = bad.to_string(); + let detail = expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "unsafe_coordinates", + ); + assert!(detail.contains("uuid"), "{detail}"); + } + assert!(read_replace_entries(root).await.is_empty(), "go.mod untouched"); + } + + /// SECURITY regression: revert re-validates the (tamper-able) ledger entry + /// fail-closed rather than `remove_tree`-ing a poisoned path. + #[tokio::test] + async fn test_revert_refuses_traversal_entry() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let (_result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + let good = entry.unwrap(); + + let mut bad_uuid = good.clone(); + bad_uuid.uuid = "../../../precious".to_string(); + assert!(!revert_go_vendor(&bad_uuid, root, false).await.success); + + let mut bad_purl = good.clone(); + bad_purl.base_purl = "pkg:golang/../../../escape@v1.0.0".to_string(); + assert!(!revert_go_vendor(&bad_purl, root, false).await.success); + + // The refusals deleted nothing: the vendored state is fully intact. + assert!(root.join(copy_rel()).exists()); + assert!(read_replace_entries(root) + .await + .iter() + .any(|e| e.module == MODULE && e.owner == Some(ReplaceOwner::Vendor))); + } + + #[tokio::test] + async fn test_empty_files_is_noop() { + let (dir, blobs, pristine, mut record) = fixture().await; + let root = dir.path(); + record.files = HashMap::new(); + let (result, entry, warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(result.success); + assert!(entry.is_none(), "nothing vendored, nothing recorded"); + assert!(warnings.is_empty()); + assert!(read_replace_entries(root).await.is_empty(), "no replace written"); + assert!(!root.join(format!(".socket/vendor/golang/{UUID}")).exists()); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/npm_lock.rs b/crates/socket-patch-core/src/patch/vendor/npm_lock.rs index ed2edb3..0198fee 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_lock.rs @@ -1 +1,1644 @@ -//! (stub — implementation lands with its backend phase) +//! npm vendor backend: lock surgery + orchestration. +//! +//! Vendoring an npm package = pack the patched tree into a deterministic +//! tarball under `.socket/vendor/npm//` ([`super::npm_pack`]) and +//! rewrite every matching lockfile entry's `resolved` to a relative `file:` +//! spec + `integrity` to the tarball's recomputed sha512. That lock-only +//! rewrite passes `npm ci` (spike-proven; see `spikes/PHASE0-FINDINGS.txt`): +//! a relative `file:` resolves against the project dir and npm never +//! rewrites/normalizes the entry. +//! +//! The `integrity` recompute is load-bearing, not cosmetic: npm trusts a +//! cache entry that matches `integrity`, so leaving the registry's sha512 in +//! place would make a warm npm cache silently install the UNPATCHED registry +//! bytes — no error, no patch. Every rewrite therefore carries the packed +//! tarball's own hash, never an inherited one. + +use std::collections::HashMap; +use std::path::Path; + +use serde_json::Value; + +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{ + apply_package_patch, normalize_file_path, ApplyResult, PatchSources, VerifyResult, + VerifyStatus, +}; +use crate::patch::copy_tree::{fresh_copy, remove_tree}; +use crate::patch::path_safety; +use crate::utils::fs::atomic_write_bytes; +use crate::utils::purl::strip_purl_qualifiers; + +use super::npm_pack::pack_deterministic; +use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; +use super::state::{ + write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, +}; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +/// `npm-shrinkwrap.json` wins over `package-lock.json` when both exist — +/// npm itself ignores the package-lock in that case, so editing it would be +/// a silent no-op. +const SHRINKWRAP: &str = "npm-shrinkwrap.json"; +const PACKAGE_LOCK: &str = "package-lock.json"; + +const NODE_MODULES_SEG: &str = "node_modules/"; + +/// Wiring kinds (the `WiringRecord.kind` discriminators this backend owns). +const KIND_LOCK_ENTRY: &str = "npm_lock_entry"; +const KIND_LOCK_LEGACY_ENTRY: &str = "npm_lock_legacy_entry"; + +/// Lock-entry fields that mirror the package's own `package.json`. When the +/// patch rewrites that manifest, these go stale in the lock and `npm ci` +/// would resolve the OLD dependency graph — so they are recomputed from the +/// patched manifest (step 7 of [`vendor_npm`]). +const DEP_MANIFEST_FIELDS: [&str; 4] = [ + "dependencies", + "peerDependencies", + "optionalDependencies", + "bin", +]; + +/// Vendor one installed npm package. +/// +/// * `purl` — `pkg:npm/[@scope/]name@version` (qualifiers tolerated). +/// * `installed_dir` — the crawler's `node_modules/` dir; read-only +/// input (patching happens on a staged copy, never in place). +/// * `vendored_at` — RFC3339 timestamp for the informational marker. +/// +/// Ordering is refuse-early, wire-last: every refusal fires before any write +/// inside the project, and the lockfile edit is the final mutation so a +/// failure can never leave a lock pointing at an artifact that was not +/// produced. On success `entry` carries the ledger record to persist — +/// `None` for dry runs and for the in-sync re-run (the existing ledger entry +/// stays authoritative; we never re-record our own edit as an "original"). +#[allow(clippy::too_many_arguments)] +pub async fn vendor_npm( + purl: &str, + installed_dir: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, +) -> VendorOutcome { + let mut warnings: Vec = Vec::new(); + + // ── 1. Coordinates ────────────────────────────────────────────────── + // SECURITY: name/version/uuid come from a committed, tamper-able + // manifest and key the artifact path under `.socket/vendor/npm/` plus + // the `file:` string written into the lock. A `..` segment, separator, + // or non-canonical uuid would escape the vendor dir (arbitrary write on + // vendor, arbitrary delete on revert) — reject fail-closed before any + // disk access. + let Some((name, version)) = parse_npm_purl(purl) else { + return refused( + "unsafe_coordinates", + format!("cannot parse an npm name@version out of `{purl}`"), + ); + }; + if !is_safe_npm_name(name) || !path_safety::is_safe_single_segment(version) { + return refused( + "unsafe_coordinates", + format!( + "refusing to vendor `{name}@{version}`: a `..` segment, absolute path, or \ + separator would escape .socket/vendor/npm/" + ), + ); + } + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &record.uuid) else { + return refused( + "unsafe_coordinates", + format!( + "refusing to vendor with non-canonical patch uuid `{}`", + record.uuid + ), + ); + }; + let base_purl = strip_purl_qualifiers(purl).to_string(); + + // ── 2. Lockfile selection ─────────────────────────────────────────── + let (lock_name, lock_bytes) = match select_lockfile(project_root).await { + Ok(Some(found)) => found, + Ok(None) => { + return refused( + "vendor_lockfile_missing", + format!( + "no {PACKAGE_LOCK} or {SHRINKWRAP} at {} — vendoring rewires the lockfile, \ + so one must exist (run `npm install` first)", + project_root.display() + ), + ); + } + Err(e) => { + return refused( + "vendor_lockfile_missing", + format!("cannot read the lockfile: {e}"), + ); + } + }; + let mut lock: Value = match serde_json::from_slice(&lock_bytes) { + Ok(v) => v, + Err(e) => { + return refused( + "vendor_lockfile_version_unsupported", + format!("{lock_name} is not parseable JSON: {e}"), + ); + } + }; + let lock_version = lock.get("lockfileVersion").and_then(Value::as_u64); + if !matches!(lock_version, Some(2) | Some(3)) + || !lock.get("packages").is_some_and(Value::is_object) + { + return refused( + "vendor_lockfile_version_unsupported", + format!( + "{lock_name} has lockfileVersion {:?}; only v2/v3 locks (with a `packages` \ + object) are supported — run `npm install` with npm >= 7 to upgrade it", + lock_version + ), + ); + } + + // ── 3. Find the rewritable lock instances ─────────────────────────── + let matches = match scan_lock_matches(&lock, name, version, &mut warnings) { + LockScan::Matches(m) => m, + LockScan::WorkspaceMember { key } => { + // A matching key outside node_modules/ is the user's own + // workspace member — its source of truth is the working tree, + // not a tarball; vendoring it would shadow their code. + return refused( + "vendor_workspace_member", + format!( + "`{key}` is a workspace member of this project; patch the source directly \ + instead of vendoring it" + ), + ); + } + }; + if matches.is_empty() { + return refused( + "vendor_lock_entry_not_found", + format!( + "{lock_name} has no rewritable entry for {name}@{version} — make sure the \ + package is installed and locked (`npm install`) before vendoring" + ), + ); + } + + // ── 4. Stage + patch a private copy ───────────────────────────────── + // The stage lives in a tempdir OUTSIDE the project: nothing inside the + // project is written until the patched tarball verifies. + let stage_tmp = match tempfile::tempdir() { + Ok(t) => t, + Err(e) => return done_failure(purl, format!("cannot create staging tempdir: {e}")), + }; + let stage = stage_tmp.path().join("stage"); + if let Err(e) = fresh_copy(installed_dir, &stage, None).await { + return done_failure(purl, format!("cannot stage a copy of the installed package: {e}")); + } + // The tarball must carry ONLY the package's own files: a nested + // node_modules (hoisting leftovers, file:-dep installs) would balloon + // the artifact and shadow the lock's own resolution. + if let Err(e) = remove_tree(&stage.join("node_modules")).await { + return done_failure(purl, format!("cannot prune staged node_modules: {e}")); + } + // Bundled dependencies ship INSIDE the package tarball; since we just + // dropped nested node_modules, repacking would produce a tarball npm + // cannot satisfy those deps from. Refuse before patching. + if let Ok(bytes) = tokio::fs::read(stage.join("package.json")).await { + if let Ok(pkg) = serde_json::from_slice::(&bytes) { + if declares_bundled_deps(&pkg) { + return refused( + "vendor_bundled_deps_unsupported", + format!( + "{name}@{version} declares bundleDependencies; vendoring would repack \ + the tarball without its bundled node_modules and break installs" + ), + ); + } + } + } + + // Delegate to the hardened apply pipeline, pointed at the stage (which + // plays the role of the installed package dir — manifest npm keys carry + // the `package/` prefix and `apply` strips it via `normalize_file_path`, + // exactly as it does for an in-place npm apply). + let result = apply_package_patch( + purl, + &stage, + &record.files, + sources, + Some(&record.uuid), + dry_run, + force, + ) + .await; + if !result.success { + // No lock writes — wiring is last, so a failed patch leaves the + // project byte-untouched. + return VendorOutcome::Done { result, entry: None, warnings }; + } + + // ── 5. Dry run stops after the verify ─────────────────────────────── + if dry_run { + return VendorOutcome::Done { result, entry: None, warnings }; + } + + // ── 6. Pack the deterministic tarball ─────────────────────────────── + let rel_tgz = format!("{uuid_dir_rel}/{}", tgz_rel_leaf(name, version)); + let dest = project_root.join(&rel_tgz); + if let Some(parent) = dest.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + return done_failure(purl, format!("cannot create {}: {e}", parent.display())); + } + } + let packed = match pack_deterministic(&stage, &dest).await { + Ok(p) => p, + Err(e) => return done_failure(purl, format!("cannot pack the vendored tarball: {e}")), + }; + // Forward slashes by construction (uuid_dir_rel + leaf are built with + // `/`), relative to the project dir — the spelling npm resolves + // `file:` specs against. + let resolved = format!("file:{rel_tgz}"); + + // ── 7. Patched package.json ⇒ the lock's dependency mirror is stale ─ + let staged_pkg_json = if record + .files + .keys() + .any(|k| normalize_file_path(k) == "package.json") + { + match read_staged_package_json(&stage).await { + Ok(pkg) => Some(pkg), + Err(e) => return done_failure(purl, e), + } + } else { + None + }; + + // ── 8. Lock rewrite (in-place Value mutation: untouched keys stay + // byte-stable thanks to serde_json's preserve_order) ──────────── + let mut wiring: Vec = Vec::new(); + let mut changed = false; + let mut recomputed_deps = false; + { + let Some(packages) = lock.get_mut("packages").and_then(Value::as_object_mut) else { + return done_failure(purl, "lock `packages` object vanished mid-rewrite".to_string()); + }; + for m in &matches { + let Some(live) = packages.get_mut(&m.key).and_then(Value::as_object_mut) else { + continue; + }; + // Idempotency: an instance already carrying our exact spec needs + // no edit and no wiring record. + if entry_in_sync(live, &resolved, &packed.integrity) { + continue; + } + // Never record one of our own (stale) edits as the "original" — + // revert must restore the pre-vendor registry fragment, not a + // dangling `.socket/vendor/` pointer from an earlier uuid. + let was_vendored = entry_points_into_vendor(live); + live.insert("resolved".to_string(), Value::String(resolved.clone())); + live.insert("integrity".to_string(), Value::String(packed.integrity.clone())); + if let Some(pkg) = &staged_pkg_json { + recompute_dep_fields(live, pkg); + recomputed_deps = true; + } + wiring.push(WiringRecord { + file: lock_name.clone(), + kind: KIND_LOCK_ENTRY.to_string(), + action: WiringAction::Rewritten, + key: Some(m.key.clone()), + original: if was_vendored { None } else { Some(m.original.clone()) }, + new: Some(Value::Object(live.clone())), + }); + changed = true; + } + } + // lockfileVersion 2 keeps a legacy `dependencies` mirror (read by npm 6); + // leaving the registry resolved/integrity there would let an old client + // silently install unpatched bytes. + if lock_version == Some(2) { + if let Some(deps) = lock.get_mut("dependencies").and_then(Value::as_object_mut) { + rewrite_legacy_tree( + deps, + "/dependencies", + name, + version, + &resolved, + &packed.integrity, + &lock_name, + &mut wiring, + &mut changed, + ); + } + } + if recomputed_deps { + warnings.push(VendorWarning::new( + "vendor_dep_manifest_rewritten", + format!( + "the patch rewrites {name}@{version}'s package.json; its lock entries' \ + dependency/bin fields were recomputed from the patched manifest" + ), + )); + } + + if !changed { + // Every instance already points at this uuid with the packed + // integrity: the project is in sync. Touch nothing (the tarball + // rewrite above was byte-identical by determinism) and synthesize an + // AlreadyPatched-style success, mirroring the go_redirect hot path. + let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + return VendorOutcome::Done { + result: synthesized_result(purl, &dest, verified, true, None), + entry: None, + warnings, + }; + } + + let indent = detect_indent(&String::from_utf8_lossy(&lock_bytes)); + let out = match serialize_lock(&lock, &indent) { + Ok(out) => out, + Err(e) => return done_failure(purl, format!("cannot serialize {lock_name}: {e}")), + }; + if let Err(e) = atomic_write_bytes(&project_root.join(&lock_name), &out).await { + return done_failure(purl, format!("cannot write {lock_name}: {e}")); + } + + // ── 9. Marker + ledger entry ───────────────────────────────────────── + // The marker is informational belt-and-braces (never a trust input), so + // a write failure downgrades to a warning rather than failing a vendor + // whose lock is already correctly wired. + let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); + vulnerabilities.sort(); + let marker = VendorMarker { + schema_version: 1, + purl: base_purl.clone(), + patch_uuid: record.uuid.clone(), + ecosystem: "npm".to_string(), + vulnerabilities, + vendored_at: vendored_at.to_string(), + }; + if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { + warnings.push(VendorWarning::new( + "vendor_marker_write_failed", + format!("could not write the informational vendor marker: {e}"), + )); + } + + let entry = VendorEntry { + ecosystem: "npm".to_string(), + base_purl, + uuid: record.uuid.clone(), + artifact: VendorArtifact { + path: rel_tgz, + sha256: packed.sha256_hex, + size: Some(packed.size), + platform_locked: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + flavor: None, + uv: None, + }; + VendorOutcome::Done { result, entry: Some(entry), warnings } +} + +/// Undo one vendored npm package: restore the recorded lock fragments and +/// remove the artifact dir. +pub async fn revert_npm(entry: &VendorEntry, project_root: &Path, dry_run: bool) -> RevertOutcome { + // SECURITY: `entry.uuid` comes from the committed, tamper-able + // state.json and names the directory tree we are about to DELETE. + // Validate through the same fail-closed grammar vendor used before any + // disk access — never delete by an unvalidated path. + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &entry.uuid) else { + return RevertOutcome::failed(format!( + "refusing revert: `{}` is not a canonical patch uuid (tampered state.json?)", + entry.uuid + )); + }; + if dry_run { + return RevertOutcome::ok(); + } + + let mut outcome = RevertOutcome::ok(); + + // The lockfile(s) the wiring named (normally exactly one). SECURITY: + // restrict the write targets to the two known lockfile names — a + // poisoned state.json must not be able to point this rewrite at an + // arbitrary project file. + let mut lock_files: Vec<&str> = Vec::new(); + for rec in &entry.wiring { + if rec.file != PACKAGE_LOCK && rec.file != SHRINKWRAP { + outcome.warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("ignoring wiring record for unexpected file `{}`", rec.file), + )); + continue; + } + if !lock_files.contains(&rec.file.as_str()) { + lock_files.push(&rec.file); + } + } + + for lock_name in lock_files { + let lock_path = project_root.join(lock_name); + let lock_bytes = match tokio::fs::read(&lock_path).await { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // The lock is gone (user regenerated the project?); the + // artifact removal below still proceeds. + outcome.warnings.push(VendorWarning::new( + "vendor_lockfile_missing", + format!("{lock_name} is missing; lock fragments cannot be restored"), + )); + continue; + } + Err(e) => return RevertOutcome::failed(format!("cannot read {lock_name}: {e}")), + }; + let mut lock: Value = match serde_json::from_slice(&lock_bytes) { + Ok(v) => v, + // Fail-closed: editing a lock we cannot parse risks destroying + // it; the user must repair it before revert can restore. + Err(e) => { + return RevertOutcome::failed(format!( + "{lock_name} is not parseable JSON ({e}); fix it and re-run revert" + )) + } + }; + + let mut changed = false; + // Reverse application order, like every backend's revert. + for rec in entry.wiring.iter().rev().filter(|r| r.file == lock_name) { + revert_one_record(&mut lock, rec, &entry.uuid, &mut changed, &mut outcome.warnings); + } + + if changed { + let indent = detect_indent(&String::from_utf8_lossy(&lock_bytes)); + let out = match serialize_lock(&lock, &indent) { + Ok(out) => out, + Err(e) => { + return RevertOutcome::failed(format!("cannot serialize {lock_name}: {e}")) + } + }; + if let Err(e) = atomic_write_bytes(&lock_path, &out).await { + return RevertOutcome::failed(format!("cannot write {lock_name}: {e}")); + } + } + } + + // Remove the whole validated uuid dir (tgz + marker + any @scope level) + // in one tree delete — pruning by leaf would leave empty dirs behind. + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + } + + outcome +} + +// ───────────────────────────── lock matching ───────────────────────────── + +/// One rewritable `packages` instance found by the scan. +struct LockMatch { + /// The verbatim `packages` key (`node_modules/a/node_modules/b`). + key: String, + /// Verbatim entry snapshot taken BEFORE any mutation — the revert + /// `original`. + original: Value, +} + +/// What the `packages` scan found. +enum LockScan { + Matches(Vec), + /// A matching key outside `node_modules/` — the caller refuses. + WorkspaceMember { key: String }, +} + +/// Scan `packages` for instances of `name@version`, pushing skip warnings +/// for the link / inBundle instances that cannot be rewritten. +fn scan_lock_matches( + lock: &Value, + name: &str, + version: &str, + warnings: &mut Vec, +) -> LockScan { + let mut matches = Vec::new(); + let Some(packages) = lock.get("packages").and_then(Value::as_object) else { + return LockScan::Matches(matches); // validated earlier; defensive + }; + for (key, entry) in packages { + // The root "" entry is the project itself, never a dependency. + if key.is_empty() { + continue; + } + let Some(obj) = entry.as_object() else { continue }; + if entry_name(key, obj) != name { + continue; + } + if obj.get("version").and_then(Value::as_str) != Some(version) { + continue; + } + if !key.contains(NODE_MODULES_SEG) { + return LockScan::WorkspaceMember { key: key.clone() }; + } + if obj.get("link").and_then(Value::as_bool) == Some(true) { + warnings.push(VendorWarning::new( + "vendor_link_entry_skipped", + format!("lock entry `{key}` is a link (npm workspaces/file: dir); skipped"), + )); + continue; + } + if obj.get("inBundle").and_then(Value::as_bool) == Some(true) { + // LOUD: this copy ships inside its PARENT's tarball, which we do + // not repack — it will still be the unpatched bytes after vendor. + warnings.push(VendorWarning::new( + "vendor_bundled_instance_skipped", + format!( + "lock entry `{key}` is bundled inside its parent's tarball and CANNOT be \ + rewritten — that copy stays UNPATCHED; vendor or update the bundling \ + parent to cover it" + ), + )); + continue; + } + matches.push(LockMatch { + key: key.clone(), + original: entry.clone(), + }); + } + LockScan::Matches(matches) +} + +/// The package name a lock entry stands for: the explicit `name` field when +/// present (npm writes it for aliases — `npm i alias@npm:real`), else the +/// path after the LAST `node_modules/` (handles nesting AND scopes), else +/// the key's basename (workspace-member keys, for classification only). +fn entry_name<'a>(key: &'a str, obj: &'a serde_json::Map) -> &'a str { + if let Some(n) = obj.get("name").and_then(Value::as_str) { + return n; + } + if let Some(idx) = key.rfind(NODE_MODULES_SEG) { + return &key[idx + NODE_MODULES_SEG.len()..]; + } + key.rsplit('/').next().unwrap_or(key) +} + +fn entry_in_sync(live: &serde_json::Map, resolved: &str, integrity: &str) -> bool { + live.get("resolved").and_then(Value::as_str) == Some(resolved) + && live.get("integrity").and_then(Value::as_str) == Some(integrity) +} + +/// Does this entry's `resolved` already point into `.socket/vendor/npm/` +/// (ours — current or stale uuid)? +fn entry_points_into_vendor(live: &serde_json::Map) -> bool { + live.get("resolved") + .and_then(Value::as_str) + .and_then(parse_vendor_path) + .is_some_and(|p| p.eco == "npm") +} + +/// Replace the lock entry's dependency-manifest mirror fields with the +/// patched package.json's (absent in the manifest ⇒ removed from the entry, +/// matching what npm would regenerate). +fn recompute_dep_fields(live: &mut serde_json::Map, staged_pkg: &Value) { + for field in DEP_MANIFEST_FIELDS { + match staged_pkg.get(field) { + Some(v) => { + live.insert(field.to_string(), v.clone()); + } + None => { + // shift_remove keeps the remaining keys' order stable + // (preserve_order Maps swap by default). + live.shift_remove(field); + } + } + } +} + +/// Walk the v2 legacy `dependencies` tree and rewrite every node matching +/// `name`+`version`. Nodes are addressed for revert by RFC 6901 JSON +/// Pointer (names may contain `/` — scoped packages — so a plain +/// slash-joined key would be ambiguous; `Value::pointer_mut` handles the +/// `~1` escaping natively). +#[allow(clippy::too_many_arguments)] +fn rewrite_legacy_tree( + deps: &mut serde_json::Map, + pointer_base: &str, + name: &str, + version: &str, + resolved: &str, + integrity: &str, + lock_name: &str, + wiring: &mut Vec, + changed: &mut bool, +) { + for (dep_name, node) in deps.iter_mut() { + let Some(obj) = node.as_object_mut() else { continue }; + let pointer = format!("{pointer_base}/{}", escape_json_pointer_token(dep_name)); + if dep_name == name + && obj.get("version").and_then(Value::as_str) == Some(version) + && !entry_in_sync(obj, resolved, integrity) + { + let was_vendored = entry_points_into_vendor(obj); + let original = Value::Object(obj.clone()); + obj.insert("resolved".to_string(), Value::String(resolved.to_string())); + obj.insert("integrity".to_string(), Value::String(integrity.to_string())); + wiring.push(WiringRecord { + file: lock_name.to_string(), + kind: KIND_LOCK_LEGACY_ENTRY.to_string(), + action: WiringAction::Rewritten, + key: Some(pointer.clone()), + original: if was_vendored { None } else { Some(original) }, + new: Some(Value::Object(obj.clone())), + }); + *changed = true; + } + if let Some(sub) = obj.get_mut("dependencies").and_then(Value::as_object_mut) { + rewrite_legacy_tree( + sub, + &format!("{pointer}/dependencies"), + name, + version, + resolved, + integrity, + lock_name, + wiring, + changed, + ); + } + } +} + +/// RFC 6901 token escaping (`~` → `~0`, `/` → `~1`). +fn escape_json_pointer_token(token: &str) -> String { + token.replace('~', "~0").replace('/', "~1") +} + +/// Apply one wiring record in reverse: restore `original` iff the live +/// fragment is still ours (drift = third party re-resolved it; leave theirs +/// alone, with a warning). +fn revert_one_record( + lock: &mut Value, + rec: &WiringRecord, + entry_uuid: &str, + changed: &mut bool, + warnings: &mut Vec, +) { + let Some(key) = rec.key.as_deref() else { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("wiring record in {} has no key; left alone", rec.file), + )); + return; + }; + let live = match rec.kind.as_str() { + KIND_LOCK_ENTRY => lock.get_mut("packages").and_then(|p| p.get_mut(key)), + KIND_LOCK_LEGACY_ENTRY => lock.pointer_mut(key), + other => { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("unknown wiring kind `{other}` for `{key}`; left alone"), + )); + return; + } + }; + let Some(live) = live else { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("lock entry `{key}` no longer exists; nothing to restore"), + )); + return; + }; + + // Ours iff resolved is exactly what we wrote, or still points into OUR + // uuid dir (a re-serialized but unmoved entry). + let live_resolved = live.get("resolved").and_then(Value::as_str); + let new_resolved = rec + .new + .as_ref() + .and_then(|n| n.get("resolved")) + .and_then(Value::as_str); + let ours = match live_resolved { + Some(r) => { + Some(r) == new_resolved + || parse_vendor_path(r).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid) + } + None => false, + }; + if !ours { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "lock entry `{key}` was re-resolved since vendoring (resolved = {:?}); \ + left alone", + live_resolved + ), + )); + return; + } + match &rec.original { + Some(original) => { + *live = original.clone(); + *changed = true; + } + None => { + // The record rewrote one of our own earlier edits, so there is + // no pre-vendor fragment to restore (by design — see vendor_npm + // step 8). Surface it instead of guessing a registry URL. + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "lock entry `{key}` has no recorded pre-vendor original; left as-is \ + (re-run `npm install` to re-resolve it from the registry)" + ), + )); + } + } +} + +// ───────────────────────────── small helpers ───────────────────────────── + +/// `pkg:npm/[@scope/]name@version` → `(name, version)`; scoped names keep +/// the `@scope/` prefix. The LAST `@` separates the version (a leading +/// scope-`@` is at index 0 and never the last `@` of a versioned purl). +fn parse_npm_purl(purl: &str) -> Option<(&str, &str)> { + let base = strip_purl_qualifiers(purl); + let rest = base.strip_prefix("pkg:npm/")?; + let at = rest.rfind('@').filter(|&i| i > 0)?; + let (name, version) = (&rest[..at], &rest[at + 1..]); + if name.is_empty() || version.is_empty() { + return None; + } + Some((name, version)) +} + +/// npm-name shape on top of the generic traversal guard: at most one `/`, +/// and only with an `@scope` first segment (so a smuggled `a/b/c` can't +/// create surprise directory levels under the uuid dir). +fn is_safe_npm_name(name: &str) -> bool { + if !path_safety::is_safe_multi_segment(name) { + return false; + } + match name.split_once('/') { + None => !name.starts_with('@'), + Some((scope, bare)) => scope.starts_with('@') && !bare.contains('/'), + } +} + +/// The artifact path under the uuid dir: `[@scope/]-.tgz`, +/// with the scope kept as a real subdirectory. +fn tgz_rel_leaf(name: &str, version: &str) -> String { + match name.split_once('/') { + Some((scope, bare)) => format!("{scope}/{bare}-{version}.tgz"), + None => format!("{name}-{version}.tgz"), + } +} + +async fn select_lockfile(project_root: &Path) -> std::io::Result)>> { + for lock_name in [SHRINKWRAP, PACKAGE_LOCK] { + match tokio::fs::read(project_root.join(lock_name)).await { + Ok(bytes) => return Ok(Some((lock_name.to_string(), bytes))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e), + } + } + Ok(None) +} + +/// `bundleDependencies` (npm) / `bundledDependencies` (legacy alias): +/// `true` means "all deps", an array names them; either makes the package +/// unvendorable (see the refusal site). +fn declares_bundled_deps(pkg: &Value) -> bool { + ["bundleDependencies", "bundledDependencies"].iter().any(|k| match pkg.get(*k) { + Some(Value::Bool(b)) => *b, + Some(Value::Array(a)) => !a.is_empty(), + _ => false, + }) +} + +async fn read_staged_package_json(stage: &Path) -> Result { + let bytes = tokio::fs::read(stage.join("package.json")) + .await + .map_err(|e| format!("patched package.json unreadable in the stage: {e}"))?; + serde_json::from_slice(&bytes) + .map_err(|e| format!("patched package.json is not parseable JSON: {e}")) +} + +/// The lock's indent unit: the leading whitespace of the first indented +/// line (npm emits 2 spaces; respect whatever formatter the project uses +/// so untouched lines stay byte-identical in diffs). Defaults to 2 spaces. +fn detect_indent(text: &str) -> String { + for line in text.lines() { + let trimmed = line.trim_start_matches([' ', '\t']); + if !trimmed.is_empty() && trimmed.len() < line.len() { + return line[..line.len() - trimmed.len()].to_string(); + } + } + " ".to_string() +} + +/// Pretty-print with the detected indent + trailing newline — npm's own +/// output shape, so `npm install` after vendoring produces no format-only +/// churn. +fn serialize_lock(lock: &Value, indent: &str) -> std::io::Result> { + use serde::Serialize; + let mut out = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes()); + let mut ser = serde_json::Serializer::with_formatter(&mut out, formatter); + lock.serialize(&mut ser).map_err(std::io::Error::other)?; + out.push(b'\n'); + Ok(out) +} + +fn refused(code: &'static str, detail: String) -> VendorOutcome { + VendorOutcome::Refused { code, detail } +} + +/// A backend failure after the refusal phase: `Done` with a failed +/// [`ApplyResult`], mirroring `go_redirect`'s synthesized results. +fn done_failure(purl: &str, error: String) -> VendorOutcome { + VendorOutcome::Done { + result: synthesized_result(purl, Path::new(""), Vec::new(), false, Some(error)), + entry: None, + warnings: Vec::new(), + } +} + +fn synthesized_result( + package_key: &str, + path: &Path, + files_verified: Vec, + success: bool, + error: Option, +) -> ApplyResult { + ApplyResult { + package_key: package_key.to_string(), + package_path: path.display().to_string(), + success, + files_verified, + files_patched: Vec::new(), + applied_via: HashMap::new(), + error, + sidecar: None, + } +} + +fn already_patched_verify(file: &str) -> VerifyResult { + VerifyResult { + file: file.to_string(), + status: VerifyStatus::AlreadyPatched, + message: None, + current_hash: None, + expected_hash: None, + target_hash: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; + use base64::Engine as _; + use serde_json::json; + use sha2::{Digest, Sha512}; + use std::path::PathBuf; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; + const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + const REG_RESOLVED: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; + + struct Fixture { + tmp: tempfile::TempDir, + record: PatchRecord, + /// Bytes of the lockfile exactly as written (the byte-stability + /// oracle for dry-run / revert round-trips). + lock_bytes: Vec, + name: String, + version: String, + } + + impl Fixture { + fn root(&self) -> &Path { + self.tmp.path() + } + + fn installed(&self) -> PathBuf { + self.root().join("node_modules").join(&self.name) + } + + fn purl(&self) -> String { + format!("pkg:npm/{}@{}", self.name, self.version) + } + + fn expected_rel_tgz(&self) -> String { + format!( + ".socket/vendor/npm/{UUID}/{}", + tgz_rel_leaf(&self.name, &self.version) + ) + } + + fn lock_path(&self) -> PathBuf { + self.root().join(PACKAGE_LOCK) + } + + async fn read_lock(&self) -> Value { + serde_json::from_slice(&tokio::fs::read(self.lock_path()).await.unwrap()).unwrap() + } + + async fn vendor(&self, dry_run: bool) -> VendorOutcome { + let blobs = self.root().join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + vendor_npm( + &self.purl(), + &self.installed(), + self.root(), + &self.record, + &sources, + "2026-06-09T00:00:00Z", + dry_run, + false, + ) + .await + } + } + + fn installed_pkg_json(name: &str, version: &str) -> Vec { + format!("{{\"name\":\"{name}\",\"version\":\"{version}\"}}\n").into_bytes() + } + + /// Default v3 lock: root entry + a direct left-pad + a NESTED + /// node_modules/foo/node_modules/left-pad instance. + fn default_lock() -> Value { + json!({ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fixture", + "version": "1.0.0", + "dependencies": { "left-pad": "^1.3.0" } + }, + "node_modules/foo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foo/-/foo-2.0.0.tgz", + "integrity": "sha512-foo==" + }, + "node_modules/foo/node_modules/left-pad": { + "version": "1.3.0", + "resolved": REG_RESOLVED, + "integrity": "sha512-orig==" + }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": REG_RESOLVED, + "integrity": "sha512-orig==", + "license": "WTFPL" + } + } + }) + } + + async fn fixture() -> Fixture { + fixture_with("left-pad", "1.3.0", default_lock()).await + } + + /// Build a project tempdir: installed package, patched blob, lockfile, + /// and the PatchRecord. The lock is written in production format (the + /// same serializer + 2-space indent) so byte-identity assertions are + /// meaningful. + async fn fixture_with(name: &str, version: &str, lock: Value) -> Fixture { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + let installed = root.join("node_modules").join(name); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write(installed.join("package.json"), installed_pkg_json(name, version)) + .await + .unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX).await.unwrap(); + + let blobs = root.join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX).await.unwrap(); + + let lock_bytes = serialize_lock(&lock, " ").unwrap(); + tokio::fs::write(root.join(PACKAGE_LOCK), &lock_bytes).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(ORIG_INDEX), + after_hash, + }, + ); + let record = PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-06-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: "test patch".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }; + + Fixture { + tmp, + record, + lock_bytes, + name: name.to_string(), + version: version.to_string(), + } + } + + fn expect_done(outcome: VendorOutcome) -> (ApplyResult, Option, Vec) { + match outcome { + VendorOutcome::Done { result, entry, warnings } => (result, entry, warnings), + VendorOutcome::Refused { code, detail } => { + panic!("expected Done, got Refused {code}: {detail}") + } + } + } + + fn expect_refused(outcome: VendorOutcome, want_code: &str) -> String { + match outcome { + VendorOutcome::Refused { code, detail } => { + assert_eq!(code, want_code, "wrong refusal code ({detail})"); + detail + } + VendorOutcome::Done { result, .. } => { + panic!("expected Refused {want_code}, got Done (success={})", result.success) + } + } + } + + fn sri_sha512(bytes: &[u8]) -> String { + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes)) + ) + } + + #[tokio::test] + async fn happy_path_rewrites_every_instance_and_records_wiring() { + let fx = fixture().await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(warnings.is_empty(), "{warnings:?}"); + let entry = entry.expect("success must carry a ledger entry"); + + // Tarball on disk; ledger artifact facts describe it. + let rel_tgz = fx.expected_rel_tgz(); + let tgz = tokio::fs::read(fx.root().join(&rel_tgz)).await.unwrap(); + assert_eq!(entry.artifact.path, rel_tgz); + assert_eq!(entry.artifact.size, Some(tgz.len() as u64)); + assert_eq!(entry.artifact.sha256, hex::encode(sha2::Sha256::digest(&tgz))); + let expected_integrity = sri_sha512(&tgz); + + // BOTH instances (direct + nested) rewritten; everything else intact. + let lock = fx.read_lock().await; + let expected_resolved = format!("file:{rel_tgz}"); + for key in ["node_modules/left-pad", "node_modules/foo/node_modules/left-pad"] { + let e = &lock["packages"][key]; + assert_eq!(e["resolved"], json!(expected_resolved), "{key}"); + assert_eq!(e["integrity"], json!(expected_integrity), "{key}: integrity MUST be the recomputed tarball hash"); + assert_eq!(e["version"], json!("1.3.0"), "{key}: version untouched"); + } + assert_eq!(lock["packages"]["node_modules/left-pad"]["license"], json!("WTFPL")); + assert_eq!( + lock["packages"]["node_modules/foo"], + default_lock()["packages"]["node_modules/foo"], + "unrelated entry untouched" + ); + + // Wiring: one record per instance, verbatim originals. + assert_eq!(entry.wiring.len(), 2); + for rec in &entry.wiring { + assert_eq!(rec.file, PACKAGE_LOCK); + assert_eq!(rec.kind, KIND_LOCK_ENTRY); + assert_eq!(rec.action, WiringAction::Rewritten); + let key = rec.key.as_deref().unwrap(); + assert_eq!( + rec.original.as_ref().unwrap(), + &default_lock()["packages"][key], + "original must be the verbatim pre-vendor entry for {key}" + ); + assert_eq!(rec.new.as_ref().unwrap()["resolved"], json!(expected_resolved)); + } + + // Marker sits next to the artifact. + let marker = tokio::fs::read_to_string( + fx.root().join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")), + ) + .await + .unwrap(); + assert!(marker.contains(UUID)); + assert!(marker.contains("pkg:npm/left-pad@1.3.0")); + + // The tarball contains the PATCHED bytes under package/. + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(tgz.as_slice())); + let mut found = false; + for e in archive.entries().unwrap() { + let mut e = e.unwrap(); + if e.path().unwrap().to_string_lossy() == "package/index.js" { + let mut data = Vec::new(); + std::io::Read::read_to_end(&mut e, &mut data).unwrap(); + assert_eq!(data, PATCHED_INDEX); + found = true; + } + } + assert!(found, "package/index.js missing from the tarball"); + } + + #[tokio::test] + async fn rerun_is_in_sync_and_byte_stable() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + assert!(entry.is_some()); + let lock_after_first = tokio::fs::read(fx.lock_path()).await.unwrap(); + let tgz_path = fx.root().join(fx.expected_rel_tgz()); + let tgz_first = tokio::fs::read(&tgz_path).await.unwrap(); + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success); + assert!(entry.is_none(), "in-sync re-run must not produce a new ledger entry"); + assert!( + result + .files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), + "in-sync re-run reports AlreadyPatched: {:?}", + result.files_verified + ); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_after_first, + "lock must be byte-stable across re-runs" + ); + assert_eq!( + tokio::fs::read(&tgz_path).await.unwrap(), + tgz_first, + "tarball must be byte-identical across re-runs" + ); + } + + #[tokio::test] + async fn scoped_package_uses_scope_subdirectory() { + let lock = json!({ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "packages": { + "": { "name": "fixture", "version": "1.0.0" }, + "node_modules/@scope/pkg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz", + "integrity": "sha512-orig==" + } + } + }); + let fx = fixture_with("@scope/pkg", "1.0.0", lock).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + + let rel = format!(".socket/vendor/npm/{UUID}/@scope/pkg-1.0.0.tgz"); + assert_eq!(entry.artifact.path, rel); + assert!(fx.root().join(&rel).exists(), "tarball at the scoped path"); + let lock = fx.read_lock().await; + assert_eq!( + lock["packages"]["node_modules/@scope/pkg"]["resolved"], + json!(format!("file:{rel}")) + ); + } + + #[tokio::test] + async fn alias_entry_is_matched_by_name_field() { + // `npm i aliased@npm:left-pad@1.3.0` → key node_modules/aliased, + // entry carries the real name. + let mut lock = default_lock(); + lock["packages"]["node_modules/aliased"] = json!({ + "name": "left-pad", + "version": "1.3.0", + "resolved": REG_RESOLVED, + "integrity": "sha512-orig==" + }); + let fx = fixture_with("left-pad", "1.3.0", lock).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success); + let entry = entry.unwrap(); + assert_eq!(entry.wiring.len(), 3, "direct + nested + alias all rewritten"); + + let lock = fx.read_lock().await; + let alias = &lock["packages"]["node_modules/aliased"]; + assert_eq!(alias["resolved"], json!(format!("file:{}", fx.expected_rel_tgz()))); + assert_eq!(alias["name"], json!("left-pad"), "alias name field preserved"); + } + + #[tokio::test] + async fn link_and_in_bundle_instances_are_skipped_with_warnings() { + let mut lock = default_lock(); + lock["packages"]["node_modules/linked-pad"] = json!({ + "name": "left-pad", + "version": "1.3.0", + "resolved": "projects/left-pad", + "link": true + }); + lock["packages"]["node_modules/bundler/node_modules/left-pad"] = json!({ + "version": "1.3.0", + "resolved": REG_RESOLVED, + "integrity": "sha512-orig==", + "inBundle": true + }); + let fx = fixture_with("left-pad", "1.3.0", lock.clone()).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success); + assert_eq!(entry.unwrap().wiring.len(), 2, "only the rewritable instances"); + + let codes: Vec<&str> = warnings.iter().map(|w| w.code).collect(); + assert!(codes.contains(&"vendor_link_entry_skipped"), "{codes:?}"); + assert!(codes.contains(&"vendor_bundled_instance_skipped"), "{codes:?}"); + let bundled = warnings.iter().find(|w| w.code == "vendor_bundled_instance_skipped").unwrap(); + assert!(bundled.detail.contains("UNPATCHED"), "loud warning: {}", bundled.detail); + + // Skipped entries are byte-untouched. + let live = fx.read_lock().await; + assert_eq!( + live["packages"]["node_modules/linked-pad"], + lock["packages"]["node_modules/linked-pad"] + ); + assert_eq!( + live["packages"]["node_modules/bundler/node_modules/left-pad"], + lock["packages"]["node_modules/bundler/node_modules/left-pad"] + ); + } + + #[tokio::test] + async fn workspace_member_is_refused() { + let lock = json!({ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "packages": { + "": { "name": "fixture", "version": "1.0.0" }, + "packages/left-pad": { "name": "left-pad", "version": "1.3.0" } + } + }); + let fx = fixture_with("left-pad", "1.3.0", lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_workspace_member"); + assert!(detail.contains("packages/left-pad")); + assert!(!fx.root().join(".socket/vendor").exists(), "refusal writes nothing"); + } + + #[tokio::test] + async fn bundled_deps_package_is_refused_before_lock_writes() { + let fx = fixture().await; + tokio::fs::write( + fx.installed().join("package.json"), + br#"{"name":"left-pad","version":"1.3.0","bundleDependencies":["dep"]}"#, + ) + .await + .unwrap(); + expect_refused(fx.vendor(false).await, "vendor_bundled_deps_unsupported"); + assert!(!fx.root().join(".socket/vendor").exists()); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "lock untouched by the refusal" + ); + } + + #[tokio::test] + async fn lockfile_v1_is_refused() { + let lock = json!({ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 1, + "dependencies": { + "left-pad": { "version": "1.3.0", "resolved": REG_RESOLVED, "integrity": "sha512-orig==" } + } + }); + let fx = fixture_with("left-pad", "1.3.0", lock).await; + expect_refused(fx.vendor(false).await, "vendor_lockfile_version_unsupported"); + } + + #[tokio::test] + async fn missing_lockfile_is_refused() { + let fx = fixture().await; + tokio::fs::remove_file(fx.lock_path()).await.unwrap(); + let detail = expect_refused(fx.vendor(false).await, "vendor_lockfile_missing"); + assert!(detail.contains("npm install"), "actionable detail: {detail}"); + } + + #[tokio::test] + async fn no_matching_entry_is_refused() { + let mut lock = default_lock(); + // Lock knows only a DIFFERENT version of left-pad. + lock["packages"]["node_modules/left-pad"]["version"] = json!("1.2.0"); + lock["packages"]["node_modules/foo/node_modules/left-pad"]["version"] = json!("1.2.0"); + let fx = fixture_with("left-pad", "1.3.0", lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); + assert!(detail.contains("npm install"), "actionable detail: {detail}"); + } + + #[tokio::test] + async fn shrinkwrap_wins_over_package_lock() { + let fx = fixture().await; + // Same content as the package-lock, but under the shrinkwrap name. + tokio::fs::write(fx.root().join(SHRINKWRAP), &fx.lock_bytes).await.unwrap(); + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success); + let entry = entry.unwrap(); + assert!(entry.wiring.iter().all(|r| r.file == SHRINKWRAP)); + + // package-lock.json byte-untouched; shrinkwrap rewritten. + assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + let shrink: Value = + serde_json::from_slice(&tokio::fs::read(fx.root().join(SHRINKWRAP)).await.unwrap()) + .unwrap(); + assert_eq!( + shrink["packages"]["node_modules/left-pad"]["resolved"], + json!(format!("file:{}", fx.expected_rel_tgz())) + ); + } + + #[tokio::test] + async fn v2_lock_rewrites_the_legacy_dependencies_mirror_and_reverts() { + let lock = json!({ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { "name": "fixture", "version": "1.0.0" }, + "node_modules/foo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foo/-/foo-2.0.0.tgz", + "integrity": "sha512-foo==" + }, + "node_modules/foo/node_modules/left-pad": { + "version": "1.3.0", + "resolved": REG_RESOLVED, + "integrity": "sha512-orig==" + }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": REG_RESOLVED, + "integrity": "sha512-orig==" + } + }, + "dependencies": { + "foo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foo/-/foo-2.0.0.tgz", + "integrity": "sha512-foo==", + "requires": { "left-pad": "^1.3.0" }, + "dependencies": { + "left-pad": { + "version": "1.3.0", + "resolved": REG_RESOLVED, + "integrity": "sha512-orig==" + } + } + }, + "left-pad": { + "version": "1.3.0", + "resolved": REG_RESOLVED, + "integrity": "sha512-orig==" + } + } + }); + let fx = fixture_with("left-pad", "1.3.0", lock).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success); + let entry = entry.unwrap(); + + let legacy: Vec<&WiringRecord> = + entry.wiring.iter().filter(|r| r.kind == KIND_LOCK_LEGACY_ENTRY).collect(); + assert_eq!(legacy.len(), 2, "top-level + nested legacy nodes: {:?}", entry.wiring); + let keys: Vec<&str> = legacy.iter().map(|r| r.key.as_deref().unwrap()).collect(); + assert!(keys.contains(&"/dependencies/left-pad"), "{keys:?}"); + assert!(keys.contains(&"/dependencies/foo/dependencies/left-pad"), "{keys:?}"); + + let resolved = json!(format!("file:{}", fx.expected_rel_tgz())); + let live = fx.read_lock().await; + assert_eq!(live["dependencies"]["left-pad"]["resolved"], resolved); + assert_eq!( + live["dependencies"]["foo"]["dependencies"]["left-pad"]["resolved"], + resolved + ); + assert_eq!( + live["dependencies"]["foo"]["resolved"], + json!("https://registry.npmjs.org/foo/-/foo-2.0.0.tgz"), + "non-matching legacy node untouched" + ); + + // Pointer-addressed revert restores the v2 lock byte-for-byte. + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + } + + #[tokio::test] + async fn dry_run_writes_nothing() { + let fx = fixture().await; + let (result, entry, _) = expect_done(fx.vendor(true).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none(), "dry run must not produce a ledger entry"); + assert!(result.files_patched.is_empty(), "dry run patches nothing"); + + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "lock byte-untouched" + ); + assert!(!fx.root().join(".socket/vendor").exists(), ".socket/vendor absent"); + // The installed package is never patched in place by vendor. + assert_eq!( + tokio::fs::read(fx.installed().join("index.js")).await.unwrap(), + ORIG_INDEX + ); + } + + #[tokio::test] + async fn patched_package_json_recomputes_lock_dep_fields() { + let mut fx = fixture().await; + // Give one lock instance dep-mirror fields the patch obsoletes. + let mut lock = default_lock(); + lock["packages"]["node_modules/left-pad"]["peerDependencies"] = json!({ "gone": "^1.0.0" }); + let lock_bytes = serialize_lock(&lock, " ").unwrap(); + tokio::fs::write(fx.lock_path(), &lock_bytes).await.unwrap(); + fx.lock_bytes = lock_bytes; + + // The patch rewrites package.json: adds a dependency + a bin. + let before = installed_pkg_json("left-pad", "1.3.0"); + let after: &[u8] = + br#"{"name":"left-pad","version":"1.3.0","dependencies":{"wow":"^1.0.0"},"bin":{"lp":"cli.js"}}"#; + let after_hash = compute_git_sha256_from_bytes(after); + tokio::fs::write(fx.root().join(".socket/blobs").join(&after_hash), after) + .await + .unwrap(); + fx.record.files.insert( + "package/package.json".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(&before), + after_hash, + }, + ); + + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_some()); + assert!( + warnings.iter().any(|w| w.code == "vendor_dep_manifest_rewritten"), + "{warnings:?}" + ); + + let live = fx.read_lock().await; + let e = &live["packages"]["node_modules/left-pad"]; + assert_eq!(e["dependencies"], json!({ "wow": "^1.0.0" })); + assert_eq!(e["bin"], json!({ "lp": "cli.js" })); + assert!( + e.get("peerDependencies").is_none(), + "field absent from the patched manifest must be removed" + ); + assert_eq!(e["license"], json!("WTFPL"), "non-dep fields untouched"); + } + + #[tokio::test] + async fn revert_round_trips_the_lock_and_removes_the_artifact() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let tgz_path = fx.root().join(fx.expected_rel_tgz()); + assert!(tgz_path.exists()); + + // Dry-run revert: success, nothing removed/restored. + let outcome = revert_npm(&entry, fx.root(), true).await; + assert!(outcome.success); + assert!(tgz_path.exists(), "dry-run revert must not delete the artifact"); + assert_ne!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "dry-run revert must not touch the lock" + ); + + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "lock restored byte-for-byte" + ); + assert!(!tgz_path.exists(), "tarball removed"); + assert!( + !fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists(), + "uuid dir pruned" + ); + } + + #[tokio::test] + async fn revert_leaves_drifted_entries_alone_with_warning() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + // The user re-resolved the DIRECT instance behind our back. + let mut live = fx.read_lock().await; + live["packages"]["node_modules/left-pad"]["resolved"] = + json!("https://example.com/their-fork.tgz"); + tokio::fs::write(fx.lock_path(), serialize_lock(&live, " ").unwrap()) + .await + .unwrap(); + + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success); + assert!( + outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + "{:?}", + outcome.warnings + ); + + let after = fx.read_lock().await; + assert_eq!( + after["packages"]["node_modules/left-pad"]["resolved"], + json!("https://example.com/their-fork.tgz"), + "drifted entry left alone" + ); + assert_eq!( + after["packages"]["node_modules/foo/node_modules/left-pad"], + default_lock()["packages"]["node_modules/foo/node_modules/left-pad"], + "non-drifted instance restored" + ); + assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + } + + #[tokio::test] + async fn traversal_uuid_is_refused_before_any_write() { + let mut fx = fixture().await; + fx.record.uuid = "../../x".to_string(); + expect_refused(fx.vendor(false).await, "unsafe_coordinates"); + assert!(!fx.root().join(".socket/vendor").exists()); + assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + // And revert refuses to delete through a tampered uuid too. + let entry = VendorEntry { + ecosystem: "npm".into(), + base_purl: fx.purl(), + uuid: "../../x".into(), + artifact: VendorArtifact { + path: "whatever".into(), + sha256: String::new(), + size: None, + platform_locked: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + flavor: None, + uv: None, + }; + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(!outcome.success, "tampered uuid must fail closed"); + } + + #[test] + fn purl_and_name_helpers() { + assert_eq!(parse_npm_purl("pkg:npm/left-pad@1.3.0"), Some(("left-pad", "1.3.0"))); + assert_eq!( + parse_npm_purl("pkg:npm/@scope/pkg@1.0.0?foo=bar"), + Some(("@scope/pkg", "1.0.0")) + ); + assert_eq!(parse_npm_purl("pkg:npm/@scope/pkg"), None, "no version"); + assert_eq!(parse_npm_purl("pkg:pypi/six@1.16.0"), None, "wrong ecosystem"); + + assert!(is_safe_npm_name("left-pad")); + assert!(is_safe_npm_name("@scope/pkg")); + assert!(!is_safe_npm_name("../escape")); + assert!(!is_safe_npm_name("a/b"), "slash without a scope"); + assert!(!is_safe_npm_name("@scope/a/b"), "extra path level"); + assert!(!is_safe_npm_name("@scope"), "scope marker without a name"); + + assert_eq!(tgz_rel_leaf("left-pad", "1.3.0"), "left-pad-1.3.0.tgz"); + assert_eq!(tgz_rel_leaf("@scope/pkg", "1.0.0"), "@scope/pkg-1.0.0.tgz"); + } + + #[test] + fn indent_detection_and_pointer_escaping() { + assert_eq!(detect_indent("{\n \"a\": 1\n}\n"), " "); + assert_eq!(detect_indent("{\n\t\"a\": 1\n}\n"), "\t"); + assert_eq!(detect_indent("{\n \"a\": 1\n}\n"), " "); + assert_eq!(detect_indent("{}"), " ", "default for flat files"); + + assert_eq!(escape_json_pointer_token("@scope/name"), "@scope~1name"); + assert_eq!(escape_json_pointer_token("a~b"), "a~0b"); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/npm_pack.rs b/crates/socket-patch-core/src/patch/vendor/npm_pack.rs index ed2edb3..746945e 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_pack.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_pack.rs @@ -1 +1,310 @@ -//! (stub — implementation lands with its backend phase) +//! Deterministic npm tarball packing for the vendor backend. +//! +//! The tarball's sha512 lands in the committed lockfile's `integrity` field +//! ([`super::npm_lock`]), so packing the same patched tree MUST yield the +//! same bytes every time: any churn would dirty `package-lock.json` + +//! `.socket/vendor/` on every re-run and break the "re-vendor is a no-op" +//! idempotency contract. Determinism is achieved the same way `npm pack` +//! does it — fixed entry metadata (npm's well-known 1985 mtime, uid/gid 0, +//! normalized modes), entries sorted by path, and a gzip stream with a +//! zeroed header mtime and a pinned compression level. + +use std::path::{Path, PathBuf}; + +use base64::Engine as _; +use sha2::{Digest, Sha256, Sha512}; + +use crate::utils::fs::atomic_write_bytes; + +/// npm's fixed tar entry mtime: `1985-10-26T08:15:00Z`. Every `npm pack` +/// tarball carries this timestamp (npm pins it for reproducible packs); +/// reusing it keeps our artifacts byte-deterministic AND familiar to any +/// tooling that special-cases the value. +const NPM_PACK_MTIME: u64 = 499_162_500; + +/// Result of [`pack_deterministic`]: the identity facts of the written +/// tarball, computed over the FINAL on-disk bytes (exactly what npm hashes +/// when it verifies `integrity`). +pub struct PackedTarball { + /// SRI string: `"sha512-" + base64(sha512(tgz bytes))`. + pub integrity: String, + /// Plain sha256 hex of the tgz bytes (the vendor ledger's artifact hash). + pub sha256_hex: String, + /// Byte size of the tgz. + pub size: u64, +} + +/// Pack every regular file under `staged_dir` into an npm-conventional +/// `package/`-prefixed tar.gz at `dest`, deterministically (see module docs). +/// +/// Entries are sorted lexicographically by full entry path bytes; symlinks +/// and special files are skipped (a registry npm package contains none, and +/// a symlink in a tarball npm extracts would be an escape hazard). The write +/// is atomic (stage + rename) so a crash never leaves a torn artifact that a +/// later `npm ci` would fail integrity-checking with a confusing error. +pub async fn pack_deterministic(staged_dir: &Path, dest: &Path) -> std::io::Result { + let staged = staged_dir.to_path_buf(); + // tar + flate2 are synchronous; run the whole pack on the blocking pool. + let bytes = tokio::task::spawn_blocking(move || pack_to_bytes(&staged)) + .await + .map_err(|e| std::io::Error::other(e.to_string()))??; + + atomic_write_bytes(dest, &bytes).await?; + + let integrity = format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&bytes)) + ); + Ok(PackedTarball { + integrity, + sha256_hex: hex::encode(Sha256::digest(&bytes)), + size: bytes.len() as u64, + }) +} + +/// Build the deterministic tar.gz in memory (vendored packages are small — +/// the same size class the apply pipeline already buffers per-file). +fn pack_to_bytes(staged_dir: &Path) -> std::io::Result> { + let mut files = collect_regular_files(staged_dir)?; + // Lexicographic byte order of the full entry path — the deterministic, + // platform-independent ordering (String's Ord is byte-wise, but spell it + // out so a future refactor can't accidentally switch to a locale sort). + files.sort_unstable_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); + + // Pin the compression level explicitly: `Compression::default()` is 6 + // today, but a flate2 default bump would silently churn every committed + // integrity hash, so the level must never float. flate2's GzEncoder + // header is already deterministic (GzBuilder defaults: mtime = 0, OS + // byte = 255 "unknown" — verified against flate2 1.1.9 source); the + // determinism test below byte-compares two packs to lock that in. + let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::new(6)); + let mut builder = tar::Builder::new(gz); + + for (entry_path, abs_path, executable) in &files { + let data = std::fs::read(abs_path)?; + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Regular); + header.set_size(data.len() as u64); + // Normalized modes, like `npm pack`: 0o644, or 0o755 when the source + // carries any exec bit (preserving WHICH user had exec would leak + // host umask into the bytes). + header.set_mode(if *executable { 0o755 } else { 0o644 }); + header.set_mtime(NPM_PACK_MTIME); + header.set_uid(0); + header.set_gid(0); + // uname/gname stay empty (a GNU header is zero-initialized) — real + // user names would differ per host and break determinism. + builder.append_data(&mut header, entry_path, data.as_slice())?; + } + + builder.into_inner()?.finish() +} + +/// Walk `staged_dir` and return `(entry_path, abs_path, executable)` for +/// every regular file, with entry paths `package/`-prefixed and +/// forward-slashed (the npm tarball convention on every platform). +fn collect_regular_files(staged_dir: &Path) -> std::io::Result> { + let mut files = Vec::new(); + for entry in walkdir::WalkDir::new(staged_dir).follow_links(false) { + let entry = entry.map_err(|e| std::io::Error::other(e.to_string()))?; + // Regular files only: directories are implicit in member paths (npm + // tarballs carry no dir entries), and symlinks/specials are skipped — + // following one could read content from outside the staged tree. + if !entry.file_type().is_file() { + continue; + } + let rel = entry + .path() + .strip_prefix(staged_dir) + .map_err(|e| std::io::Error::other(e.to_string()))?; + let mut parts = Vec::new(); + for component in rel.components() { + match component { + std::path::Component::Normal(seg) => { + parts.push(seg.to_str().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("non-UTF-8 file name in staged package: {rel:?}"), + ) + })?); + } + // walkdir under strip_prefix yields only Normal components; + // anything else means the path math broke — refuse rather + // than emit a malformed entry path. + other => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("unexpected path component {other:?} in staged package"), + )); + } + } + } + let executable = is_executable(&entry.metadata().map_err(|e| { + std::io::Error::other(e.to_string()) + })?); + files.push((format!("package/{}", parts.join("/")), entry.into_path(), executable)); + } + Ok(files) +} + +fn is_executable(metadata: &std::fs::Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + let _ = metadata; + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + /// Build a small staged tree with nested dirs, an executable, and an + /// empty directory (which must NOT produce a tar entry). + async fn build_stage(root: &Path) { + tokio::fs::create_dir_all(root.join("lib/nested")).await.unwrap(); + tokio::fs::create_dir_all(root.join("empty-dir")).await.unwrap(); + tokio::fs::write(root.join("package.json"), b"{\"name\":\"x\"}\n").await.unwrap(); + tokio::fs::write(root.join("index.js"), b"module.exports = 1;\n").await.unwrap(); + tokio::fs::write(root.join("lib/nested/deep.js"), b"deep\n").await.unwrap(); + tokio::fs::write(root.join("cli.sh"), b"#!/bin/sh\n").await.unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions( + root.join("cli.sh"), + std::fs::Permissions::from_mode(0o755), + ) + .await + .unwrap(); + } + } + + fn read_entries(tgz: &[u8]) -> Vec<(String, u64, u64, u64, u32, Vec)> { + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(tgz)); + let mut out = Vec::new(); + for entry in archive.entries().unwrap() { + let mut entry = entry.unwrap(); + let path = entry.path().unwrap().to_string_lossy().into_owned(); + let header = entry.header(); + let (mtime, uid, gid, mode) = ( + header.mtime().unwrap(), + header.uid().unwrap(), + header.gid().unwrap(), + header.mode().unwrap(), + ); + let mut data = Vec::new(); + entry.read_to_end(&mut data).unwrap(); + out.push((path, mtime, uid, gid, mode, data)); + } + out + } + + #[tokio::test] + async fn pack_is_byte_deterministic_and_reports_true_hashes() { + let tmp = tempfile::tempdir().unwrap(); + let stage = tmp.path().join("stage"); + build_stage(&stage).await; + + let dest1 = tmp.path().join("a.tgz"); + let dest2 = tmp.path().join("b.tgz"); + let packed1 = pack_deterministic(&stage, &dest1).await.unwrap(); + let packed2 = pack_deterministic(&stage, &dest2).await.unwrap(); + + let bytes1 = tokio::fs::read(&dest1).await.unwrap(); + let bytes2 = tokio::fs::read(&dest2).await.unwrap(); + assert_eq!(bytes1, bytes2, "two packs of the same tree must be byte-identical"); + assert_eq!(packed1.sha256_hex, packed2.sha256_hex); + assert_eq!(packed1.integrity, packed2.integrity); + + // The reported facts describe the final on-disk bytes. + assert_eq!(packed1.size, bytes1.len() as u64); + assert_eq!(packed1.sha256_hex, hex::encode(Sha256::digest(&bytes1))); + let expected_integrity = format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&bytes1)) + ); + assert_eq!(packed1.integrity, expected_integrity); + + // gzip header: mtime field (bytes 4..8) zeroed, OS byte 255 — the + // two flate2 defaults our determinism depends on. + assert_eq!(&bytes1[4..8], &[0, 0, 0, 0], "gzip header mtime must be 0"); + assert_eq!(bytes1[9], 255, "gzip header OS byte must be 255 (unknown)"); + } + + #[tokio::test] + async fn entries_are_sorted_prefixed_and_normalized() { + let tmp = tempfile::tempdir().unwrap(); + let stage = tmp.path().join("stage"); + build_stage(&stage).await; + let dest = tmp.path().join("pkg.tgz"); + pack_deterministic(&stage, &dest).await.unwrap(); + + let entries = read_entries(&tokio::fs::read(&dest).await.unwrap()); + let paths: Vec<&str> = entries.iter().map(|e| e.0.as_str()).collect(); + // Sorted by full entry path bytes; every path `package/`-prefixed; + // no entry for the empty directory. + assert_eq!( + paths, + vec![ + "package/cli.sh", + "package/index.js", + "package/lib/nested/deep.js", + "package/package.json", + ] + ); + for (path, mtime, uid, gid, mode, data) in &entries { + assert_eq!(*mtime, NPM_PACK_MTIME, "{path}: npm's fixed 1985 mtime"); + assert_eq!((*uid, *gid), (0, 0), "{path}: uid/gid must be 0"); + let expected_mode = if path == "package/cli.sh" && cfg!(unix) { 0o755 } else { 0o644 }; + assert_eq!(*mode, expected_mode, "{path}: normalized mode"); + assert!(!data.is_empty(), "{path}: content must round-trip"); + } + // Content integrity spot check. + let index = entries.iter().find(|e| e.0 == "package/index.js").unwrap(); + assert_eq!(index.5, b"module.exports = 1;\n"); + } + + #[cfg(unix)] + #[tokio::test] + async fn symlinks_are_skipped() { + let tmp = tempfile::tempdir().unwrap(); + let stage = tmp.path().join("stage"); + build_stage(&stage).await; + // An out-of-tree symlink: must neither appear nor be followed. + tokio::fs::write(tmp.path().join("outside.txt"), b"outside").await.unwrap(); + std::os::unix::fs::symlink(tmp.path().join("outside.txt"), stage.join("link.txt")) + .unwrap(); + + let dest = tmp.path().join("pkg.tgz"); + pack_deterministic(&stage, &dest).await.unwrap(); + + let entries = read_entries(&tokio::fs::read(&dest).await.unwrap()); + assert!( + entries.iter().all(|e| !e.0.contains("link.txt") && !e.0.contains("outside")), + "symlink leaked into the tarball: {:?}", + entries.iter().map(|e| &e.0).collect::>() + ); + } + + #[tokio::test] + async fn write_is_atomic_no_stage_litter() { + let tmp = tempfile::tempdir().unwrap(); + let stage = tmp.path().join("stage"); + build_stage(&stage).await; + let dest_dir = tmp.path().join("out"); + tokio::fs::create_dir_all(&dest_dir).await.unwrap(); + pack_deterministic(&stage, &dest_dir.join("pkg.tgz")).await.unwrap(); + + for entry in std::fs::read_dir(&dest_dir).unwrap() { + let name = entry.unwrap().file_name().to_string_lossy().into_owned(); + assert!(!name.starts_with(".socket-stage-"), "stage litter: {name}"); + } + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/pypi.rs b/crates/socket-patch-core/src/patch/vendor/pypi.rs index ed2edb3..5f511d2 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi.rs @@ -1 +1,825 @@ -//! (stub — implementation lands with its backend phase) +//! pypi vendor backend: flavor routing + orchestration. +//! +//! Order of operations is the safety story: every refusal-capable check +//! (flavor route, uv project guards, requirements pre-flight, dist lookup, +//! tag compression) runs BEFORE the wheel artifact is built, and the +//! lockfile/manifest wiring is written LAST — so a refusal leaves the tree +//! byte-untouched and an artifact failure never leaves half-wired lockfiles. + +use std::path::Path; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::PatchSources; +use crate::utils::purl::{parse_pypi_purl, strip_purl_qualifiers}; + +use super::path::vendor_uuid_dir_rel; +use super::pypi_requirements::{preflight_requirements, revert_requirements, wire_requirements}; +use super::pypi_uv::{ + check_target_guards, classify_dependency, load_uv_project, revert_uv, wire_uv, UvDepClass, + UvProject, +}; +use super::pypi_wheel::{build_patched_wheel, locate_installed_dist, wheel_file_name}; +use super::state::{write_marker, VendorArtifact, VendorEntry, VendorMarker}; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +/// Which wiring backend serves this project. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PypiFlavor { + /// `uv.lock`-managed project → paired pyproject + lock surgery. + UvProject, + /// Plain `requirements.txt` (pip / `uv pip`) → line rewriting. + Requirements, +} + +impl PypiFlavor { + fn as_str(self) -> &'static str { + match self { + PypiFlavor::UvProject => "uv", + PypiFlavor::Requirements => "requirements", + } + } +} + +const SETUP_ALTERNATIVE: &str = + "use the `socket-patch setup` .pth install hook instead, which patches installed \ + site-packages without lockfile edits"; + +/// Route the project to a wiring flavor, first match wins: +/// 1. `uv.lock` at the root → uv; +/// 2. `[tool.uv]` without a lock (and no requirements.txt fallback) → +/// refuse, asking for `uv lock`; +/// 3. Pipenv / Poetry / PDM markers → refuse (no spike-verified wiring); +/// 4. `requirements.txt` → requirements; +/// 5. a lone pyproject → refuse (no lock, nothing to wire); +/// 6. nothing → refuse. +pub async fn detect_pypi_flavor( + project_root: &Path, +) -> Result { + let exists = |name: &str| { + let p = project_root.join(name); + async move { tokio::fs::metadata(&p).await.is_ok() } + }; + if exists("uv.lock").await { + return Ok(PypiFlavor::UvProject); + } + let pyproject_text = tokio::fs::read_to_string(project_root.join("pyproject.toml")) + .await + .ok(); + let has_requirements = exists("requirements.txt").await; + let has_pyproject_table = |prefix: &str| { + pyproject_text + .as_deref() + .map(|t| has_table(t, prefix)) + .unwrap_or(false) + }; + if has_pyproject_table("tool.uv") && !has_requirements { + return Err(( + "pypi_uv_no_lockfile", + format!( + "pyproject.toml declares [tool.uv] but there is no uv.lock; run `uv lock` and \ + re-run vendor, or {SETUP_ALTERNATIVE}" + ), + )); + } + if exists("Pipfile").await || exists("Pipfile.lock").await { + return Err(( + "pypi_pipenv_unsupported", + format!("Pipenv projects are not supported by vendor; {SETUP_ALTERNATIVE}"), + )); + } + if exists("poetry.lock").await || has_pyproject_table("tool.poetry") { + return Err(( + "pypi_poetry_unsupported", + format!("Poetry projects are not supported by vendor; {SETUP_ALTERNATIVE}"), + )); + } + if exists("pdm.lock").await || has_pyproject_table("tool.pdm") { + return Err(( + "pypi_pdm_unsupported", + format!("PDM projects are not supported by vendor; {SETUP_ALTERNATIVE}"), + )); + } + if has_requirements { + return Ok(PypiFlavor::Requirements); + } + if pyproject_text.is_some() { + return Err(( + "pypi_pyproject_only", + format!( + "the project has a pyproject.toml but no lockfile or requirements.txt to wire; \ + {SETUP_ALTERNATIVE}" + ), + )); + } + Err(( + "pypi_no_requirements", + format!( + "no uv.lock, pyproject.toml, or requirements.txt found at the project root; \ + {SETUP_ALTERNATIVE}" + ), + )) +} + +/// `[prefix]` / `[prefix.*]` table-header probe. Mirrors the private +/// `has_table` in `pth_hook/detect.rs` (header-anchored so a substring in a +/// value or comment cannot misroute the flavor). +fn has_table(content: &str, prefix: &str) -> bool { + content.lines().any(|line| { + let line = line.trim(); + let Some(rest) = line.strip_prefix('[') else { + return false; + }; + let rest = rest.trim_start_matches('['); + let Some(end) = rest.find(']') else { + return false; + }; + let header = rest[..end].trim(); + header == prefix || header.starts_with(&format!("{prefix}.")) + }) +} + +/// Per-flavor pre-flight result carried into the wiring step. +enum WiringPlan { + Uv(Box, UvDepClass), + Requirements, +} + +/// Vendor one pypi package: route the flavor, pre-flight every guard, build +/// the patched wheel at `.socket/vendor/pypi//`, write the +/// marker, then wire the project files (LAST). +#[allow(clippy::too_many_arguments)] +pub async fn vendor_pypi( + purl: &str, + site_packages: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, +) -> VendorOutcome { + // The purl may carry `?artifact_id=` variant qualifiers; everything here + // keys off the qualifier-free base. + let base = strip_purl_qualifiers(purl); + let Some((raw_name, version)) = parse_pypi_purl(base) else { + return VendorOutcome::Refused { + code: "pypi_invalid_purl", + detail: format!("{purl} is not a pkg:pypi PURL with a version"), + }; + }; + let canon_name = canonicalize_pypi_name(raw_name); + + // SECURITY: the uuid comes from a committed, tamper-able manifest and + // keys the on-disk artifact directory vendor creates (and --revert + // deletes). Anything but the canonical UUID grammar is rejected + // fail-closed before any disk access. + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("pypi", &record.uuid) else { + return VendorOutcome::Refused { + code: "vendor_unsafe_uuid", + detail: format!( + "patch uuid {:?} is not a canonical lowercase uuid; refusing to derive a \ + vendor path from it", + record.uuid + ), + }; + }; + + let flavor = match detect_pypi_flavor(project_root).await { + Ok(f) => f, + Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + }; + + // Pre-flight the wiring guards BEFORE building anything, so refusals + // leave the tree byte-untouched. + let mut warnings: Vec = Vec::new(); + let plan = match flavor { + PypiFlavor::UvProject => { + let project = match load_uv_project(project_root).await { + Ok(p) => p, + Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + }; + if let Err((code, detail)) = check_target_guards(&project, &canon_name) { + return VendorOutcome::Refused { code, detail }; + } + warnings.extend(project.warnings.iter().cloned()); + let class = classify_dependency(&project, &canon_name); + WiringPlan::Uv(Box::new(project), class) + } + PypiFlavor::Requirements => { + if let Err((code, detail)) = + preflight_requirements(project_root, &canon_name, version).await + { + return VendorOutcome::Refused { code, detail }; + } + WiringPlan::Requirements + } + }; + + let dist = match locate_installed_dist(site_packages, raw_name, version).await { + Ok(d) => d, + Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + }; + let wheel_name = match wheel_file_name(&dist) { + Ok(n) => n, + Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + }; + let rel_wheel = format!("{uuid_dir_rel}/{wheel_name}"); + let dest = project_root.join(&uuid_dir_rel).join(&wheel_name); + + let built = build_patched_wheel( + base, + site_packages, + &dist, + record, + sources, + &dest, + dry_run, + force, + ) + .await; + let (result, artifact) = match built { + Ok(pair) => pair, + Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + }; + if dry_run || !result.success { + return VendorOutcome::Done { + result, + entry: None, + warnings, + }; + } + let Some(artifact) = artifact else { + // Defensive: success without an artifact would be a bug upstream. + let mut result = result; + result.success = false; + result.error = Some("wheel build reported success without an artifact".to_string()); + return VendorOutcome::Done { + result, + entry: None, + warnings, + }; + }; + + // A compiled-extension wheel (cp311/manylinux tags) only installs on this + // platform, where the registry offered wheels for many — surface it. + let platform_locked = dist.wheel_tags.iter().any(|t| tag_is_platform_specific(t)); + if platform_locked { + let per_flavor = match flavor { + PypiFlavor::UvProject => { + "uv.lock now resolves it from this single-platform wheel only" + } + PypiFlavor::Requirements => { + "the requirements.txt path line installs on this platform only" + } + }; + warnings.push(VendorWarning::new( + "vendor_platform_locked", + format!( + "the vendored wheel for {canon_name}=={version} is platform-specific \ + ({}); {per_flavor}", + dist.wheel_tags.join(", ") + ), + )); + } + + // Marker: artifact-side breadcrumb in the uuid dir (informational only — + // sweep/verify key off state.json + the path uuid). Written before the + // wiring so lockfile edits stay the last mutation. + let mut vulns: Vec = record.vulnerabilities.keys().cloned().collect(); + vulns.sort(); + let marker = VendorMarker { + schema_version: 1, + purl: base.to_string(), + patch_uuid: record.uuid.clone(), + ecosystem: "pypi".to_string(), + vulnerabilities: vulns, + vendored_at: vendored_at.to_string(), + }; + if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { + let _ = tokio::fs::remove_dir_all(project_root.join(&uuid_dir_rel)).await; + let mut result = result; + result.success = false; + result.error = Some(format!("cannot write vendor marker: {e}")); + return VendorOutcome::Done { + result, + entry: None, + warnings, + }; + } + + // Wiring LAST. On failure the wheel artifact is swept back out so a + // failed vendor leaves no committed residue. + let wired = match plan { + WiringPlan::Uv(project, class) => wire_uv( + &project, + project_root, + &canon_name, + version, + &rel_wheel, + &wheel_name, + &artifact.sha256_hex, + class, + ) + .await + .map(|(wiring, meta)| (wiring, Some(meta))), + WiringPlan::Requirements => wire_requirements( + project_root, + &canon_name, + version, + &rel_wheel, + &artifact.sha256_hex, + ) + .await + .map(|wiring| (wiring, None)), + }; + let (wiring, uv_meta) = match wired { + Ok(pair) => pair, + Err((code, detail)) => { + let _ = tokio::fs::remove_dir_all(project_root.join(&uuid_dir_rel)).await; + let mut result = result; + result.success = false; + result.error = Some(format!("{code}: {detail}")); + return VendorOutcome::Done { + result, + entry: None, + warnings, + }; + } + }; + + let entry = VendorEntry { + ecosystem: "pypi".to_string(), + base_purl: base.to_string(), + uuid: record.uuid.clone(), + artifact: VendorArtifact { + path: rel_wheel, + sha256: artifact.sha256_hex, + size: Some(artifact.size), + platform_locked: platform_locked.then_some(true), + }, + wiring, + lock: None, + took_over_go_patches: false, + flavor: Some(flavor.as_str().to_string()), + uv: uv_meta, + }; + VendorOutcome::Done { + result, + entry: Some(entry), + warnings, + } +} + +/// Revert one pypi vendor entry: reverse the wiring per flavor, then remove +/// the artifact uuid dir (validated path only — never a path taken on faith +/// from state.json). +pub async fn revert_pypi(entry: &VendorEntry, project_root: &Path, dry_run: bool) -> RevertOutcome { + let mut outcome = match entry.flavor.as_deref() { + Some("uv") => revert_uv(entry, project_root, dry_run).await, + Some("requirements") => revert_requirements(entry, project_root, dry_run).await, + other => { + return RevertOutcome::failed(format!( + "unknown pypi vendor flavor {other:?}; cannot revert" + )) + } + }; + if !outcome.success || dry_run { + return outcome; + } + // SECURITY: entry.uuid comes from the committed, tamper-able state.json + // and names a directory for DELETION. Re-validate through the canonical + // uuid grammar; on failure warn and keep the dir (fail-closed). + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("pypi", &entry.uuid) else { + outcome.warnings.push(VendorWarning::new( + "vendor_unsafe_uuid", + format!( + "refusing to delete an artifact dir for non-canonical uuid {:?}", + entry.uuid + ), + )); + return outcome; + }; + match tokio::fs::remove_dir_all(project_root.join(&uuid_dir_rel)).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => outcome.warnings.push(VendorWarning::new( + "vendor_artifact_remove_failed", + format!("could not remove {uuid_dir_rel}: {e}"), + )), + } + outcome +} + +/// Platform-specific iff the tag triple binds an ABI or platform — `cp311- +/// none-any` is merely version-bound, `*-cp311-*` / `*-manylinux*` lock the +/// artifact to this machine's platform. +fn tag_is_platform_specific(tag: &str) -> bool { + let parts: Vec<&str> = tag.split('-').collect(); + match parts.as_slice() { + [_py, abi, plat] => *abi != "none" || *plat != "any", + // Malformed tags can't prove portability — claim platform-locked. + _ => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; + use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use sha2::Digest as _; + use std::collections::HashMap; + use std::path::PathBuf; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const ORIG: &[u8] = b"class Six:\n pass\n"; + const PATCHED: &[u8] = b"class Six:\n pass\n# SOCKET-PATCH-MARKER\n"; + + async fn touch(root: &Path, name: &str, content: &str) { + tokio::fs::write(root.join(name), content).await.unwrap(); + } + + #[tokio::test] + async fn flavor_routing_table_all_six_rules() { + // 1. uv.lock wins outright. + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "uv.lock", "version = 1\n").await; + touch(tmp.path(), "requirements.txt", "six==1.16.0\n").await; + assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap(), PypiFlavor::UvProject); + + // 2. [tool.uv] without a lock (and no requirements fallback). + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "pyproject.toml", "[project]\nname = \"x\"\n\n[tool.uv]\ndev = true\n").await; + let err = detect_pypi_flavor(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_uv_no_lockfile"); + assert!(err.1.contains("uv lock")); + assert!(err.1.contains("socket-patch setup")); + + // ...but WITH requirements.txt present the pip flavor still serves. + touch(tmp.path(), "requirements.txt", "six==1.16.0\n").await; + assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap(), PypiFlavor::Requirements); + + // 3. Pipenv / Poetry / PDM markers refuse (file and table forms). + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "Pipfile", "").await; + touch(tmp.path(), "requirements.txt", "six==1.16.0\n").await; + assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap_err().0, "pypi_pipenv_unsupported"); + + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "poetry.lock", "").await; + assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap_err().0, "pypi_poetry_unsupported"); + + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "pyproject.toml", "[tool.poetry]\nname = \"x\"\n").await; + assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap_err().0, "pypi_poetry_unsupported"); + + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "pdm.lock", "").await; + assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap_err().0, "pypi_pdm_unsupported"); + + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "pyproject.toml", "[tool.pdm]\n").await; + assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap_err().0, "pypi_pdm_unsupported"); + + // 4. requirements.txt at the root. + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "requirements.txt", "six==1.16.0\n").await; + assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap(), PypiFlavor::Requirements); + + // 5. a lone pyproject. + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "pyproject.toml", "[project]\nname = \"x\"\n").await; + assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap_err().0, "pypi_pyproject_only"); + + // 6. nothing at all. + let tmp = tempfile::tempdir().unwrap(); + let err = detect_pypi_flavor(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_no_requirements"); + assert!(err.1.contains("socket-patch setup")); + } + + #[test] + fn table_probe_is_header_anchored() { + assert!(has_table("[tool.uv]\n", "tool.uv")); + assert!(has_table("[tool.uv.sources]\n", "tool.uv")); + assert!(has_table("[ tool.uv ] # padded\n", "tool.uv")); + assert!(!has_table("# [tool.uv]\nx = \"[tool.uv]\"\n", "tool.uv")); + assert!(!has_table("[tool.uvloop]\n", "tool.uv")); + } + + struct E2eFixture { + _tmp: tempfile::TempDir, + root: PathBuf, + site_packages: PathBuf, + blobs: PathBuf, + record: PatchRecord, + } + + /// A requirements-flavor project: requirements.txt at the root, a + /// six-like install in a venv-ish site-packages, and a blob store. + async fn e2e_fixture() -> E2eFixture { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().to_path_buf(); + touch(&root, "requirements.txt", "six==1.16.0\n").await; + let sp = root.join(".venv/lib/python3.12/site-packages"); + let di = sp.join("six-1.16.0.dist-info"); + tokio::fs::create_dir_all(&di).await.unwrap(); + tokio::fs::write(sp.join("six.py"), ORIG).await.unwrap(); + tokio::fs::write( + di.join("METADATA"), + "Metadata-Version: 2.1\nName: six\nVersion: 1.16.0\n\nbody\n", + ) + .await + .unwrap(); + tokio::fs::write( + di.join("WHEEL"), + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py2-none-any\nTag: py3-none-any\n", + ) + .await + .unwrap(); + tokio::fs::write( + di.join("RECORD"), + "six.py,sha256=AAAA,20\nsix-1.16.0.dist-info/METADATA,,\nsix-1.16.0.dist-info/WHEEL,,\nsix-1.16.0.dist-info/RECORD,,\n", + ) + .await + .unwrap(); + let blobs = root.join("blob-store"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + tokio::fs::write(blobs.join(compute_git_sha256_from_bytes(PATCHED)), PATCHED) + .await + .unwrap(); + let mut files = HashMap::new(); + files.insert( + "six.py".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(ORIG), + after_hash: compute_git_sha256_from_bytes(PATCHED), + }, + ); + let record = PatchRecord { + uuid: UUID.to_string(), + exported_at: String::new(), + files, + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }; + E2eFixture { + _tmp: tmp, + root, + site_packages: sp, + blobs, + record, + } + } + + #[tokio::test] + async fn end_to_end_requirements_vendor_and_revert() { + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let outcome = vendor_pypi( + // Qualified variant purl: the base must be derived internally. + "pkg:pypi/six@1.16.0?artifact_id=abc123", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + ) + .await; + let VendorOutcome::Done { + result, + entry, + warnings, + } = outcome + else { + panic!("expected Done, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("entry must be present on success"); + + // Entry shape. + assert_eq!(entry.ecosystem, "pypi"); + assert_eq!(entry.base_purl, "pkg:pypi/six@1.16.0"); + assert_eq!(entry.uuid, UUID); + assert_eq!(entry.flavor.as_deref(), Some("requirements")); + assert!(entry.uv.is_none()); + let wheel_rel = format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl"); + assert_eq!(entry.artifact.path, wheel_rel); + // py2.py3-none-any is portable — no platform lock, no warning. + assert_eq!(entry.artifact.platform_locked, None); + assert!(warnings.iter().all(|w| w.code != "vendor_platform_locked")); + + // The wheel exists at the uuid path with the recorded hash + size. + let wheel_bytes = tokio::fs::read(fx.root.join(&wheel_rel)).await.unwrap(); + assert_eq!(entry.artifact.size, Some(wheel_bytes.len() as u64)); + assert_eq!( + entry.artifact.sha256, + hex::encode(sha2::Sha256::digest(&wheel_bytes)) + ); + + // The requirements line was rewritten with that exact hash. + let req = tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(); + assert_eq!( + req, + format!( + "./{wheel_rel} --hash=sha256:{} # socket-patch vendor: six==1.16.0\n", + entry.artifact.sha256 + ) + ); + assert_eq!(entry.wiring.len(), 1); + assert_eq!(entry.wiring[0].kind, "requirements_line"); + + // The marker breadcrumb sits next to the wheel. + let marker_text = tokio::fs::read_to_string( + fx.root + .join(format!(".socket/vendor/pypi/{UUID}")) + .join(VENDOR_MARKER_FILE), + ) + .await + .unwrap(); + assert!(marker_text.contains("pkg:pypi/six@1.16.0")); + assert!(marker_text.contains(UUID)); + + // The installed site-packages tree was never touched. + assert_eq!( + tokio::fs::read(fx.site_packages.join("six.py")).await.unwrap(), + ORIG + ); + + // Revert: requirements restored, artifact dir removed. + let reverted = revert_pypi(&entry, &fx.root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert!(reverted.warnings.is_empty(), "{:?}", reverted.warnings); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("requirements.txt")).await.unwrap(), + "six==1.16.0\n" + ); + assert!(!fx.root.join(format!(".socket/vendor/pypi/{UUID}")).exists()); + } + + #[tokio::test] + async fn uuid_traversal_is_refused_before_any_write() { + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let mut record = fx.record.clone(); + record.uuid = "../../../../tmp/evil".to_string(); + let outcome = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + ) + .await; + let VendorOutcome::Refused { code, .. } = outcome else { + panic!("expected Refused, got {outcome:?}"); + }; + assert_eq!(code, "vendor_unsafe_uuid"); + assert!(!fx.root.join(".socket").exists(), "nothing may be written"); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("requirements.txt")).await.unwrap(), + "six==1.16.0\n" + ); + } + + #[tokio::test] + async fn dry_run_writes_nothing() { + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let outcome = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + true, + false, + ) + .await; + let VendorOutcome::Done { result, entry, .. } = outcome else { + panic!("expected Done, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none(), "dry run yields no entry to persist"); + assert!(!fx.root.join(".socket").exists()); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("requirements.txt")).await.unwrap(), + "six==1.16.0\n" + ); + } + + #[tokio::test] + async fn requirements_refusal_happens_before_artifact_build() { + let fx = e2e_fixture().await; + touch(&fx.root, "requirements.txt", "six>=1.0\n").await; + let sources = PatchSources::blobs_only(&fx.blobs); + let outcome = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + ) + .await; + let VendorOutcome::Refused { code, .. } = outcome else { + panic!("expected Refused, got {outcome:?}"); + }; + assert_eq!(code, "pypi_requirement_not_pinned"); + assert!( + !fx.root.join(".socket").exists(), + "pre-flight refusal must precede the wheel build" + ); + } + + #[tokio::test] + async fn platform_specific_tags_set_platform_locked_and_warn() { + let fx = e2e_fixture().await; + // Make the installed dist a cp312/manylinux wheel. + tokio::fs::write( + fx.site_packages.join("six-1.16.0.dist-info/WHEEL"), + "Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: cp312-cp312-manylinux_2_17_x86_64\n", + ) + .await + .unwrap(); + let sources = PatchSources::blobs_only(&fx.blobs); + let outcome = vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + ) + .await; + let VendorOutcome::Done { + result, + entry, + warnings, + } = outcome + else { + panic!("expected Done, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + assert_eq!(entry.artifact.platform_locked, Some(true)); + assert!(entry + .artifact + .path + .ends_with("six-1.16.0-cp312-cp312-manylinux_2_17_x86_64.whl")); + assert!( + warnings.iter().any(|w| w.code == "vendor_platform_locked"), + "{warnings:?}" + ); + } + + #[test] + fn platform_specific_tag_detection() { + assert!(!tag_is_platform_specific("py3-none-any")); + assert!(!tag_is_platform_specific("cp311-none-any")); + assert!(tag_is_platform_specific("cp311-cp311-manylinux_2_17_x86_64")); + assert!(tag_is_platform_specific("py3-none-macosx_11_0_arm64")); + assert!(tag_is_platform_specific("py3-abi3-any")); + assert!(tag_is_platform_specific("garbage")); + } + + #[tokio::test] + async fn revert_unknown_flavor_fails_closed() { + let fx = e2e_fixture().await; + let entry = VendorEntry { + ecosystem: "pypi".into(), + base_purl: "pkg:pypi/six@1.16.0".into(), + uuid: UUID.into(), + artifact: VendorArtifact { + path: format!(".socket/vendor/pypi/{UUID}/x.whl"), + sha256: String::new(), + size: None, + platform_locked: None, + }, + wiring: vec![], + lock: None, + took_over_go_patches: false, + flavor: Some("mystery".into()), + uv: None, + }; + let outcome = revert_pypi(&entry, &fx.root, false).await; + assert!(!outcome.success); + assert!(outcome.error.unwrap().contains("mystery")); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs index ed2edb3..c77a6dd 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs @@ -1 +1,989 @@ -//! (stub — implementation lands with its backend phase) +//! requirements.txt wiring (pip & `uv pip`). +//! +//! The spike-verified line shape is +//! `./ --hash=sha256:[ ; ] # socket-patch vendor: ==`: +//! both pip 26 and uv 0.11 accept the bare relative path (resolved against +//! the INVOKING CWD, never the requirements-file dir — hence the documented +//! root-only constraint), enforce the `--hash` pin (implicitly: any +//! `--hash` on any line turns hash-checking on), strip the trailing comment, +//! and genuinely EVALUATE a `; marker` on a path line — so an environment +//! marker is carried over from the replaced pin instead of refused. +//! +//! Logical-line model: physical lines join on a trailing `\`; comments start +//! at a `#` preceded by whitespace (or column 0) outside that. The dominant +//! newline style is preserved (mirroring `pth_hook/edit.rs`). + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::fs::atomic_write_bytes; + +use super::state::{VendorEntry, WiringAction, WiringRecord}; +use super::{RevertOutcome, VendorWarning}; + +/// Classification of the target package within the requirements tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PinSearch { + /// A clean `name==version` pin (no extras). `line_start` / `line_count` + /// span the PHYSICAL lines (0-based) of the first matching logical line. + Exact { + line_start: usize, + line_count: usize, + /// Always `None` today — pins WITH extras classify as [`PinSearch::Extras`]; + /// the field is kept so the span type can carry them if extras + /// rewriting is ever supported. + extras: Option, + /// The environment marker verbatim (text after `;`), to carry over. + marker: Option, + /// The pin carries `--hash` options (informational; the rewrite + /// always emits a fresh `--hash`). + hashed: bool, + }, + /// The pin names the package with extras (`requests[socks]==…`) — a path + /// line cannot express extras, so the vendor refuses. + Extras, + /// The package is named but not exactly `==version`-pinned (range + /// specifier, bare name, or a pin to a different version). + Range, + /// The package is not named in this file. + Absent, +} + +/// Find the target pin in one file's content. Precedence is fail-closed: +/// any extras occurrence wins over any non-pin occurrence wins over a clean +/// exact pin — a file that names the package ambiguously is never rewritten. +pub fn find_pin(content: &str, canon_name: &str, version: &str) -> PinSearch { + let mut found_extras = false; + let mut found_range = false; + let mut exact: Option = None; + for ll in logical_lines(content) { + let Some(req) = parse_requirement_line(&ll.text) else { + continue; + }; + if canonicalize_pypi_name(&req.name) != canon_name { + continue; + } + if req.extras.is_some() { + found_extras = true; + continue; + } + let spec_no_ws: String = req.specifier.chars().filter(|c| !c.is_whitespace()).collect(); + if spec_no_ws == format!("=={version}") { + if exact.is_none() { + exact = Some(PinSearch::Exact { + line_start: ll.start, + line_count: ll.physical.len(), + extras: None, + marker: req.marker, + hashed: req.hashed, + }); + } + } else { + found_range = true; + } + } + if found_extras { + return PinSearch::Extras; + } + if found_range { + return PinSearch::Range; + } + exact.unwrap_or(PinSearch::Absent) +} + +/// Pre-flight the wiring without writing — the orchestrator runs this before +/// building the wheel so every refusal happens with the tree byte-untouched. +pub(super) async fn preflight_requirements( + root: &Path, + canon_name: &str, + version: &str, +) -> Result<(), (&'static str, String)> { + plan_requirements(root, canon_name, version, "", "").await.map(|_| ()) +} + +/// Rewrite every exact pin across the root `requirements.txt` and its `-r` +/// includes (or append a managed transitive line at the root EOF when the +/// package is absent). Returns the wiring records in application order. +pub async fn wire_requirements( + root: &Path, + canon_name: &str, + version: &str, + rel_wheel: &str, + wheel_sha256_hex: &str, +) -> Result, (&'static str, String)> { + let plan = plan_requirements(root, canon_name, version, rel_wheel, wheel_sha256_hex).await?; + let mut wiring = Vec::new(); + for file in &plan { + atomic_write_bytes(&root.join(&file.rel), file.new_content.as_bytes()) + .await + .map_err(|e| { + ( + "pypi_requirements_write_failed", + format!("cannot write {}: {e}", file.rel), + ) + })?; + wiring.extend(file.records.iter().cloned()); + } + Ok(wiring) +} + +/// Reverse the wiring: splice the recorded original physical lines back over +/// each vendor line (or delete an appended line). Lines that no longer match +/// what vendor wrote are left alone with `vendor_revert_line_drifted`; any +/// surviving reference to the vendored uuid dir afterwards raises +/// `vendor_revert_residual_reference`. +pub async fn revert_requirements( + entry: &VendorEntry, + root: &Path, + dry_run: bool, +) -> RevertOutcome { + let mut warnings: Vec = Vec::new(); + + // Group records per file, preserving application order within each. + let mut files: Vec = Vec::new(); + for rec in &entry.wiring { + if !files.contains(&rec.file) { + files.push(rec.file.clone()); + } + } + + let mut reverted: Vec<(String, String)> = Vec::new(); + for file in &files { + let path = root.join(file); + let content = match tokio::fs::read_to_string(&path).await { + Ok(c) => c, + Err(e) => { + return RevertOutcome::failed(format!("cannot read {file}: {e}")); + } + }; + let nl = newline_of(&content); + let had_trailing_newline = content.ends_with('\n'); + let mut lines: Vec = content.lines().map(str::to_string).collect(); + + // Reverse order = bottom-up matching, so identical vendor lines pair + // with their own originals (records were emitted top-down). + for rec in entry.wiring.iter().rev().filter(|r| &r.file == file) { + let Some(new_line) = rec.new.as_ref().and_then(serde_json::Value::as_str) else { + warnings.push(drift_warning(file, rec)); + continue; + }; + let Some(idx) = lines.iter().rposition(|l| l.trim() == new_line.trim()) else { + warnings.push(drift_warning(file, rec)); + continue; + }; + match rec.action { + WiringAction::Added => { + lines.remove(idx); + } + WiringAction::Rewritten => { + let originals: Vec = rec + .original + .as_ref() + .and_then(serde_json::Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + lines.splice(idx..idx + 1, originals); + } + } + } + + let mut new_content = lines.join(nl); + if had_trailing_newline && !new_content.is_empty() { + new_content.push_str(nl); + } + reverted.push((file.clone(), new_content)); + } + + if !dry_run { + for (file, content) in &reverted { + if let Err(e) = atomic_write_bytes(&root.join(file), content.as_bytes()).await { + return RevertOutcome { + success: false, + warnings, + error: Some(format!("cannot write {file}: {e}")), + }; + } + } + } + + // Residual-reference sweep over the reverted contents: a leftover line + // pointing at the (about to be deleted) uuid dir would break installs. + let needle = format!(".socket/vendor/pypi/{}", entry.uuid); + for (file, content) in &reverted { + if content.contains(&needle) { + warnings.push(VendorWarning::new( + "vendor_revert_residual_reference", + format!("{file} still references {needle} after revert"), + )); + } + } + + RevertOutcome { + success: true, + warnings, + error: None, + } +} + +fn drift_warning(file: &str, rec: &WiringRecord) -> VendorWarning { + VendorWarning::new( + "vendor_revert_line_drifted", + format!( + "{file}: the vendor line for {:?} changed since vendoring; left untouched", + rec.key + ), + ) +} + +// ── planning ───────────────────────────────────────────────────────────── + +struct PlannedFile { + /// Root-relative, forward-slashed path. + rel: String, + new_content: String, + records: Vec, +} + +/// One reachable requirements file. +struct ReqFile { + rel: String, + content: String, + /// In-root files may be edited; out-of-root includes are read-only + /// (their pins refuse the vendor instead). + editable: bool, +} + +/// Compute the full edit set (or refuse). Pure read — no writes happen here. +async fn plan_requirements( + root: &Path, + canon_name: &str, + version: &str, + rel_wheel: &str, + wheel_sha256_hex: &str, +) -> Result, (&'static str, String)> { + let files = collect_requirements_files(root).await?; + let mut planned: Vec = Vec::new(); + let mut rewrote_any = false; + + for file in &files { + match find_pin(&file.content, canon_name, version) { + PinSearch::Extras => { + return Err(( + "pypi_extras_unsupported", + format!( + "{}: the {canon_name} pin declares extras, which a vendored wheel path \ + line cannot express; remove the extras or use the `socket-patch setup` \ + .pth install hook instead", + file.rel + ), + )); + } + PinSearch::Range => { + return Err(( + "pypi_requirement_not_pinned", + format!( + "{}: {canon_name} is not pinned to =={version}; pin it exactly or use \ + the `socket-patch setup` .pth install hook instead", + file.rel + ), + )); + } + PinSearch::Absent => continue, + PinSearch::Exact { .. } => {} + } + if !file.editable { + // SECURITY/scope: an include outside the project root cannot be + // edited by a committable vendor flow; rewriting only the in-root + // copy would leave pip a duplicate requirement. Fail closed. + return Err(( + "pypi_requirements_outside_root", + format!( + "{}: {canon_name} is pinned in a requirements include outside the project \ + root, which vendor cannot edit; inline it or use the `socket-patch setup` \ + .pth install hook instead", + file.rel + ), + )); + } + + // Rewrite EVERY exact-pin occurrence in this file, bottom-up so the + // recorded spans (against the original content) stay valid. + let mut spans: Vec<(usize, usize, Option)> = Vec::new(); + for ll in logical_lines(&file.content) { + let Some(req) = parse_requirement_line(&ll.text) else { + continue; + }; + if canonicalize_pypi_name(&req.name) != canon_name || req.extras.is_some() { + continue; + } + let spec: String = req.specifier.chars().filter(|c| !c.is_whitespace()).collect(); + if spec == format!("=={version}") { + spans.push((ll.start, ll.physical.len(), req.marker)); + } + } + if spans.is_empty() { + continue; + } + let nl = newline_of(&file.content); + let original_lines: Vec = file.content.lines().map(str::to_string).collect(); + let mut lines = original_lines.clone(); + let mut records = Vec::new(); + for (start, count, marker) in spans.iter().rev() { + let line = vendor_line(rel_wheel, wheel_sha256_hex, canon_name, version, marker, false); + let replaced: Vec = original_lines[*start..*start + *count].to_vec(); + lines.splice(*start..*start + *count, [line.clone()]); + records.push(WiringRecord { + file: file.rel.clone(), + kind: "requirements_line".to_string(), + action: WiringAction::Rewritten, + key: Some(format!("{}:{}", file.rel, start + 1)), + original: Some(serde_json::Value::Array( + replaced.into_iter().map(serde_json::Value::String).collect(), + )), + new: Some(serde_json::Value::String(line)), + }); + } + records.reverse(); // application order = top-down + let mut new_content = lines.join(nl); + if file.content.ends_with('\n') && !new_content.is_empty() { + new_content.push_str(nl); + } + planned.push(PlannedFile { + rel: file.rel.clone(), + new_content, + records, + }); + rewrote_any = true; + } + + if !rewrote_any { + // Transitive: append a managed line at the ROOT file's EOF. pip + // treats it as one more requirement; the resolver folds it into the + // graph exactly like the spike's mixed-requirements run. + let root_file = files + .first() + .expect("collect_requirements_files always yields the root file first"); + let line = vendor_line(rel_wheel, wheel_sha256_hex, canon_name, version, &None, true); + let nl = newline_of(&root_file.content); + let mut new_content = root_file.content.clone(); + if !new_content.is_empty() && !new_content.ends_with('\n') { + new_content.push_str(nl); + } + new_content.push_str(&line); + new_content.push_str(nl); + planned.push(PlannedFile { + rel: root_file.rel.clone(), + new_content, + records: vec![WiringRecord { + file: root_file.rel.clone(), + kind: "requirements_line".to_string(), + action: WiringAction::Added, + key: Some(format!("{}:eof", root_file.rel)), + original: None, + new: Some(serde_json::Value::String(line)), + }], + }); + } + Ok(planned) +} + +/// The committed vendor line. `transitive` adds the `(transitive)` note so a +/// reader knows the line was appended (no pin was replaced). +fn vendor_line( + rel_wheel: &str, + sha256_hex: &str, + canon_name: &str, + version: &str, + marker: &Option, + transitive: bool, +) -> String { + let marker_part = marker + .as_ref() + .map(|m| format!(" ; {m}")) + .unwrap_or_default(); + let note = if transitive { " (transitive)" } else { "" }; + format!( + "./{rel_wheel} --hash=sha256:{sha256_hex}{marker_part} # socket-patch vendor: {canon_name}=={version}{note}" + ) +} + +/// Walk the root `requirements.txt` plus its `-r`/`--requirement` includes +/// (depth-first, resolved against the INCLUDING file's directory, visited-set +/// cycle guard). `-c` constraints files are never followed — they may not +/// introduce requirements, so a pin there is pip's problem, not ours, and we +/// must never edit them. The root file is always element 0. +async fn collect_requirements_files( + root: &Path, +) -> Result, (&'static str, String)> { + let mut out: Vec = Vec::new(); + let mut visited: HashSet = HashSet::new(); + let mut stack: Vec<(String, PathBuf)> = vec![( + "requirements.txt".to_string(), + root.join("requirements.txt"), + )]; + while let Some((rel, path)) = stack.pop() { + if !visited.insert(rel.clone()) { + continue; + } + let Ok(content) = tokio::fs::read_to_string(&path).await else { + if out.is_empty() { + return Err(( + "pypi_no_requirements", + format!("cannot read {}", path.display()), + )); + } + // A broken include is pip's error to report; vendor just can't + // see inside it. Skip. + continue; + }; + let editable = !rel.starts_with("../"); + let include_dir = match rel.rfind('/') { + Some(i) => rel[..i].to_string(), + None => String::new(), + }; + for ll in logical_lines(&content) { + let Some(target) = include_target(&ll.text) else { + continue; + }; + let joined = if include_dir.is_empty() { + target.to_string() + } else { + format!("{include_dir}/{target}") + }; + let normalized = normalize_rel_path(&joined); + stack.push((normalized.clone(), root.join(&normalized))); + } + out.push(ReqFile { + rel, + content, + editable, + }); + } + // Depth-first stack order put the root last among pushes; restore "root + // first" deterministically. + out.sort_by_key(|f| f.rel != "requirements.txt"); + Ok(out) +} + +/// The `-r`/`--requirement` include target of a logical line, if any. +fn include_target(text: &str) -> Option<&str> { + let code = strip_comment(text).trim(); + if let Some(rest) = code.strip_prefix("--requirement=") { + return Some(rest.trim()).filter(|s| !s.is_empty()); + } + let mut tokens = code.split_whitespace(); + match tokens.next() { + Some("-r") | Some("--requirement") => tokens.next(), + _ => None, + } +} + +/// Lexically normalize a relative path (`a/../b` → `b`); escapes above the +/// root keep their `../` prefix so the caller can spot out-of-root includes. +fn normalize_rel_path(path: &str) -> String { + let mut stack: Vec<&str> = Vec::new(); + let mut leading_parents = 0usize; + let normalized = path.replace('\\', "/"); + for comp in normalized.split('/') { + match comp { + "" | "." => {} + ".." => { + if stack.is_empty() { + leading_parents += 1; + } else { + stack.pop(); + } + } + other => stack.push(other), + } + } + let mut out = String::new(); + for _ in 0..leading_parents { + out.push_str("../"); + } + out.push_str(&stack.join("/")); + out +} + +// ── logical-line lexer ─────────────────────────────────────────────────── + +struct LogicalLine { + /// 0-based index of the first physical line. + start: usize, + /// The raw physical lines (no newlines, no `\r`). + physical: Vec, + /// Continuation-joined text (comments NOT yet stripped). + text: String, +} + +fn logical_lines(content: &str) -> Vec { + let lines: Vec<&str> = content.lines().collect(); + let mut out = Vec::new(); + let mut i = 0; + while i < lines.len() { + let start = i; + let mut physical = vec![lines[i].to_string()]; + while lines[i].trim_end().ends_with('\\') && i + 1 < lines.len() { + i += 1; + physical.push(lines[i].to_string()); + } + let mut text = String::new(); + for (k, pl) in physical.iter().enumerate() { + if k + 1 < physical.len() { + // pip's join: the backslash and the newline vanish. + text.push_str(pl.trim_end().strip_suffix('\\').unwrap_or(pl)); + } else { + text.push_str(pl); + } + } + out.push(LogicalLine { + start, + physical, + text, + }); + i += 1; + } + out +} + +/// Cut a trailing comment: `#` at column 0 or preceded by whitespace +/// (`--hash=sha256:ab#cd` is NOT a comment — no preceding whitespace). +fn strip_comment(text: &str) -> &str { + let bytes = text.as_bytes(); + for (i, &b) in bytes.iter().enumerate() { + if b == b'#' && (i == 0 || bytes[i - 1].is_ascii_whitespace()) { + return &text[..i]; + } + } + text +} + +struct ParsedRequirement { + name: String, + extras: Option, + specifier: String, + marker: Option, + hashed: bool, +} + +/// Parse one logical line as a requirement; `None` for blank lines, option +/// lines (`-r`, `--index-url`, …) and path/URL lines (no leading name). +fn parse_requirement_line(text: &str) -> Option { + let code = strip_comment(text).trim(); + if code.is_empty() || code.starts_with('-') { + return None; + } + // Per-line `--hash` options come after the requirement (and marker). + let (req_part, hashed) = match code.find(" --hash") { + Some(i) => (code[..i].trim_end(), true), + None => (code, false), + }; + // The environment marker is everything after the first `;` (specifiers + // and names cannot contain one), carried VERBATIM for the rewrite. + let (req_part, marker) = match req_part.find(';') { + Some(i) => ( + req_part[..i].trim_end(), + Some(req_part[i + 1..].trim().to_string()).filter(|m| !m.is_empty()), + ), + None => (req_part, None), + }; + // PEP 508 name: must start alphanumeric. + let name_end = req_part + .char_indices() + .find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) + .map(|(i, _)| i) + .unwrap_or(req_part.len()); + if name_end == 0 || !req_part.starts_with(|c: char| c.is_ascii_alphanumeric()) { + return None; + } + let name = req_part[..name_end].to_string(); + let mut rest = req_part[name_end..].trim_start(); + let mut extras = None; + if let Some(stripped) = rest.strip_prefix('[') { + let close = stripped.find(']')?; + extras = Some(stripped[..close].trim().to_string()); + rest = stripped[close + 1..].trim_start(); + } + Some(ParsedRequirement { + name, + extras, + specifier: rest.trim().to_string(), + marker, + hashed, + }) +} + +/// The file's dominant newline style (mirrors `pth_hook/edit.rs`). +fn newline_of(content: &str) -> &'static str { + if content.contains("\r\n") { + "\r\n" + } else { + "\n" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::patch::vendor::state::VendorArtifact; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const REL_WHEEL: &str = + ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"; + const SHA: &str = "f75f0d4e2f0a4d29b8d3f3a87b8d6cbe9a1c1f95d97d4a92f51e1b04b6a3c9aa"; + + fn expected_line() -> String { + format!("./{REL_WHEEL} --hash=sha256:{SHA} # socket-patch vendor: six==1.16.0") + } + + async fn write_root(content: &str) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("requirements.txt"), content) + .await + .unwrap(); + tmp + } + + async fn read_root(root: &Path) -> String { + tokio::fs::read_to_string(root.join("requirements.txt")) + .await + .unwrap() + } + + fn entry_for(wiring: Vec) -> VendorEntry { + VendorEntry { + ecosystem: "pypi".into(), + base_purl: "pkg:pypi/six@1.16.0".into(), + uuid: UUID.into(), + artifact: VendorArtifact { + path: REL_WHEEL.into(), + sha256: SHA.into(), + size: Some(11053), + platform_locked: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + flavor: Some("requirements".into()), + uv: None, + } + } + + // ── lexer ──────────────────────────────────────────────────────────── + + #[test] + fn lexer_joins_continuations_and_strips_comments_correctly() { + let lines = logical_lines("six==1.16.0 \\\n --hash=sha256:abc\nrequests\n"); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].start, 0); + assert_eq!(lines[0].physical.len(), 2); + assert_eq!(lines[0].text, "six==1.16.0 --hash=sha256:abc"); + assert_eq!(lines[1].start, 2); + + // Comment rules: whitespace-preceded `#` (or column 0) only. + assert_eq!(strip_comment("six==1.0 # pinned"), "six==1.0 "); + assert_eq!(strip_comment("# whole line"), ""); + assert_eq!( + strip_comment("x --hash=sha256:ab#cd"), + "x --hash=sha256:ab#cd", + "a # without preceding whitespace is data, not a comment" + ); + } + + #[test] + fn find_pin_classifies_every_shape() { + // Clean pin, with marker + hash flags captured. + let found = find_pin( + "requests==2.31.0\nsix==1.16.0 ; python_version >= \"3.8\" --hash=sha256:abc\n", + "six", + "1.16.0", + ); + match found { + PinSearch::Exact { + line_start, + line_count, + extras, + marker, + hashed, + } => { + assert_eq!(line_start, 1); + assert_eq!(line_count, 1); + assert_eq!(extras, None); + assert_eq!(marker.as_deref(), Some("python_version >= \"3.8\"")); + assert!(hashed); + } + other => panic!("expected Exact, got {other:?}"), + } + + // Spaces around the operator still count as the pin. + assert!(matches!( + find_pin("six == 1.16.0\n", "six", "1.16.0"), + PinSearch::Exact { .. } + )); + // PEP 503 name canonicalization on both sides. + assert!(matches!( + find_pin("Six_Pkg==1.0\n", "six-pkg", "1.0"), + PinSearch::Exact { .. } + )); + assert_eq!(find_pin("six[socks]==1.16.0\n", "six", "1.16.0"), PinSearch::Extras); + assert_eq!(find_pin("six>=1.0\n", "six", "1.16.0"), PinSearch::Range); + assert_eq!(find_pin("six\n", "six", "1.16.0"), PinSearch::Range); + // Pinned, but to a different version than the one being vendored. + assert_eq!(find_pin("six==1.15.0\n", "six", "1.16.0"), PinSearch::Range); + assert_eq!(find_pin("requests==2.31.0\n", "six", "1.16.0"), PinSearch::Absent); + // `sixty` must not match `six` (name boundary). + assert_eq!(find_pin("sixty==1.16.0\n", "six", "1.16.0"), PinSearch::Absent); + // Comment-only and option lines are not requirements. + assert_eq!(find_pin("# six==1.16.0\n-r other.txt\n", "six", "1.16.0"), PinSearch::Absent); + } + + // ── wiring ─────────────────────────────────────────────────────────── + + #[tokio::test] + async fn rewrites_plain_pin_and_round_trips_revert_byte_identically() { + let original = "requests==2.31.0\nsix==1.16.0\n"; + let tmp = write_root(original).await; + let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + assert_eq!( + read_root(tmp.path()).await, + format!("requests==2.31.0\n{}\n", expected_line()) + ); + assert_eq!(wiring.len(), 1); + assert_eq!(wiring[0].kind, "requirements_line"); + assert_eq!(wiring[0].action, WiringAction::Rewritten); + + let outcome = revert_requirements(&entry_for(wiring), tmp.path(), false).await; + assert!(outcome.success); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(read_root(tmp.path()).await, original, "byte-identical revert"); + } + + #[tokio::test] + async fn rewrites_hash_pinned_continuation_and_preserves_crlf() { + // A hash-pinned requirement spanning two physical lines, CRLF file. + let original = "requests==2.31.0\r\nsix==1.16.0 \\\r\n --hash=sha256:000111\r\n"; + let tmp = write_root(original).await; + let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + let written = read_root(tmp.path()).await; + assert_eq!( + written, + format!("requests==2.31.0\r\n{}\r\n", expected_line()), + "both physical lines replaced; CRLF preserved" + ); + // The record keeps BOTH original physical lines for the revert. + let originals = wiring[0].original.as_ref().unwrap().as_array().unwrap(); + assert_eq!(originals.len(), 2); + + let outcome = revert_requirements(&entry_for(wiring), tmp.path(), false).await; + assert!(outcome.success); + assert_eq!(read_root(tmp.path()).await, original); + } + + #[tokio::test] + async fn marker_is_carried_over_verbatim() { + let tmp = write_root("six==1.16.0 ; python_version >= \"3.8\"\n").await; + wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + assert_eq!( + read_root(tmp.path()).await, + format!( + "./{REL_WHEEL} --hash=sha256:{SHA} ; python_version >= \"3.8\" # socket-patch vendor: six==1.16.0\n" + ) + ); + } + + #[tokio::test] + async fn absent_package_appends_managed_transitive_line() { + let tmp = write_root("python-dateutil==2.8.2\n").await; + let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + assert_eq!( + read_root(tmp.path()).await, + format!( + "python-dateutil==2.8.2\n./{REL_WHEEL} --hash=sha256:{SHA} # socket-patch vendor: six==1.16.0 (transitive)\n" + ) + ); + assert_eq!(wiring[0].action, WiringAction::Added); + + // Revert deletes the appended line. + let outcome = revert_requirements(&entry_for(wiring), tmp.path(), false).await; + assert!(outcome.success); + assert_eq!(read_root(tmp.path()).await, "python-dateutil==2.8.2\n"); + } + + #[tokio::test] + async fn follows_dash_r_includes_and_rewrites_pin_in_place() { + let tmp = write_root("-r deps/pinned.txt\nrequests==2.31.0\n").await; + tokio::fs::create_dir_all(tmp.path().join("deps")).await.unwrap(); + tokio::fs::write(tmp.path().join("deps/pinned.txt"), "six==1.16.0\n") + .await + .unwrap(); + let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + // The pin is rewritten where it lives; the root stays untouched (no + // duplicate appended). + assert_eq!(read_root(tmp.path()).await, "-r deps/pinned.txt\nrequests==2.31.0\n"); + assert_eq!( + tokio::fs::read_to_string(tmp.path().join("deps/pinned.txt")).await.unwrap(), + format!("{}\n", expected_line()) + ); + assert_eq!(wiring.len(), 1); + assert_eq!(wiring[0].file, "deps/pinned.txt"); + + let outcome = revert_requirements(&entry_for(wiring), tmp.path(), false).await; + assert!(outcome.success); + assert_eq!( + tokio::fs::read_to_string(tmp.path().join("deps/pinned.txt")).await.unwrap(), + "six==1.16.0\n" + ); + } + + #[tokio::test] + async fn include_cycles_terminate() { + let tmp = write_root("-r a.txt\nsix==1.16.0\n").await; + tokio::fs::write(tmp.path().join("a.txt"), "-r requirements.txt\n") + .await + .unwrap(); + let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + assert_eq!(wiring.len(), 1, "cycle guard must not duplicate the rewrite"); + } + + #[tokio::test] + async fn extras_and_range_pins_refuse() { + let tmp = write_root("six[socks]==1.16.0\n").await; + let err = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_extras_unsupported"); + + let tmp = write_root("six~=1.16\n").await; + let err = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_requirement_not_pinned"); + // Refusals leave the file untouched. + assert_eq!(read_root(tmp.path()).await, "six~=1.16\n"); + } + + #[tokio::test] + async fn pin_in_out_of_root_include_refuses() { + let outer = tempfile::tempdir().unwrap(); + let root = outer.path().join("project"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(root.join("requirements.txt"), "-r ../shared.txt\n") + .await + .unwrap(); + tokio::fs::write(outer.path().join("shared.txt"), "six==1.16.0\n") + .await + .unwrap(); + let err = wire_requirements(&root, "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_requirements_outside_root"); + // The out-of-root file is never edited. + assert_eq!( + tokio::fs::read_to_string(outer.path().join("shared.txt")).await.unwrap(), + "six==1.16.0\n" + ); + } + + // ── revert edge cases ──────────────────────────────────────────────── + + #[tokio::test] + async fn revert_warns_on_drifted_line_and_leaves_it() { + let tmp = write_root("six==1.16.0\n").await; + let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + // Drift: the user edited the vendor line (changed the hash). + let drifted = read_root(tmp.path()).await.replace(SHA, &"0".repeat(64)); + tokio::fs::write(tmp.path().join("requirements.txt"), &drifted) + .await + .unwrap(); + + let outcome = revert_requirements(&entry_for(wiring), tmp.path(), false).await; + assert!(outcome.success); + assert!(outcome + .warnings + .iter() + .any(|w| w.code == "vendor_revert_line_drifted")); + // A drifted edit (still referencing the uuid dir) also raises the + // residual-reference warning. + assert!(outcome + .warnings + .iter() + .any(|w| w.code == "vendor_revert_residual_reference")); + assert_eq!(read_root(tmp.path()).await, drifted, "drifted line left alone"); + } + + #[tokio::test] + async fn revert_warns_on_residual_reference_from_other_lines() { + let tmp = write_root("six==1.16.0\n").await; + let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + // A second, manually-added reference to the vendored wheel. + let mut content = read_root(tmp.path()).await; + content.push_str(&format!("./{REL_WHEEL}\n")); + tokio::fs::write(tmp.path().join("requirements.txt"), &content) + .await + .unwrap(); + + let outcome = revert_requirements(&entry_for(wiring), tmp.path(), false).await; + assert!(outcome.success); + assert!(outcome + .warnings + .iter() + .any(|w| w.code == "vendor_revert_residual_reference")); + // The managed line was reverted; the manual line survives. + assert_eq!( + read_root(tmp.path()).await, + format!("six==1.16.0\n./{REL_WHEEL}\n") + ); + } + + #[tokio::test] + async fn revert_dry_run_writes_nothing() { + let tmp = write_root("six==1.16.0\n").await; + let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + let wired = read_root(tmp.path()).await; + let outcome = revert_requirements(&entry_for(wiring), tmp.path(), true).await; + assert!(outcome.success); + assert_eq!(read_root(tmp.path()).await, wired, "dry run must not write"); + } + + /// Two identical pins: each record must splice back its OWN original + /// (bottom-up matching), and both lines must be rewritten. + #[tokio::test] + async fn multiple_occurrences_all_rewritten_and_reverted() { + let original = "six==1.16.0\nrequests==2.31.0\nsix==1.16.0 # twice\n"; + let tmp = write_root(original).await; + let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + assert_eq!(wiring.len(), 2); + let written = read_root(tmp.path()).await; + assert_eq!(written.matches(&expected_line()).count(), 2); + + let outcome = revert_requirements(&entry_for(wiring), tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!(read_root(tmp.path()).await, original); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs b/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs index ed2edb3..731851c 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs @@ -1 +1,1783 @@ -//! (stub — implementation lands with its backend phase) +//! uv-project wiring: paired `pyproject.toml` + `uv.lock` surgery. +//! +//! The pairing is load-bearing (spike claims 7/9): a `[tool.uv.sources]` +//! entry for a package uv doesn't consider declared is SILENTLY ignored, and +//! a path-source lock without the pyproject entry is silently rewritten back +//! to the registry by a plain `uv sync`. So vendor always writes BOTH — the +//! pyproject sources entry (plus, for transitive deps, a +//! `[tool.uv] override-dependencies` pin, which sources DO apply to — claim +//! 8) and the lock's `[[package]]` / `requires-dist` / `[manifest]` fragments. +//! +//! All lock edits are targeted text surgery rather than a TOML re-serialize: +//! the spike proved a surgical edit reproduces uv's own serializer output +//! byte-identically (claim 2), which keeps `uv lock --check` green and the +//! committed diff minimal. The `spikes/uv/` fixtures pin the exact shapes. + +use std::ops::Range; +use std::path::Path; + +use toml_edit::{DocumentMut, Item, Table, Value}; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::fs::atomic_write_bytes; + +use super::state::{UvMeta, VendorEntry, WiringAction, WiringRecord}; +use super::{RevertOutcome, VendorWarning}; + +/// Highest uv.lock `revision` the spike fixtures were generated with. A newer +/// revision is a warning, not a refusal: the shapes we rewrite have been +/// stable across revisions and `uv lock --check` will catch a real mismatch. +const HIGHEST_TESTED_LOCK_REVISION: u64 = 3; + +/// How the target package is declared, which picks the wiring strategy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UvDepClass { + /// Declared in `project.dependencies` / `optional-dependencies` / + /// `dependency-groups` — a `[tool.uv.sources]` entry suffices. + Direct, + /// Not declared anywhere — wired via `[tool.uv] override-dependencies` + /// (sources apply to overrides; no promotion into project.dependencies). + Transitive, +} + +/// A loaded-and-guard-checked uv project pair. +#[derive(Debug)] +pub struct UvProject { + pub pyproject_text: String, + pub lock_text: String, + pub pyproject: DocumentMut, + pub lock: DocumentMut, + /// `[project] name` — identifies the root `[[package]]` whose + /// `requires-dist` carries the direct-dep specifier. + pub root_name: String, + /// uv.lock `revision` (diagnostics; recorded into [`UvMeta`]). + pub lock_revision: Option, + /// Non-fatal advisories raised during load (untested lock revision). + pub warnings: Vec, +} + +/// Read + parse the pair and run every project-level guard. Refuses before +/// ANY write — the orchestrator runs this (and the target guards) before the +/// wheel is even built, so a refusal leaves the tree byte-untouched. +pub async fn load_uv_project(root: &Path) -> Result { + let pyproject_text = tokio::fs::read_to_string(root.join("pyproject.toml")) + .await + .map_err(|e| { + ( + "pypi_uv_lock_parse_failed", + format!("cannot read pyproject.toml: {e}"), + ) + })?; + let lock_text = tokio::fs::read_to_string(root.join("uv.lock")) + .await + .map_err(|e| ("pypi_uv_lock_parse_failed", format!("cannot read uv.lock: {e}")))?; + let pyproject: DocumentMut = pyproject_text.parse().map_err(|e| { + ( + "pypi_uv_lock_parse_failed", + format!("pyproject.toml does not parse: {e}"), + ) + })?; + let lock: DocumentMut = lock_text.parse().map_err(|e| { + ( + "pypi_uv_lock_parse_failed", + format!("uv.lock does not parse: {e}"), + ) + })?; + + // Workspaces resolve all members into ONE shared lock whose fragments we + // have no fixtures for; refuse rather than guess (fail-closed). + if pyproject + .get("tool") + .and_then(|t| item_get(t, "uv")) + .and_then(|u| item_get(u, "workspace")) + .is_some() + { + return Err(( + "pypi_uv_workspace_unsupported", + "pyproject.toml declares [tool.uv.workspace]; vendoring uv workspaces is not \ + supported yet" + .to_string(), + )); + } + + let root_name = pyproject + .get("project") + .and_then(|p| item_get(p, "name")) + .and_then(Item::as_str) + .map(str::to_string) + .ok_or_else(|| { + ( + "pypi_uv_lock_root_missing", + "pyproject.toml has no [project] name; cannot identify the root package in \ + uv.lock" + .to_string(), + ) + })?; + + match lock.get("version").and_then(Item::as_integer) { + Some(1) => {} + other => { + return Err(( + "pypi_uv_lock_version_unsupported", + format!("uv.lock schema version {other:?} is not the supported version 1"), + )) + } + } + + // A `[manifest] members` list beyond the root is the lock-side workspace + // signal (single-project locks normally have no members at all). + if let Some(members) = lock + .get("manifest") + .and_then(|m| item_get(m, "members")) + .and_then(Item::as_array) + { + let canon_root = canonicalize_pypi_name(&root_name); + let extras: Vec<&str> = members + .iter() + .filter_map(Value::as_str) + .filter(|m| canonicalize_pypi_name(m) != canon_root) + .collect(); + if !extras.is_empty() { + return Err(( + "pypi_uv_workspace_unsupported", + format!( + "uv.lock [manifest] members lists workspace packages beyond the root: {}", + extras.join(", ") + ), + )); + } + } + + // PEP 621 dynamic dependencies are resolved by a build backend at lock + // time — there is no static dependency list to classify against. + if pyproject + .get("project") + .and_then(|p| item_get(p, "dynamic")) + .and_then(Item::as_array) + .is_some_and(|d| d.iter().filter_map(Value::as_str).any(|x| x == "dependencies")) + { + return Err(( + "pypi_uv_dynamic_dependencies", + "pyproject.toml declares dynamic = [\"dependencies\"]; vendor cannot classify the \ + dependency statically" + .to_string(), + )); + } + + if find_root_package_name(&lock).is_none() { + return Err(( + "pypi_uv_lock_root_missing", + "uv.lock has no root [[package]] (source virtual/editable \".\")".to_string(), + )); + } + + let lock_revision = lock + .get("revision") + .and_then(Item::as_integer) + .and_then(|i| u64::try_from(i).ok()); + let mut warnings = Vec::new(); + if let Some(rev) = lock_revision { + if rev > HIGHEST_TESTED_LOCK_REVISION { + warnings.push(VendorWarning::new( + "pypi_uv_lock_revision_untested", + format!( + "uv.lock revision {rev} is newer than the highest fixture-tested revision \ + {HIGHEST_TESTED_LOCK_REVISION}; verify with `uv lock --check` after vendoring" + ), + )); + } + } + + Ok(UvProject { + pyproject_text, + lock_text, + pyproject, + lock, + root_name, + lock_revision, + warnings, + }) +} + +/// Direct iff the package is named (PEP 508 name, canonicalized) anywhere in +/// `project.dependencies`, `project.optional-dependencies`, or the PEP 735 +/// `dependency-groups` — every surface `[tool.uv.sources]` applies to without +/// an override. +pub fn classify_dependency(p: &UvProject, canon_name: &str) -> UvDepClass { + let mut declared: Vec<&str> = Vec::new(); + if let Some(project) = p.pyproject.get("project") { + if let Some(deps) = item_get(project, "dependencies").and_then(Item::as_array) { + declared.extend(deps.iter().filter_map(Value::as_str)); + } + if let Some(optional) = + item_get(project, "optional-dependencies").and_then(Item::as_table_like) + { + for (_, item) in optional.iter() { + if let Some(arr) = item.as_array() { + declared.extend(arr.iter().filter_map(Value::as_str)); + } + } + } + } + if let Some(groups) = p.pyproject.get("dependency-groups").and_then(Item::as_table_like) { + for (_, item) in groups.iter() { + if let Some(arr) = item.as_array() { + // Non-string members are `{include-group = "..."}` includes; + // the included group's own array is already scanned above. + declared.extend(arr.iter().filter_map(Value::as_str)); + } + } + } + if declared + .iter() + .any(|spec| canonicalize_pypi_name(pep508_name(spec)) == canon_name) + { + UvDepClass::Direct + } else { + UvDepClass::Transitive + } +} + +/// Target-specific guards (also re-run by [`wire_uv`] right before writing). +/// Split out of [`load_uv_project`] because they need the target name; the +/// orchestrator runs them pre-flight so a refusal happens before the wheel +/// artifact is built. +pub(super) fn check_target_guards( + p: &UvProject, + canon_name: &str, +) -> Result<(), (&'static str, String)> { + // The same name at multiple versions/sources (platform forks) means one + // surgical [[package]] rewrite would mispin the other forks — refuse. + let units = p + .lock + .get("package") + .and_then(Item::as_array_of_tables) + .map(|pkgs| { + pkgs.iter() + .filter(|t| t.get("name").and_then(Item::as_str) == Some(canon_name)) + .count() + }) + .unwrap_or(0); + if units == 0 { + return Err(( + "pypi_uv_lock_package_missing", + format!("uv.lock has no [[package]] entry for {canon_name}; run `uv lock` first"), + )); + } + if units > 1 { + return Err(( + "pypi_uv_lock_forked_package", + format!( + "uv.lock resolves {canon_name} at multiple versions/sources (a forked \ + resolution); vendoring would mispin the other forks" + ), + )); + } + + // An existing sources entry would be silently shadowed/clobbered by ours. + if let Some(sources) = p + .pyproject + .get("tool") + .and_then(|t| item_get(t, "uv")) + .and_then(|u| item_get(u, "sources")) + .and_then(Item::as_table_like) + { + for (key, item) in sources.iter() { + if canonicalize_pypi_name(key) != canon_name { + continue; + } + let path = item + .as_value() + .and_then(Value::as_inline_table) + .and_then(|t| t.get("path")) + .and_then(Value::as_str) + .unwrap_or(""); + let detail = if path.contains(".socket/vendor/pypi/") { + format!( + "[tool.uv.sources] already routes {key} to a socket-patch vendored wheel; \ + run `socket-patch vendor --revert` before re-vendoring" + ) + } else { + format!( + "[tool.uv.sources] already declares a source for {key}; refusing to \ + overwrite a user-authored source" + ) + }; + return Err(("pypi_uv_source_already_exists", detail)); + } + } + + // A user override pins this package already; layering ours on top would + // change resolution behind the user's back. + if let Some(overrides) = p + .pyproject + .get("tool") + .and_then(|t| item_get(t, "uv")) + .and_then(|u| item_get(u, "override-dependencies")) + .and_then(Item::as_array) + { + for spec in overrides.iter().filter_map(Value::as_str) { + if canonicalize_pypi_name(pep508_name(spec)) == canon_name { + return Err(( + "pypi_uv_source_already_exists", + format!( + "[tool.uv] override-dependencies already pins {spec:?}; refusing to \ + stack a vendor override on a user override" + ), + )); + } + } + } + Ok(()) +} + +/// Wire the pair for the vendored wheel. Writes `pyproject.toml` FIRST, then +/// `uv.lock`; a failed lock write unwinds the pyproject from the recorded +/// original so the pair is never left half-wired (either half alone is a +/// silent no-op or a silent revert — spike claims 7/9). +#[allow(clippy::too_many_arguments)] +pub async fn wire_uv( + p: &UvProject, + root: &Path, + canon_name: &str, + version: &str, + rel_wheel: &str, + wheel_file_name: &str, + wheel_sha256_hex: &str, + class: UvDepClass, +) -> Result<(Vec, UvMeta), (&'static str, String)> { + check_target_guards(p, canon_name)?; + let mut wiring: Vec = Vec::new(); + + // ── pyproject.toml (computed in memory; committed before the lock) ──── + let mut doc = p.pyproject.clone(); + let had_uv_table = doc + .get("tool") + .and_then(|t| item_get(t, "uv")) + .is_some(); + let created_sources_table = doc + .get("tool") + .and_then(|t| item_get(t, "uv")) + .and_then(|u| item_get(u, "sources")) + .is_none(); + + if class == UvDepClass::Transitive { + let spec = format!("{canon_name}=={version}"); + let uv_table = ensure_table(&mut doc, &["tool", "uv"])?; + if !had_uv_table { + uv_table.set_implicit(false); + uv_table.decor_mut().set_prefix("\n"); + } + match uv_table.get("override-dependencies") { + None => { + let value: Value = format!("[\"{spec}\"]").parse().map_err(|e| { + ( + "pypi_uv_lock_parse_failed", + format!("cannot build override value: {e}"), + ) + })?; + uv_table.insert("override-dependencies", Item::Value(value.decorated(" ", ""))); + wiring.push(record( + "pyproject.toml", + "uv_override", + WiringAction::Added, + canon_name, + None, + format!("override-dependencies = [\"{spec}\"]"), + )); + } + Some(existing) => { + let old_text = existing + .as_value() + .map(|v| v.to_string().trim().to_string()) + .ok_or_else(|| { + ( + "pypi_uv_lock_parse_failed", + "pyproject.toml [tool.uv] override-dependencies is not a value" + .to_string(), + ) + })?; + let arr = uv_table + .get_mut("override-dependencies") + .and_then(Item::as_array_mut) + .ok_or_else(|| { + ( + "pypi_uv_lock_parse_failed", + "pyproject.toml [tool.uv] override-dependencies is not an array" + .to_string(), + ) + })?; + arr.push_formatted(Value::from(spec.clone()).decorated(" ", "")); + let new_text = uv_table + .get("override-dependencies") + .and_then(Item::as_value) + .map(|v| v.to_string().trim().to_string()) + .unwrap_or_default(); + wiring.push(record( + "pyproject.toml", + "uv_override", + WiringAction::Rewritten, + canon_name, + Some(old_text), + new_text, + )); + } + } + } + + let sources_table = ensure_table(&mut doc, &["tool", "uv", "sources"])?; + if created_sources_table { + sources_table.set_implicit(false); + sources_table.decor_mut().set_prefix("\n"); + } + let sources_value: Value = format!("{{ path = \"{rel_wheel}\" }}").parse().map_err(|e| { + ( + "pypi_uv_lock_parse_failed", + format!("cannot build sources value: {e}"), + ) + })?; + sources_table.insert(canon_name, Item::Value(sources_value.decorated(" ", ""))); + wiring.push(record( + "pyproject.toml", + "uv_sources_entry", + WiringAction::Added, + canon_name, + None, + format!("{canon_name} = {{ path = \"{rel_wheel}\" }}"), + )); + let new_pyproject = doc.to_string(); + + // ── uv.lock text surgery (fully computed before any write) ──────────── + let mut new_lock = p.lock_text.clone(); + + let (old_unit, new_unit) = rewrite_target_package_unit( + &new_lock, + canon_name, + version, + rel_wheel, + wheel_file_name, + wheel_sha256_hex, + )?; + new_lock = new_lock.replacen(&old_unit, &new_unit, 1); + wiring.push(record( + "uv.lock", + "uv_lock_package", + WiringAction::Rewritten, + canon_name, + Some(old_unit), + new_unit, + )); + + let mut original_specifier: Option = None; + match class { + UvDepClass::Direct => { + let edit = rewrite_requires_dist_entry(&new_lock, canon_name, rel_wheel)?; + new_lock.replace_range(edit.span, &edit.new_entry); + original_specifier = edit.specifier; + wiring.push(record( + "uv.lock", + "uv_lock_requires_dist", + WiringAction::Rewritten, + canon_name, + Some(edit.old_entry), + edit.new_entry, + )); + } + UvDepClass::Transitive => { + let (rec, text) = add_manifest_override(&new_lock, canon_name, rel_wheel)?; + new_lock = text; + wiring.push(rec); + } + } + + // ── commit: pyproject first, then the lock; unwind on lock failure ──── + let pyproject_path = root.join("pyproject.toml"); + atomic_write_bytes(&pyproject_path, new_pyproject.as_bytes()) + .await + .map_err(|e| { + ( + "pypi_uv_write_failed", + format!("cannot write pyproject.toml: {e}"), + ) + })?; + if let Err(e) = atomic_write_bytes(&root.join("uv.lock"), new_lock.as_bytes()).await { + // Unwind so a sources-bearing pyproject is never paired with the old + // registry lock (that combo makes `uv lock --check` fail and plain + // `uv sync` rewrite the lock under the user). + let _ = atomic_write_bytes(&pyproject_path, p.pyproject_text.as_bytes()).await; + return Err(( + "pypi_uv_write_failed", + format!("cannot write uv.lock: {e}; pyproject.toml was restored"), + )); + } + + let meta = UvMeta { + dep_class: match class { + UvDepClass::Direct => "direct".to_string(), + UvDepClass::Transitive => "override".to_string(), + }, + original_specifier, + created_sources_table, + lock_revision: p.lock_revision, + }; + Ok((wiring, meta)) +} + +/// Reverse the wiring: restore verbatim originals (or delete added fragments) +/// in reverse application order. A live fragment that no longer matches what +/// we wrote is left alone with a `vendor_lock_entry_drifted` warning — revert +/// must never clobber third-party edits. +pub async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { + let pyproject_path = root.join("pyproject.toml"); + let lock_path = root.join("uv.lock"); + let mut pyproject_text = match tokio::fs::read_to_string(&pyproject_path).await { + Ok(t) => t, + Err(e) => return RevertOutcome::failed(format!("cannot read pyproject.toml: {e}")), + }; + let mut lock_text = match tokio::fs::read_to_string(&lock_path).await { + Ok(t) => t, + Err(e) => return RevertOutcome::failed(format!("cannot read uv.lock: {e}")), + }; + let mut warnings: Vec = Vec::new(); + let created_sources_table = entry + .uv + .as_ref() + .map(|m| m.created_sources_table) + .unwrap_or(false); + + for rec in entry.wiring.iter().rev() { + let new_text = rec.new.as_ref().and_then(serde_json::Value::as_str); + let original_text = rec.original.as_ref().and_then(serde_json::Value::as_str); + let drifted = |what: &str| { + VendorWarning::new( + "vendor_lock_entry_drifted", + format!("{what} fragment for {:?} changed since vendoring; left untouched", rec.key), + ) + }; + match rec.kind.as_str() { + "uv_lock_package" | "uv_lock_requires_dist" => { + let (Some(new), Some(orig)) = (new_text, original_text) else { + warnings.push(drifted("uv.lock")); + continue; + }; + if lock_text.contains(new) { + lock_text = lock_text.replacen(new, orig, 1); + } else { + warnings.push(drifted("uv.lock")); + } + } + "uv_lock_manifest_overrides" => match rec.action { + WiringAction::Added => { + let Some(new) = new_text else { + warnings.push(drifted("uv.lock")); + continue; + }; + // A created [manifest] section was inserted with a blank + // separator line; a created overrides key is one line. + let removed = if new.starts_with("[manifest]") { + remove_substring(&lock_text, &format!("{new}\n\n")) + } else { + remove_substring(&lock_text, &format!("{new}\n")) + }; + match removed { + Some(t) => lock_text = t, + None => warnings.push(drifted("uv.lock")), + } + } + WiringAction::Rewritten => { + let (Some(new), Some(orig)) = (new_text, original_text) else { + warnings.push(drifted("uv.lock")); + continue; + }; + if lock_text.contains(new) { + lock_text = lock_text.replacen(new, orig, 1); + } else { + warnings.push(drifted("uv.lock")); + } + } + }, + "uv_sources_entry" => { + let Some(new) = new_text else { + warnings.push(drifted("pyproject.toml")); + continue; + }; + match remove_exact_line(&pyproject_text, new) { + Some(t) => { + pyproject_text = t; + if created_sources_table { + pyproject_text = + remove_table_if_empty(&pyproject_text, "[tool.uv.sources]"); + } + } + None => warnings.push(drifted("pyproject.toml")), + } + } + "uv_override" => match rec.action { + WiringAction::Added => { + let Some(new) = new_text else { + warnings.push(drifted("pyproject.toml")); + continue; + }; + match remove_exact_line(&pyproject_text, new) { + Some(t) => { + // Drop a now-empty [tool.uv] only when we created + // the whole structure (the sources entry above + // was removed first — reverse order). + pyproject_text = remove_table_if_empty(&t, "[tool.uv]"); + } + None => warnings.push(drifted("pyproject.toml")), + } + } + WiringAction::Rewritten => { + let (Some(new), Some(orig)) = (new_text, original_text) else { + warnings.push(drifted("pyproject.toml")); + continue; + }; + if pyproject_text.contains(new) { + pyproject_text = pyproject_text.replacen(new, orig, 1); + } else { + warnings.push(drifted("pyproject.toml")); + } + } + }, + other => warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("unknown uv wiring kind {other:?}; skipped"), + )), + } + } + + if !dry_run { + // Reverse of the wire order: the lock first, then the pyproject. + if let Err(e) = atomic_write_bytes(&lock_path, lock_text.as_bytes()).await { + return RevertOutcome { + success: false, + warnings, + error: Some(format!("cannot write uv.lock: {e}")), + }; + } + if let Err(e) = atomic_write_bytes(&pyproject_path, pyproject_text.as_bytes()).await { + return RevertOutcome { + success: false, + warnings, + error: Some(format!("cannot write pyproject.toml: {e}")), + }; + } + } + RevertOutcome { + success: true, + warnings, + error: None, + } +} + +// ── helpers ────────────────────────────────────────────────────────────── + +fn record( + file: &str, + kind: &str, + action: WiringAction, + key: &str, + original: Option, + new: String, +) -> WiringRecord { + WiringRecord { + file: file.to_string(), + kind: kind.to_string(), + action, + key: Some(key.to_string()), + original: original.map(serde_json::Value::String), + new: Some(serde_json::Value::String(new)), + } +} + +fn item_get<'a>(item: &'a Item, key: &str) -> Option<&'a Item> { + item.as_table_like().and_then(|t| t.get(key)) +} + +/// Leading PEP 508 distribution name of a dependency spec. +fn pep508_name(spec: &str) -> &str { + let s = spec.trim_start(); + let end = s + .char_indices() + .find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) + .map(|(i, _)| i) + .unwrap_or(s.len()); + &s[..end] +} + +/// Walk/create the table chain, marking CREATED intermediates implicit so +/// they never render stray `[tool]` headers. +fn ensure_table<'a>( + doc: &'a mut DocumentMut, + path: &[&str], +) -> Result<&'a mut Table, (&'static str, String)> { + let mut table: &mut Table = doc.as_table_mut(); + for key in path { + if !table.contains_key(key) { + let mut t = Table::new(); + t.set_implicit(true); + table.insert(key, Item::Table(t)); + } + table = table + .get_mut(key) + .and_then(Item::as_table_mut) + .ok_or_else(|| { + ( + "pypi_uv_lock_parse_failed", + format!("pyproject.toml [{}] is not a standard table", path.join(".")), + ) + })?; + } + Ok(table) +} + +fn find_root_package_name(lock: &DocumentMut) -> Option { + let packages = lock.get("package")?.as_array_of_tables()?; + for table in packages { + let Some(source) = table.get("source").and_then(Item::as_inline_table) else { + continue; + }; + let is_root = ["virtual", "editable"] + .iter() + .any(|k| source.get(k).and_then(Value::as_str) == Some(".")); + if is_root { + return table.get("name").and_then(Item::as_str).map(str::to_string); + } + } + None +} + +/// `(byte_offset, line_without_newline)` for every line (locks are LF). +fn line_index(text: &str) -> Vec<(usize, &str)> { + let mut out = Vec::new(); + let mut offset = 0; + for seg in text.split_inclusive('\n') { + let line = seg.strip_suffix('\n').unwrap_or(seg); + out.push((offset, line)); + offset += seg.len(); + } + out +} + +/// Byte span of the `[[package]]` unit (header through last non-blank line, +/// including `[package.*]` sub-tables) matching `predicate`. +fn find_unit_span(text: &str, predicate: F) -> Option> +where + F: Fn(&[&str]) -> bool, +{ + let index = line_index(text); + let starts: Vec = index + .iter() + .enumerate() + .filter(|(_, (_, l))| l.trim_end() == "[[package]]") + .map(|(i, _)| i) + .collect(); + for (k, &s) in starts.iter().enumerate() { + let hard_end = starts.get(k + 1).copied().unwrap_or(index.len()); + let mut e = hard_end; + while e > s && index[e - 1].1.trim().is_empty() { + e -= 1; + } + let lines: Vec<&str> = index[s..e].iter().map(|(_, l)| *l).collect(); + if predicate(&lines) { + let start = index[s].0; + let end = index[e - 1].0 + index[e - 1].1.len(); + return Some(start..end); + } + } + None +} + +fn unit_has_name(lines: &[&str], canon: &str) -> bool { + lines + .iter() + .find_map(|l| l.strip_prefix("name = ")) + .map(|r| r.trim().trim_matches('"')) + == Some(canon) +} + +fn unit_is_root(lines: &[&str]) -> bool { + lines.iter().any(|l| { + l.starts_with("source = {") + && (l.contains("virtual = \".\"") || l.contains("editable = \".\"")) + }) +} + +/// Rewrite the target `[[package]]` unit to the path-wheel shape proven by +/// the fixtures: `source = { path = ... }`, `sdist` dropped, `wheels` becomes +/// the single `{ filename, hash }` element, `version` pinned to the vendored +/// version. Returns `(old_unit, new_unit)` verbatim for the wiring record. +fn rewrite_target_package_unit( + lock_text: &str, + canon: &str, + version: &str, + rel_wheel: &str, + wheel_file_name: &str, + wheel_sha256_hex: &str, +) -> Result<(String, String), (&'static str, String)> { + let span = find_unit_span(lock_text, |lines| unit_has_name(lines, canon)).ok_or_else(|| { + ( + "pypi_uv_lock_package_missing", + format!("uv.lock has no [[package]] entry for {canon}"), + ) + })?; + let old_unit = lock_text[span].to_string(); + let unit: Vec<&str> = old_unit.lines().collect(); + let wheels_lines = [ + "wheels = [".to_string(), + format!(" {{ filename = \"{wheel_file_name}\", hash = \"sha256:{wheel_sha256_hex}\" }},"), + "]".to_string(), + ]; + + let mut out: Vec = Vec::new(); + let mut wheels_done = false; + let mut i = 0; + while i < unit.len() { + let line = unit[i]; + if line.starts_with("version = ") { + out.push(format!("version = \"{version}\"")); + } else if line.starts_with("source = ") { + out.push(format!("source = {{ path = \"{rel_wheel}\" }}")); + } else if line.starts_with("sdist = ") { + // dropped: a path-wheel source has no sdist (fixture-pinned) + } else if line.starts_with("wheels = [") { + out.extend(wheels_lines.iter().cloned()); + wheels_done = true; + if !line.trim_end().ends_with(']') { + // skip the original multi-line array body + closing bracket + while i + 1 < unit.len() && unit[i + 1].trim() != "]" { + i += 1; + } + i += 1; + } + } else { + out.push(line.to_string()); + } + i += 1; + } + if !wheels_done { + // sdist-only lock entry: add the wheels array at the end of the + // [[package]] table itself, before any [package.*] sub-table. + let mut pos = out + .iter() + .position(|l| l.starts_with("[package.")) + .unwrap_or(out.len()); + while pos > 0 && out[pos - 1].trim().is_empty() { + pos -= 1; + } + out.splice(pos..pos, wheels_lines.iter().cloned()); + } + Ok((old_unit, out.join("\n"))) +} + +/// One planned requires-dist entry rewrite: the absolute byte span plus the +/// verbatim old/new entry texts and the captured specifier. +struct RequiresDistEdit { + span: Range, + old_entry: String, + new_entry: String, + specifier: Option, +} + +/// Find + transform the root package's `requires-dist` entry for `canon`: +/// `{ name = "x", specifier = "==v" }` → `{ name = "x", path = "" }` +/// (uv DROPS the specifier for path sources — recorded for revert). Returns +/// the absolute byte span so the caller splices by range, never by string +/// search (a bare `{ name = "x" }` entry would collide with `dependencies` +/// arrays elsewhere in the lock). +fn rewrite_requires_dist_entry( + lock_text: &str, + canon: &str, + rel_wheel: &str, +) -> Result { + let unit_span = find_unit_span(lock_text, unit_is_root).ok_or_else(|| { + ( + "pypi_uv_lock_root_missing", + "uv.lock has no root [[package]] (source virtual/editable \".\")".to_string(), + ) + })?; + let unit_start = unit_span.start; + let unit_text = &lock_text[unit_span]; + let rd_rel = unit_text.find("requires-dist = [").ok_or_else(|| { + ( + "pypi_uv_lock_root_missing", + "uv.lock root package has no [package.metadata] requires-dist".to_string(), + ) + })?; + let arr_open = rd_rel + "requires-dist = ".len(); + let arr_end = balanced_span(unit_text, arr_open, '[', ']').ok_or_else(|| { + ( + "pypi_uv_lock_parse_failed", + "uv.lock requires-dist array is unbalanced".to_string(), + ) + })?; + let array_text = &unit_text[arr_open..arr_end]; + let needle = format!("name = \"{canon}\""); + for (s, e) in top_level_brace_groups(array_text) { + let entry = &array_text[s..e]; + if !entry.contains(&needle) { + continue; + } + let (new_entry, specifier) = path_source_entry(entry, rel_wheel); + return Ok(RequiresDistEdit { + span: (unit_start + arr_open + s)..(unit_start + arr_open + e), + old_entry: entry.to_string(), + new_entry, + specifier, + }); + } + Err(( + "pypi_uv_lock_package_missing", + format!("uv.lock root requires-dist has no entry for {canon}"), + )) +} + +/// Build the path-source requires-dist entry from the registry one: keep +/// every other key (extras, markers) in place, drop `specifier`, append +/// `path` — matching uv's own serialization of a sources-path dep. +fn path_source_entry(old_entry: &str, rel_wheel: &str) -> (String, Option) { + let inner = old_entry + .trim() + .trim_start_matches('{') + .trim_end_matches('}'); + let mut kvs: Vec = Vec::new(); + let mut specifier = None; + for part in split_top_level_commas(inner) { + let part = part.trim(); + if part.is_empty() { + continue; + } + if let Some(value) = part.strip_prefix("specifier = ") { + specifier = Some(value.trim().trim_matches('"').to_string()); + continue; + } + kvs.push(part.to_string()); + } + kvs.push(format!("path = \"{rel_wheel}\"")); + (format!("{{ {} }}", kvs.join(", ")), specifier) +} + +/// Add/extend the lock `[manifest] overrides` for a transitive override. +/// Returns the wiring record and the new lock text. +fn add_manifest_override( + lock_text: &str, + canon: &str, + rel_wheel: &str, +) -> Result<(WiringRecord, String), (&'static str, String)> { + let element = format!("{{ name = \"{canon}\", path = \"{rel_wheel}\" }}"); + let index = line_index(lock_text); + let manifest_line = index + .iter() + .position(|(_, l)| l.trim_end() == "[manifest]"); + + let Some(h) = manifest_line else { + // No [manifest] yet: create it between the lock header and the first + // [[package]] (where uv itself emits it — fixture-pinned). + let first_pkg = index + .iter() + .find(|(_, l)| l.trim_end() == "[[package]]") + .map(|(off, _)| *off) + .ok_or_else(|| { + ( + "pypi_uv_lock_parse_failed", + "uv.lock has no [[package]] entries".to_string(), + ) + })?; + let section = format!("[manifest]\noverrides = [{element}]"); + let mut text = lock_text.to_string(); + text.insert_str(first_pkg, &format!("{section}\n\n")); + return Ok(( + record( + "uv.lock", + "uv_lock_manifest_overrides", + WiringAction::Added, + canon, + None, + section, + ), + text, + )); + }; + + // Section spans until the next top-level header. + let section_end_line = index[h + 1..] + .iter() + .position(|(_, l)| l.starts_with('[')) + .map(|i| h + 1 + i) + .unwrap_or(index.len()); + let section_start = index[h].0; + let section_end = index + .get(section_end_line) + .map(|(off, _)| *off) + .unwrap_or(lock_text.len()); + let section_text = &lock_text[section_start..section_end]; + + if let Some(ov_rel) = section_text.find("overrides = [") { + let arr_open = ov_rel + "overrides = ".len(); + let arr_end = balanced_span(section_text, arr_open, '[', ']').ok_or_else(|| { + ( + "pypi_uv_lock_parse_failed", + "uv.lock [manifest] overrides array is unbalanced".to_string(), + ) + })?; + let old_array = §ion_text[arr_open..arr_end]; + let new_array = if old_array.contains('\n') { + // multi-line: add an indented element before the closing bracket + let body = &old_array[..old_array.rfind(']').unwrap_or(old_array.len())]; + format!("{body} {element},\n]") + } else { + format!("{}, {element}]", &old_array[..old_array.len() - 1]) + }; + let mut text = lock_text.to_string(); + text.replace_range( + (section_start + arr_open)..(section_start + arr_end), + &new_array, + ); + return Ok(( + record( + "uv.lock", + "uv_lock_manifest_overrides", + WiringAction::Rewritten, + canon, + Some(old_array.to_string()), + new_array, + ), + text, + )); + } + + // [manifest] exists (e.g. members) but has no overrides yet: add the key + // right under the header. + let line = format!("overrides = [{element}]"); + let insert_at = index + .get(h + 1) + .map(|(off, _)| *off) + .unwrap_or(lock_text.len()); + let mut text = lock_text.to_string(); + text.insert_str(insert_at, &format!("{line}\n")); + Ok(( + record( + "uv.lock", + "uv_lock_manifest_overrides", + WiringAction::Added, + canon, + None, + line, + ), + text, + )) +} + +/// Exclusive end index of the bracket opened at `open_idx` (quote-aware; +/// TOML basic strings with backslash escapes). +fn balanced_span(text: &str, open_idx: usize, open: char, close: char) -> Option { + let mut depth = 0i32; + let mut in_str = false; + let mut escaped = false; + for (i, c) in text[open_idx..].char_indices() { + if in_str { + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_str = false; + } + continue; + } + if c == '"' { + in_str = true; + } else if c == open { + depth += 1; + } else if c == close { + depth -= 1; + if depth == 0 { + return Some(open_idx + i + c.len_utf8()); + } + } + } + None +} + +/// `(start, end)` of each top-level `{...}` group (quote-aware). +fn top_level_brace_groups(text: &str) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + let mut depth = 0i32; + let mut in_str = false; + let mut escaped = false; + let mut start = None; + for (i, c) in text.char_indices() { + if in_str { + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_str = false; + } + continue; + } + match c { + '"' => in_str = true, + '{' => { + if depth == 0 { + start = Some(i); + } + depth += 1; + } + '}' => { + depth -= 1; + if depth == 0 { + if let Some(s) = start.take() { + out.push((s, i + 1)); + } + } + } + _ => {} + } + } + out +} + +/// Split inline-table body on commas outside quotes/brackets/braces. +fn split_top_level_commas(text: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut depth = 0i32; + let mut in_str = false; + let mut escaped = false; + let mut start = 0; + for (i, c) in text.char_indices() { + if in_str { + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_str = false; + } + continue; + } + match c { + '"' => in_str = true, + '{' | '[' => depth += 1, + '}' | ']' => depth -= 1, + ',' if depth == 0 => { + out.push(&text[start..i]); + start = i + 1; + } + _ => {} + } + } + out.push(&text[start..]); + out +} + +/// Remove the first exact occurrence of `needle`; `None` when absent. +fn remove_substring(text: &str, needle: &str) -> Option { + let idx = text.find(needle)?; + let mut out = String::with_capacity(text.len() - needle.len()); + out.push_str(&text[..idx]); + out.push_str(&text[idx + needle.len()..]); + Some(out) +} + +/// Remove the first line that equals `line` exactly; `None` when absent. +fn remove_exact_line(text: &str, line: &str) -> Option { + let mut out: Vec<&str> = Vec::new(); + let mut removed = false; + for l in text.lines() { + if !removed && l == line { + removed = true; + continue; + } + out.push(l); + } + if !removed { + return None; + } + let mut joined = out.join("\n"); + if text.ends_with('\n') && !joined.is_empty() { + joined.push('\n'); + } + Some(joined) +} + +/// Drop a `[header]` whose section holds only blank lines, plus its +/// preceding blank separator. A non-empty section is left untouched. +fn remove_table_if_empty(text: &str, header: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + let Some(h) = lines.iter().position(|l| l.trim_end() == header) else { + return text.to_string(); + }; + let mut end = h + 1; + while end < lines.len() && !lines[end].starts_with('[') { + if !lines[end].trim().is_empty() { + return text.to_string(); + } + end += 1; + } + let mut start = h; + if start > 0 && lines[start - 1].trim().is_empty() { + start -= 1; + } + let mut out: Vec<&str> = Vec::with_capacity(lines.len()); + out.extend(&lines[..start]); + out.extend(&lines[end..]); + let mut joined = out.join("\n"); + if text.ends_with('\n') && !joined.is_empty() { + joined.push('\n'); + } + joined +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::patch::vendor::state::VendorArtifact; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const REL_WHEEL: &str = + ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"; + const WHEEL_NAME: &str = "six-1.16.0-py2.py3-none-any.whl"; + const WHEEL_SHA: &str = "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"; + + // ── fixture constants ────────────────────────────────────────────── + // Byte-exact copies of the uv-generated spikes/uv/ fixtures (uv 0.11.19, + // 2026-06-09). If these drift from the committed fixtures, the spike + // dirs are the source of truth. + + const DIRECT_REGISTRY_PYPROJECT: &str = r#"[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["six==1.16.0"] +"#; + + const DIRECT_PATH_PYPROJECT: &str = r#"[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["six==1.16.0"] + +[tool.uv.sources] +six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } +"#; + + const DIRECT_REGISTRY_LOCK: &str = r#"version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "six" }, +] + +[package.metadata] +requires-dist = [{ name = "six", specifier = "==1.16.0" }] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, +] +"#; + + const DIRECT_PATH_LOCK: &str = r#"version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "six" }, +] + +[package.metadata] +requires-dist = [{ name = "six", path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }] + +[[package]] +name = "six" +version = "1.16.0" +source = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } +wheels = [ + { filename = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" }, +] +"#; + + const TRANSITIVE_REGISTRY_PYPROJECT: &str = r#"[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["python-dateutil==2.8.2"] +"#; + + const OVERRIDE_TRANSITIVE_PYPROJECT: &str = r#"[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["python-dateutil==2.8.2"] + +[tool.uv] +override-dependencies = ["six==1.16.0"] + +[tool.uv.sources] +six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } +"#; + + const TRANSITIVE_REGISTRY_LOCK: &str = r#"version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "python-dateutil" }, +] + +[package.metadata] +requires-dist = [{ name = "python-dateutil", specifier = "==2.8.2" }] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324, upload-time = "2021-07-14T08:19:19.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702, upload-time = "2021-07-14T08:19:18.161Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] +"#; + + const OVERRIDE_TRANSITIVE_LOCK: &str = r#"version = 1 +revision = 3 +requires-python = ">=3.10" + +[manifest] +overrides = [{ name = "six", path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }] + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "python-dateutil" }, +] + +[package.metadata] +requires-dist = [{ name = "python-dateutil", specifier = "==2.8.2" }] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324, upload-time = "2021-07-14T08:19:19.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702, upload-time = "2021-07-14T08:19:18.161Z" }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } +wheels = [ + { filename = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" }, +] +"#; + + async fn write_pair(pyproject: &str, lock: &str) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("pyproject.toml"), pyproject) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("uv.lock"), lock).await.unwrap(); + tmp + } + + async fn read_pair(root: &Path) -> (String, String) { + ( + tokio::fs::read_to_string(root.join("pyproject.toml")).await.unwrap(), + tokio::fs::read_to_string(root.join("uv.lock")).await.unwrap(), + ) + } + + fn entry_for(wiring: Vec, meta: UvMeta) -> VendorEntry { + VendorEntry { + ecosystem: "pypi".into(), + base_purl: "pkg:pypi/six@1.16.0".into(), + uuid: UUID.into(), + artifact: VendorArtifact { + path: REL_WHEEL.into(), + sha256: WHEEL_SHA.into(), + size: Some(11053), + platform_locked: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + flavor: Some("uv".into()), + uv: Some(meta), + } + } + + /// The load-bearing oracle: wiring the direct-registry pair must produce + /// the uv-generated direct-path-wheel pair BYTE-IDENTICALLY. + #[tokio::test] + async fn direct_wiring_matches_fixture_byte_identically() { + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + assert!(p.warnings.is_empty()); + assert_eq!(classify_dependency(&p, "six"), UvDepClass::Direct); + + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UvDepClass::Direct, + ) + .await + .unwrap(); + + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, DIRECT_PATH_PYPROJECT, "pyproject.toml must byte-match uv's own output"); + assert_eq!(lock, DIRECT_PATH_LOCK, "uv.lock must byte-match uv's own output"); + + assert_eq!(meta.dep_class, "direct"); + assert_eq!(meta.original_specifier.as_deref(), Some("==1.16.0")); + assert!(meta.created_sources_table); + assert_eq!(meta.lock_revision, Some(3)); + let kinds: Vec<&str> = wiring.iter().map(|w| w.kind.as_str()).collect(); + assert_eq!( + kinds, + vec!["uv_sources_entry", "uv_lock_package", "uv_lock_requires_dist"] + ); + } + + /// Transitive deps wire via override-dependencies (spike claim 8), never + /// promotion — the result must byte-match the override-transitive pair, + /// including the lock's 1.17.0 → 1.16.0 version pin-down. + #[tokio::test] + async fn override_wiring_matches_fixture_byte_identically() { + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, TRANSITIVE_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + assert_eq!(classify_dependency(&p, "six"), UvDepClass::Transitive); + + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UvDepClass::Transitive, + ) + .await + .unwrap(); + + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, OVERRIDE_TRANSITIVE_PYPROJECT); + assert_eq!(lock, OVERRIDE_TRANSITIVE_LOCK); + + assert_eq!(meta.dep_class, "override"); + assert_eq!(meta.original_specifier, None); + assert!(meta.created_sources_table); + let kinds: Vec<&str> = wiring.iter().map(|w| w.kind.as_str()).collect(); + assert_eq!( + kinds, + vec![ + "uv_override", + "uv_sources_entry", + "uv_lock_package", + "uv_lock_manifest_overrides" + ] + ); + } + + #[tokio::test] + async fn guards_refuse_workspace_lock_version_fork_sources_and_dynamic() { + // [tool.uv.workspace] + let tmp = write_pair( + &format!("{DIRECT_REGISTRY_PYPROJECT}\n[tool.uv.workspace]\nmembers = [\"pkgs/*\"]\n"), + DIRECT_REGISTRY_LOCK, + ) + .await; + let err = load_uv_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_uv_workspace_unsupported"); + + // lock [manifest] members beyond the root + let tmp = write_pair( + DIRECT_REGISTRY_PYPROJECT, + &DIRECT_REGISTRY_LOCK.replace( + "requires-python = \">=3.10\"\n", + "requires-python = \">=3.10\"\n\n[manifest]\nmembers = [\n \"proj\",\n \"helper\",\n]\n", + ), + ) + .await; + let err = load_uv_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_uv_workspace_unsupported"); + + // lock version != 1 + let tmp = write_pair( + DIRECT_REGISTRY_PYPROJECT, + &DIRECT_REGISTRY_LOCK.replace("version = 1\n", "version = 2\n"), + ) + .await; + let err = load_uv_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_uv_lock_version_unsupported"); + + // unparseable lock + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, "version = [broken\n").await; + let err = load_uv_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_uv_lock_parse_failed"); + + // missing root [[package]] + let tmp = write_pair( + DIRECT_REGISTRY_PYPROJECT, + &DIRECT_REGISTRY_LOCK.replace("source = { virtual = \".\" }", "source = { registry = \"x\" }"), + ) + .await; + let err = load_uv_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_uv_lock_root_missing"); + + // dynamic dependencies + let tmp = write_pair( + &DIRECT_REGISTRY_PYPROJECT.replace( + "dependencies = [\"six==1.16.0\"]\n", + "dynamic = [\"dependencies\"]\n", + ), + DIRECT_REGISTRY_LOCK, + ) + .await; + let err = load_uv_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_uv_dynamic_dependencies"); + + // forked package (six at two versions) + let fork = format!( + "{DIRECT_REGISTRY_LOCK}\n[[package]]\nname = \"six\"\nversion = \"1.17.0\"\nsource = {{ registry = \"https://pypi.org/simple\" }}\n" + ); + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, &fork).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let err = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_lock_forked_package"); + + // target absent from the lock entirely + let tmp2 = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + let p2 = load_uv_project(tmp2.path()).await.unwrap(); + let err = wire_uv(&p2, tmp2.path(), "absent-pkg", "1.0.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Transitive) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_lock_package_missing"); + + // user-authored sources entry for the package + let tmp = write_pair( + &format!("{DIRECT_REGISTRY_PYPROJECT}\n[tool.uv.sources]\nsix = {{ path = \"../local/six\" }}\n"), + DIRECT_REGISTRY_LOCK, + ) + .await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let err = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_source_already_exists"); + assert!(err.1.contains("user-authored"), "{}", err.1); + + // an existing SOCKET source refuses too, pointing at --revert + let tmp = write_pair( + &format!("{DIRECT_REGISTRY_PYPROJECT}\n[tool.uv.sources]\nsix = {{ path = \"{REL_WHEEL}\" }}\n"), + DIRECT_REGISTRY_LOCK, + ) + .await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let err = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_source_already_exists"); + assert!(err.1.contains("--revert"), "{}", err.1); + + // a user override for the package + let tmp = write_pair( + &format!("{TRANSITIVE_REGISTRY_PYPROJECT}\n[tool.uv]\noverride-dependencies = [\"six==1.15.0\"]\n"), + TRANSITIVE_REGISTRY_LOCK, + ) + .await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let err = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Transitive) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_source_already_exists"); + } + + #[tokio::test] + async fn untested_lock_revision_is_a_warning_not_a_refusal() { + let tmp = write_pair( + DIRECT_REGISTRY_PYPROJECT, + &DIRECT_REGISTRY_LOCK.replace("revision = 3\n", "revision = 9\n"), + ) + .await; + let p = load_uv_project(tmp.path()).await.unwrap(); + assert_eq!(p.warnings.len(), 1); + assert_eq!(p.warnings[0].code, "pypi_uv_lock_revision_untested"); + assert_eq!(p.lock_revision, Some(9)); + } + + /// A failed lock write must unwind the already-written pyproject — a + /// sources entry without the lock pair is exactly the silent-failure + /// combo the spike warned about. + #[tokio::test] + async fn lock_write_failure_unwinds_pyproject() { + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + // Make the lock unwritable: a directory can't be renamed over. + tokio::fs::remove_file(tmp.path().join("uv.lock")).await.unwrap(); + tokio::fs::create_dir(tmp.path().join("uv.lock")).await.unwrap(); + + let err = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_write_failed"); + let pyproject = tokio::fs::read_to_string(tmp.path().join("pyproject.toml")) + .await + .unwrap(); + assert_eq!(pyproject, DIRECT_REGISTRY_PYPROJECT, "pyproject must be unwound"); + } + + #[tokio::test] + async fn revert_direct_restores_originals_byte_identically() { + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) + .await + .unwrap(); + let entry = entry_for(wiring, meta); + + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, DIRECT_REGISTRY_PYPROJECT, "requires-dist specifier restored"); + assert_eq!(lock, DIRECT_REGISTRY_LOCK); + } + + #[tokio::test] + async fn revert_override_restores_originals_byte_identically() { + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, TRANSITIVE_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Transitive) + .await + .unwrap(); + let entry = entry_for(wiring, meta); + + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, TRANSITIVE_REGISTRY_PYPROJECT, "[tool.uv] removed when created by vendor"); + assert_eq!(lock, TRANSITIVE_REGISTRY_LOCK, "[manifest] removed when created by vendor"); + } + + #[tokio::test] + async fn revert_dry_run_changes_nothing() { + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) + .await + .unwrap(); + let entry = entry_for(wiring, meta); + let (before_py, before_lock) = read_pair(tmp.path()).await; + + let outcome = revert_uv(&entry, tmp.path(), true).await; + assert!(outcome.success); + let (after_py, after_lock) = read_pair(tmp.path()).await; + assert_eq!(before_py, after_py); + assert_eq!(before_lock, after_lock); + } + + /// A third-party edit to a fragment we wrote must be left alone with a + /// drift warning — revert never clobbers what it can't positively match. + #[tokio::test] + async fn revert_warns_and_skips_on_drifted_lock_fragment() { + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) + .await + .unwrap(); + let entry = entry_for(wiring, meta); + + // Drift: someone re-hashed the vendored wheel entry. + let lock = tokio::fs::read_to_string(tmp.path().join("uv.lock")).await.unwrap(); + let drifted = lock.replace(WHEEL_SHA, &"0".repeat(64)); + tokio::fs::write(tmp.path().join("uv.lock"), &drifted).await.unwrap(); + + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success); + assert!( + outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + "{:?}", + outcome.warnings + ); + // The pyproject side (undrifted) was still reverted. + let (pyproject, _) = read_pair(tmp.path()).await; + assert_eq!(pyproject, DIRECT_REGISTRY_PYPROJECT); + } + + #[test] + fn pep508_name_extraction_handles_extras_and_specifiers() { + assert_eq!(pep508_name("six==1.16.0"), "six"); + assert_eq!(pep508_name("requests[socks]>=2.8"), "requests"); + assert_eq!(pep508_name("python-dateutil"), "python-dateutil"); + assert_eq!(pep508_name("My.Pkg_2 ; python_version > \"3\""), "My.Pkg_2"); + } + + #[test] + fn path_source_entry_preserves_extras_and_captures_specifier() { + let (new, spec) = + path_source_entry("{ name = \"six\", specifier = \"==1.16.0\" }", REL_WHEEL); + assert_eq!(new, format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}")); + assert_eq!(spec.as_deref(), Some("==1.16.0")); + + // extras + marker survive (uv keeps them on path-source entries); + // the embedded comma inside extras must not split the entry. + let (new, spec) = path_source_entry( + "{ name = \"x\", extras = [\"a\", \"b\"], specifier = \">=1\", marker = \"python_version >= \\\"3.9\\\"\" }", + REL_WHEEL, + ); + assert_eq!( + new, + format!( + "{{ name = \"x\", extras = [\"a\", \"b\"], marker = \"python_version >= \\\"3.9\\\"\", path = \"{REL_WHEEL}\" }}" + ) + ); + assert_eq!(spec.as_deref(), Some(">=1")); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs b/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs index ed2edb3..933159a 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs @@ -1 +1,1146 @@ -//! (stub — implementation lands with its backend phase) +//! Rebuild an installable wheel from the patched installed distribution. +//! +//! pypi vendoring cannot reuse a registry artifact: the patch applies to the +//! *installed* site-packages tree, so the committable `.socket/vendor/pypi/` +//! artifact must be reconstructed from that tree. The installed +//! `*.dist-info/RECORD` is the authoritative member list (spike-verified: pip +//! 26 / uv 0.11 only require RECORD to exist and parse at install time — per +//! file hashes are unchecked — but we regenerate it correctly anyway, because +//! the RECORD drives uninstall bookkeeping and post-hoc audits). The rebuild +//! is byte-for-byte deterministic so the emitted `--hash` / uv lock hash pin +//! is stable across re-runs and never churns committed files. + +use std::collections::{HashMap, HashSet}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use base64::Engine as _; +use sha2::Digest as _; + +use crate::crawlers::python_crawler::{canonicalize_pypi_name, read_python_metadata}; +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{ + apply_package_patch, is_safe_relative_subpath, normalize_file_path, ApplyResult, PatchSources, +}; +use crate::utils::fs::{atomic_write_bytes, list_dir_entries}; + +/// One parsed `RECORD` row (`path,hash,size`). `hash` keeps the raw field +/// (`sha256=`); empty fields become `None` (the RECORD/ +/// signature rows legitimately carry no hash or size). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecordEntry { + pub path: String, + pub hash: Option, + pub size: Option, +} + +/// The located installed distribution for one `name@version`. +#[derive(Debug, Clone)] +pub struct InstalledDist { + /// Absolute path of the `-.dist-info` directory. + pub dist_info_dir: PathBuf, + /// Raw distribution-name part of the dist-info directory stem (casing + /// and separators as installed, e.g. `Flask-SQLAlchemy`) — the input to + /// the wheel-filename escaping, NOT a canonical PEP 503 name. + pub dist_name: String, + pub version: String, + /// Parsed `RECORD` rows. + pub record: Vec, + /// Raw `Tag:` header values from the `WHEEL` file, in file order. + pub wheel_tags: Vec, +} + +/// The rebuilt artifact: leaf filename + content identity for the lock pins. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WheelArtifact { + pub file_name: String, + /// Plain sha256 hex of the wheel bytes (what pip `--hash=` and uv lock + /// `hash = "sha256:..."` verify). + pub sha256_hex: String, + pub size: u64, +} + +/// Find the installed dist for `purl_name@version` by scanning the +/// `*.dist-info` directories under the site-packages root (the crawler's +/// `pkg_path` for pypi). Name matching is PEP 503-canonical on BOTH sides so +/// `Flask_SQLAlchemy` / `flask-sqlalchemy` spellings collapse, mirroring +/// [`crate::crawlers::python_crawler`]. +pub async fn locate_installed_dist( + site_packages: &Path, + purl_name: &str, + version: &str, +) -> Result { + let want = canonicalize_pypi_name(purl_name); + for entry in list_dir_entries(site_packages).await { + let dir_name = entry.file_name().to_string_lossy().into_owned(); + let Some(stem) = dir_name.strip_suffix(".dist-info") else { + continue; + }; + let dist_info = entry.path(); + let Some((raw_name, found_version)) = read_python_metadata(&dist_info).await else { + continue; + }; + if canonicalize_pypi_name(&raw_name) != want || found_version != version { + continue; + } + + // Wheel filenames re-escape from the RAW installed name; the + // dist-info stem keeps it (`Flask-SQLAlchemy-2.5.1.dist-info`), with + // the METADATA Name as fallback for stems that carry no version part. + let dist_name = match stem.rfind('-') { + Some(i) if i > 0 => stem[..i].to_string(), + _ => raw_name.clone(), + }; + + let record_text = tokio::fs::read_to_string(dist_info.join("RECORD")) + .await + .map_err(|e| { + ( + "pypi_missing_record", + format!( + "cannot rebuild a wheel for {purl_name}@{version}: {}/RECORD is unreadable ({e})", + dist_info.display() + ), + ) + })?; + let record = parse_record_text(&record_text); + if record.is_empty() { + return Err(( + "pypi_missing_record", + format!( + "cannot rebuild a wheel for {purl_name}@{version}: {}/RECORD lists no files", + dist_info.display() + ), + )); + } + + let wheel_text = tokio::fs::read_to_string(dist_info.join("WHEEL")) + .await + .map_err(|e| { + ( + "pypi_missing_wheel_metadata", + format!( + "cannot rebuild a wheel for {purl_name}@{version}: {}/WHEEL is unreadable ({e})", + dist_info.display() + ), + ) + })?; + let wheel_tags: Vec = wheel_text + .lines() + .filter_map(|l| l.strip_prefix("Tag:")) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if wheel_tags.is_empty() { + return Err(( + "pypi_missing_wheel_metadata", + format!( + "cannot rebuild a wheel for {purl_name}@{version}: {}/WHEEL carries no Tag: headers", + dist_info.display() + ), + )); + } + + return Ok(InstalledDist { + dist_info_dir: dist_info, + dist_name, + version: found_version, + record, + wheel_tags, + }); + } + Err(( + "pypi_dist_not_found", + format!( + "{purl_name}@{version} is not installed under {}", + site_packages.display() + ), + )) +} + +/// The PEP 427 filename for the rebuilt wheel: +/// `--.whl`. +pub fn wheel_file_name(dist: &InstalledDist) -> Result { + let name = escape_wheel_component(&dist.dist_name); + let version = escape_wheel_component(&dist.version); + let (py, abi, plat) = compress_wheel_tags(&dist.wheel_tags)?; + Ok(format!("{name}-{version}-{py}-{abi}-{plat}.whl")) +} + +/// Wheel-spec component escaping: runs of `[^A-Za-z0-9.]` collapse to a +/// single `_` so the filename stays unambiguous at the `-` separators. +fn escape_wheel_component(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut in_run = false; + for ch in s.chars() { + if ch.is_ascii_alphanumeric() || ch == '.' { + out.push(ch); + in_run = false; + } else if !in_run { + out.push('_'); + in_run = true; + } + } + out +} + +/// Compress the WHEEL `Tag:` set back into the filename's dotted triple +/// (`py2.py3-none-any`). The dotted form expands to the CROSS PRODUCT of the +/// three component sets, so the compression is only faithful when the +/// observed tag set IS a full cross product — anything else would synthesize +/// a filename claiming compatibility the installed dist never declared, so +/// it is refused instead. +fn compress_wheel_tags( + tags: &[String], +) -> Result<(String, String, String), (&'static str, String)> { + let mut pys: Vec<&str> = Vec::new(); + let mut abis: Vec<&str> = Vec::new(); + let mut plats: Vec<&str> = Vec::new(); + let mut seen: HashSet<(&str, &str, &str)> = HashSet::new(); + for tag in tags { + let parts: Vec<&str> = tag.split('-').collect(); + let [py, abi, plat] = parts.as_slice() else { + return Err(( + "pypi_wheel_tags_unrecoverable", + format!("WHEEL tag {tag:?} is not a py-abi-platform triple"), + )); + }; + if !pys.contains(py) { + pys.push(py); + } + if !abis.contains(abi) { + abis.push(abi); + } + if !plats.contains(plat) { + plats.push(plat); + } + seen.insert((py, abi, plat)); + } + let product = pys.len() * abis.len() * plats.len(); + let all_present = pys.iter().all(|p| { + abis.iter() + .all(|a| plats.iter().all(|pl| seen.contains(&(p, a, pl)))) + }); + if product != seen.len() || !all_present { + return Err(( + "pypi_wheel_tags_unrecoverable", + format!( + "WHEEL tag set {tags:?} is not a cross product of its components and cannot be \ + expressed as a single wheel filename" + ), + )); + } + Ok((pys.join("."), abis.join("."), plats.join("."))) +} + +/// Build the patched wheel at `dest` from the installed dist: +/// stage the RECORD members → apply the patch in the stage → regenerate +/// RECORD → deterministic zip → atomic write. +/// +/// Errors (`Err((code, detail))`) are refusal-shaped — nothing was written +/// and the orchestrator maps them to [`VendorOutcome::Refused`]. Runtime +/// failures after staging surface as a failed [`ApplyResult`] instead, in the +/// same shape `apply` reports them. +/// +/// `dry_run` stops after the in-stage verification (no zip, no `dest` write). +/// +/// [`VendorOutcome::Refused`]: super::VendorOutcome::Refused +#[allow(clippy::too_many_arguments)] +pub async fn build_patched_wheel( + purl: &str, + site_packages: &Path, + dist: &InstalledDist, + record: &PatchRecord, + sources: &PatchSources<'_>, + dest: &Path, + dry_run: bool, + force: bool, +) -> Result<(ApplyResult, Option), (&'static str, String)> { + // Editable installs (`pip install -e` / uv tool dev mode) point + // site-packages at the user's own working tree: the RECORD describes a + // `.pth`/finder shim, not the package contents, so a rebuilt wheel would + // vendor the shim instead of the code. Checked BEFORE staging. + if is_editable_install(&dist.dist_info_dir).await { + return Err(( + "pypi_editable_install", + format!( + "{purl} is an editable install ({}); vendor needs a regular installed distribution", + dist.dist_info_dir.display() + ), + )); + } + + let dist_info_name = dist + .dist_info_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let script_names = match tokio::fs::read_to_string(dist.dist_info_dir.join("entry_points.txt")) + .await + { + Ok(text) => console_script_names(&text), + Err(_) => HashSet::new(), + }; + + // Select the wheel members from the installed RECORD. + let mut members: Vec = Vec::new(); + let mut out_of_tree: Vec = Vec::new(); + for row in &dist.record { + let path = row.path.as_str(); + if path.is_empty() + || is_installer_bookkeeping(path, &dist_info_name) + || path.ends_with(".pyc") + || path.split('/').any(|c| c == "__pycache__") + { + continue; + } + // SECURITY: `is_safe_relative_subpath` is the in-tree gate. A RECORD + // row that escapes site-packages (`../../../bin/x`, absolute paths) + // must never be staged or zipped — only the installer-regenerated + // console/gui scripts (matched by entry_points.txt NAME, never by + // extension heuristics: the spike's splitext shortcut wrongly dropped + // `../../../share/man/man6/pycowsay.6`) are silently excluded; any + // OTHER out-of-tree entry is data the rebuilt wheel cannot carry, so + // the whole vendor is refused fail-closed. + if !is_safe_relative_subpath(path) { + let last = path.rsplit('/').next().unwrap_or(path); + if is_console_script_artifact(last, &script_names) { + continue; + } + out_of_tree.push(path.to_string()); + continue; + } + members.push(path.to_string()); + } + if !out_of_tree.is_empty() { + out_of_tree.sort(); + return Err(( + "pypi_out_of_tree_files", + format!( + "RECORD lists files outside site-packages that are not console scripts \ + (a rebuilt wheel cannot reproduce them): {}", + out_of_tree.join(", ") + ), + )); + } + members.sort(); + members.dedup(); + + // Stage the members into a private tree preserving the site-packages- + // relative layout, so the manifest's sp-relative pypi file keys resolve. + let stage = match tempfile::tempdir() { + Ok(dir) => dir, + Err(e) => { + return Ok(( + failed_result(purl, site_packages, format!("cannot create stage dir: {e}")), + None, + )) + } + }; + let mut exec_bits: HashMap = HashMap::new(); + for member in &members { + let src = site_packages.join(member); + let bytes = match tokio::fs::read(&src).await { + Ok(b) => b, + Err(e) => { + return Ok(( + failed_result( + purl, + site_packages, + format!("RECORD member {member} is unreadable: {e}"), + ), + None, + )) + } + }; + exec_bits.insert(member.clone(), file_is_executable(&src).await); + let dst = stage.path().join(member); + if let Some(parent) = dst.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + return Ok(( + failed_result(purl, site_packages, format!("cannot stage {member}: {e}")), + None, + )); + } + } + if let Err(e) = tokio::fs::write(&dst, &bytes).await { + return Ok(( + failed_result(purl, site_packages, format!("cannot stage {member}: {e}")), + None, + )); + } + } + + // Patch the stage through the shared apply pipeline (same verify/source + // strategy contract as `apply`). The installed tree is never touched. + let mut result = apply_package_patch( + purl, + stage.path(), + &record.files, + sources, + Some(&record.uuid), + dry_run, + force, + ) + .await; + if dry_run || !result.success { + return Ok((result, None)); + } + + // Files CREATED by the patch (empty beforeHash) exist only in the stage; + // union them into the member list so the wheel ships them. + for (file_name, info) in &record.files { + if info.before_hash.is_empty() { + let normalized = normalize_file_path(file_name).to_string(); + if !members.contains(&normalized) { + exec_bits.insert(normalized.clone(), false); + members.push(normalized); + } + } + } + members.sort(); + + // Regenerate RECORD from the staged (patched) bytes and assemble the + // deterministic zip entry list: lexicographic order, RECORD forced last + // (installers stream-read it; last is also what bdist_wheel emits). + let mut entries: Vec = Vec::with_capacity(members.len() + 1); + let mut record_lines = String::new(); + for member in &members { + let bytes = match tokio::fs::read(stage.path().join(member)).await { + Ok(b) => b, + Err(e) => { + result.success = false; + result.error = Some(format!("staged member {member} vanished: {e}")); + return Ok((result, None)); + } + }; + let digest = sha2::Sha256::digest(&bytes); + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest); + record_lines.push_str(&format!( + "{},sha256={},{}\n", + csv_quote(member), + b64, + bytes.len() + )); + entries.push(ZipEntry { + name: member.clone(), + bytes, + executable: exec_bits.get(member).copied().unwrap_or(false), + }); + } + record_lines.push_str(&format!("{}/RECORD,,\n", csv_quote(&dist_info_name))); + entries.push(ZipEntry { + name: format!("{dist_info_name}/RECORD"), + bytes: record_lines.into_bytes(), + executable: false, + }); + + let zip_bytes = match tokio::task::spawn_blocking(move || build_deterministic_zip(&entries)) + .await + { + Ok(Ok(bytes)) => bytes, + Ok(Err(e)) => { + result.success = false; + result.error = Some(format!("wheel zip assembly failed: {e}")); + return Ok((result, None)); + } + Err(e) => { + result.success = false; + result.error = Some(format!("wheel zip task failed: {e}")); + return Ok((result, None)); + } + }; + + if let Some(parent) = dest.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + result.success = false; + result.error = Some(format!("cannot create {}: {e}", parent.display())); + return Ok((result, None)); + } + } + if let Err(e) = atomic_write_bytes(dest, &zip_bytes).await { + result.success = false; + result.error = Some(format!("cannot write {}: {e}", dest.display())); + return Ok((result, None)); + } + + let artifact = WheelArtifact { + file_name: dest + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), + sha256_hex: hex::encode(sha2::Sha256::digest(&zip_bytes)), + size: zip_bytes.len() as u64, + }; + Ok((result, Some(artifact))) +} + +/// Installer bookkeeping the wheel must not carry: signatures and per-install +/// state regenerated by pip/uv (`RECORD` itself is rebuilt; `direct_url.json` +/// describes the OLD origin and would mislabel the vendored install). +fn is_installer_bookkeeping(path: &str, dist_info_name: &str) -> bool { + const NAMES: [&str; 6] = [ + "RECORD", + "RECORD.jws", + "RECORD.p7s", + "INSTALLER", + "REQUESTED", + "direct_url.json", + ]; + NAMES + .iter() + .any(|n| path == format!("{dist_info_name}/{n}")) +} + +/// True when `dist-info/direct_url.json` marks the install editable. +async fn is_editable_install(dist_info_dir: &Path) -> bool { + let Ok(bytes) = tokio::fs::read(dist_info_dir.join("direct_url.json")).await else { + return false; + }; + let Ok(value) = serde_json::from_slice::(&bytes) else { + return false; + }; + value + .get("dir_info") + .and_then(|d| d.get("editable")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + +/// `[console_scripts]` / `[gui_scripts]` entry names from `entry_points.txt`. +fn console_script_names(text: &str) -> HashSet { + let mut names = HashSet::new(); + let mut in_scripts = false; + for line in text.lines() { + let line = line.trim(); + if line.starts_with('[') && line.ends_with(']') { + let section = line[1..line.len() - 1].trim(); + in_scripts = section == "console_scripts" || section == "gui_scripts"; + continue; + } + if in_scripts { + if let Some((name, _)) = line.split_once('=') { + let name = name.trim(); + if !name.is_empty() { + names.insert(name.to_string()); + } + } + } + } + names +} + +/// True when an out-of-tree RECORD entry's final component is an installer- +/// generated script for a declared entry point (`x`, `x.exe`, `x-script.py`). +fn is_console_script_artifact(final_component: &str, script_names: &HashSet) -> bool { + if script_names.contains(final_component) { + return true; + } + if let Some(stem) = final_component.strip_suffix(".exe") { + if script_names.contains(stem) { + return true; + } + } + if let Some(stem) = final_component.strip_suffix("-script.py") { + if script_names.contains(stem) { + return true; + } + } + false +} + +/// Parse `RECORD` rows (CSV; quoted fields possible; empty hash/size kept as +/// `None`). Unparseable/blank lines are skipped rather than failing the whole +/// file — fail-open here is safe because the member list only ever loses a +/// row it could not have staged anyway. +fn parse_record_text(text: &str) -> Vec { + let mut out = Vec::new(); + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + let fields = parse_csv_record(line); + let Some(path) = fields.first().filter(|p| !p.is_empty()) else { + continue; + }; + out.push(RecordEntry { + path: path.clone(), + hash: fields.get(1).filter(|h| !h.is_empty()).cloned(), + size: fields.get(2).and_then(|s| s.parse().ok()), + }); + } + out +} + +/// Minimal CSV record parser (RFC 4180 quoting: `"a,b"`, doubled `""`). +fn parse_csv_record(line: &str) -> Vec { + let mut fields = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + if in_quotes { + if c == '"' { + if chars.peek() == Some(&'"') { + current.push('"'); + chars.next(); + } else { + in_quotes = false; + } + } else { + current.push(c); + } + } else { + match c { + '"' => in_quotes = true, + ',' => fields.push(std::mem::take(&mut current)), + _ => current.push(c), + } + } + } + fields.push(current); + fields +} + +/// CSV-quote a field when it needs it (comma/quote/newline). +fn csv_quote(field: &str) -> String { + if field.contains([',', '"', '\n', '\r']) { + format!("\"{}\"", field.replace('"', "\"\"")) + } else { + field.to_string() + } +} + +#[cfg(unix)] +async fn file_is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + tokio::fs::metadata(path) + .await + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +async fn file_is_executable(_path: &Path) -> bool { + false +} + +struct ZipEntry { + name: String, + bytes: Vec, + executable: bool, +} + +/// Serialize `entries` (already ordered, RECORD last) into a deterministic +/// zip: fixed DOS timestamp (1980-01-01 00:00:00), fixed deflate level, unix +/// mode 0o644 / 0o755 (preserved exec bit) — so rebuilding from the same +/// patched tree always yields identical bytes and a stable hash pin. +fn build_deterministic_zip(entries: &[ZipEntry]) -> Result, String> { + use std::io::Cursor; + use zip::write::SimpleFileOptions; + + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + for entry in entries { + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .compression_level(Some(6)) + .last_modified_time(zip::DateTime::default()) + .unix_permissions(if entry.executable { 0o755 } else { 0o644 }); + writer + .start_file(&entry.name, options) + .map_err(|e| e.to_string())?; + writer.write_all(&entry.bytes).map_err(|e| e.to_string())?; + } + let cursor = writer.finish().map_err(|e| e.to_string())?; + Ok(cursor.into_inner()) +} + +fn failed_result(purl: &str, site_packages: &Path, error: String) -> ApplyResult { + ApplyResult { + package_key: purl.to_string(), + package_path: site_packages.display().to_string(), + success: false, + files_verified: Vec::new(), + files_patched: Vec::new(), + applied_via: HashMap::new(), + error: Some(error), + sidecar: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const ORIG: &[u8] = b"class Six:\n pass\n"; + const PATCHED: &[u8] = b"class Six:\n pass\n# SOCKET-PATCH-MARKER\n"; + + struct Fixture { + _tmp: tempfile::TempDir, + site_packages: PathBuf, + blobs: PathBuf, + dest: PathBuf, + } + + /// A six-like installed dist plus a blob store carrying the afterHash + /// bytes, mirroring a real `.socket/blobs/` layout. + async fn make_fixture(extra_record_lines: &str, entry_points: Option<&str>) -> Fixture { + let tmp = tempfile::tempdir().unwrap(); + let sp = tmp.path().join("site-packages"); + let di = sp.join("six-1.16.0.dist-info"); + tokio::fs::create_dir_all(&di).await.unwrap(); + tokio::fs::write(sp.join("six.py"), ORIG).await.unwrap(); + tokio::fs::write( + di.join("METADATA"), + "Metadata-Version: 2.1\nName: six\nVersion: 1.16.0\n\nREADME body\n", + ) + .await + .unwrap(); + tokio::fs::write( + di.join("WHEEL"), + "Wheel-Version: 1.0\nGenerator: test\nRoot-Is-Purelib: true\nTag: py2-none-any\nTag: py3-none-any\n", + ) + .await + .unwrap(); + let record = format!( + "six.py,sha256=AAAA,20\n\ + six-1.16.0.dist-info/METADATA,sha256=BBBB,60\n\ + six-1.16.0.dist-info/WHEEL,,\n\ + six-1.16.0.dist-info/INSTALLER,sha256=,4\n\ + six-1.16.0.dist-info/RECORD,,\n\ + __pycache__/six.cpython-314.pyc,,\n{extra_record_lines}" + ); + tokio::fs::write(di.join("RECORD"), record).await.unwrap(); + if let Some(ep) = entry_points { + tokio::fs::write(di.join("entry_points.txt"), ep).await.unwrap(); + } + let blobs = tmp.path().join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + tokio::fs::write(blobs.join(compute_git_sha256_from_bytes(PATCHED)), PATCHED) + .await + .unwrap(); + let dest = tmp + .path() + .join(format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl")); + Fixture { + _tmp: tmp, + site_packages: sp, + blobs, + dest, + } + } + + fn patch_record(files: &[(&str, &[u8], &[u8])]) -> PatchRecord { + let mut map = HashMap::new(); + for (name, before, after) in files { + map.insert( + name.to_string(), + PatchFileInfo { + before_hash: if before.is_empty() { + String::new() + } else { + compute_git_sha256_from_bytes(before) + }, + after_hash: compute_git_sha256_from_bytes(after), + }, + ); + } + PatchRecord { + uuid: UUID.to_string(), + exported_at: String::new(), + files: map, + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + } + } + + fn zip_names(bytes: &[u8]) -> Vec { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap(); + (0..archive.len()) + .map(|i| archive.by_index(i).unwrap().name().to_string()) + .collect() + } + + fn zip_file(bytes: &[u8], name: &str) -> Vec { + use std::io::Read as _; + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap(); + let mut file = archive.by_name(name).unwrap(); + let mut out = Vec::new(); + file.read_to_end(&mut out).unwrap(); + out + } + + #[test] + fn record_parse_round_trips_quoted_and_empty_fields() { + let text = "six.py,sha256=abc_DEF,123\n\ + \"weird,name.py\",sha256=zz,9\n\ + six-1.16.0.dist-info/RECORD,,\n\ + \n"; + let rows = parse_record_text(text); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].path, "six.py"); + assert_eq!(rows[0].hash.as_deref(), Some("sha256=abc_DEF")); + assert_eq!(rows[0].size, Some(123)); + // Quoted CSV path with an embedded comma. + assert_eq!(rows[1].path, "weird,name.py"); + // Empty hash + size stay None. + assert_eq!(rows[2].hash, None); + assert_eq!(rows[2].size, None); + // Emit side: a path needing quoting survives a parse round-trip. + let quoted = csv_quote("weird,\"name\".py"); + assert_eq!(parse_csv_record("ed)[0], "weird,\"name\".py"); + } + + #[test] + fn tag_compression_round_trips_and_rejects_non_cross_products() { + let dist = InstalledDist { + dist_info_dir: PathBuf::from("x"), + dist_name: "six".into(), + version: "1.16.0".into(), + record: vec![], + wheel_tags: vec!["py2-none-any".into(), "py3-none-any".into()], + }; + assert_eq!(wheel_file_name(&dist).unwrap(), "six-1.16.0-py2.py3-none-any.whl"); + + // A tag set that is NOT a cross product of its components must refuse + // rather than fabricate compatibility. + let err = compress_wheel_tags(&["py2-none-any".into(), "py3-abi3-manylinux1_x86_64".into()]) + .unwrap_err(); + assert_eq!(err.0, "pypi_wheel_tags_unrecoverable"); + // Malformed (non-triple) tag. + let err = compress_wheel_tags(&["py3".into()]).unwrap_err(); + assert_eq!(err.0, "pypi_wheel_tags_unrecoverable"); + } + + #[test] + fn wheel_name_escapes_dist_info_stem_names() { + let dist = InstalledDist { + dist_info_dir: PathBuf::from("x"), + dist_name: "Flask-SQLAlchemy".into(), + version: "2.5.1".into(), + record: vec![], + wheel_tags: vec!["py3-none-any".into()], + }; + assert_eq!( + wheel_file_name(&dist).unwrap(), + "Flask_SQLAlchemy-2.5.1-py3-none-any.whl" + ); + } + + #[tokio::test] + async fn locate_finds_dist_with_canonicalized_name_and_parses_metadata() { + let fx = make_fixture("", None).await; + // PEP 503: `SIX` and `six` collapse to the same name. + let dist = locate_installed_dist(&fx.site_packages, "SIX", "1.16.0") + .await + .unwrap(); + assert_eq!(dist.dist_name, "six"); + assert_eq!(dist.version, "1.16.0"); + assert_eq!(dist.wheel_tags, vec!["py2-none-any", "py3-none-any"]); + assert!(dist.record.iter().any(|r| r.path == "six.py")); + + let err = locate_installed_dist(&fx.site_packages, "six", "1.17.0") + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_dist_not_found"); + } + + #[tokio::test] + async fn locate_refuses_missing_record_and_missing_wheel_metadata() { + let fx = make_fixture("", None).await; + let di = fx.site_packages.join("six-1.16.0.dist-info"); + + let wheel_backup = tokio::fs::read(di.join("WHEEL")).await.unwrap(); + tokio::fs::remove_file(di.join("WHEEL")).await.unwrap(); + let err = locate_installed_dist(&fx.site_packages, "six", "1.16.0") + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_missing_wheel_metadata"); + tokio::fs::write(di.join("WHEEL"), wheel_backup).await.unwrap(); + + tokio::fs::remove_file(di.join("RECORD")).await.unwrap(); + let err = locate_installed_dist(&fx.site_packages, "six", "1.16.0") + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_missing_record"); + } + + #[tokio::test] + async fn member_filter_excludes_bookkeeping_and_console_scripts() { + // Console script `six-cmd` lives out of tree but is declared in + // entry_points.txt — excluded, not refused. RECORD signature files, + // INSTALLER, pyc files all drop out. + let fx = make_fixture( + "../../../bin/six-cmd,sha256=cc,99\n\ + ../../../bin/six-cmd.exe,,\n\ + six-1.16.0.dist-info/RECORD.jws,,\n\ + six-1.16.0.dist-info/entry_points.txt,sha256=dd,40\n", + Some("[console_scripts]\nsix-cmd = six:main\n"), + ) + .await; + let dist = locate_installed_dist(&fx.site_packages, "six", "1.16.0") + .await + .unwrap(); + let record = patch_record(&[("six.py", ORIG, PATCHED)]); + let sources = PatchSources::blobs_only(&fx.blobs); + let (result, artifact) = build_patched_wheel( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &dist, + &record, + &sources, + &fx.dest, + false, + false, + ) + .await + .unwrap(); + assert!(result.success, "{:?}", result.error); + let artifact = artifact.unwrap(); + let bytes = tokio::fs::read(&fx.dest).await.unwrap(); + assert_eq!(artifact.size, bytes.len() as u64); + let names = zip_names(&bytes); + assert!(names.contains(&"six.py".to_string())); + assert!(names.contains(&"six-1.16.0.dist-info/METADATA".to_string())); + assert!(names.contains(&"six-1.16.0.dist-info/entry_points.txt".to_string())); + for forbidden in [ + "six-1.16.0.dist-info/INSTALLER", + "six-1.16.0.dist-info/RECORD.jws", + "__pycache__/six.cpython-314.pyc", + "../../../bin/six-cmd", + ] { + assert!(!names.contains(&forbidden.to_string()), "{forbidden} leaked"); + } + // Patched bytes actually landed in the wheel. + assert_eq!(zip_file(&bytes, "six.py"), PATCHED); + } + + #[tokio::test] + async fn out_of_tree_data_file_is_refused() { + // `share/man/...` is a wheel .data payload, NOT a console script — + // the spike showed name-stem heuristics must not swallow it. + let fx = make_fixture( + "../../../share/man/man6/six.6,sha256=ee,10\n", + Some("[console_scripts]\nsix-cmd = six:main\n"), + ) + .await; + let dist = locate_installed_dist(&fx.site_packages, "six", "1.16.0") + .await + .unwrap(); + let record = patch_record(&[("six.py", ORIG, PATCHED)]); + let sources = PatchSources::blobs_only(&fx.blobs); + let err = build_patched_wheel( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &dist, + &record, + &sources, + &fx.dest, + false, + false, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_out_of_tree_files"); + assert!(err.1.contains("share/man/man6/six.6"), "{}", err.1); + assert!(!fx.dest.exists(), "refusal must not write the artifact"); + } + + #[tokio::test] + async fn deterministic_zip_record_last_and_stable_across_builds() { + let fx = make_fixture("", None).await; + let dist = locate_installed_dist(&fx.site_packages, "six", "1.16.0") + .await + .unwrap(); + let record = patch_record(&[("six.py", ORIG, PATCHED)]); + let sources = PatchSources::blobs_only(&fx.blobs); + let (r1, a1) = build_patched_wheel( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &dist, + &record, + &sources, + &fx.dest, + false, + false, + ) + .await + .unwrap(); + assert!(r1.success); + let bytes1 = tokio::fs::read(&fx.dest).await.unwrap(); + + // Second build: the stage re-applies onto already-patched members + // (AlreadyPatched verify) — bytes and hash must be identical. + let (r2, a2) = build_patched_wheel( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &dist, + &record, + &sources, + &fx.dest, + false, + false, + ) + .await + .unwrap(); + assert!(r2.success); + let bytes2 = tokio::fs::read(&fx.dest).await.unwrap(); + assert_eq!(bytes1, bytes2, "wheel rebuild must be byte-deterministic"); + assert_eq!(a1.unwrap().sha256_hex, a2.unwrap().sha256_hex); + + // RECORD is the final zip entry and self-describes with `path,,`. + let names = zip_names(&bytes1); + assert_eq!(names.last().map(String::as_str), Some("six-1.16.0.dist-info/RECORD")); + let record_text = String::from_utf8(zip_file(&bytes1, "six-1.16.0.dist-info/RECORD")).unwrap(); + assert!(record_text.ends_with("six-1.16.0.dist-info/RECORD,,\n")); + // RECORD hash of six.py matches the patched bytes. + let digest = sha2::Sha256::digest(PATCHED); + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest); + assert!( + record_text.contains(&format!("six.py,sha256={},{}", b64, PATCHED.len())), + "{record_text}" + ); + } + + #[tokio::test] + async fn created_by_patch_file_is_unioned_into_the_wheel() { + let fx = make_fixture("", None).await; + let created = b"# brand new module\n"; + tokio::fs::write( + fx.blobs.join(compute_git_sha256_from_bytes(created)), + created, + ) + .await + .unwrap(); + let dist = locate_installed_dist(&fx.site_packages, "six", "1.16.0") + .await + .unwrap(); + let record = patch_record(&[("six.py", ORIG, PATCHED), ("six_extra.py", b"", created)]); + let sources = PatchSources::blobs_only(&fx.blobs); + let (result, _) = build_patched_wheel( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &dist, + &record, + &sources, + &fx.dest, + false, + false, + ) + .await + .unwrap(); + assert!(result.success, "{:?}", result.error); + let bytes = tokio::fs::read(&fx.dest).await.unwrap(); + assert!(zip_names(&bytes).contains(&"six_extra.py".to_string())); + assert_eq!(zip_file(&bytes, "six_extra.py"), created); + let record_text = String::from_utf8(zip_file(&bytes, "six-1.16.0.dist-info/RECORD")).unwrap(); + assert!(record_text.contains("six_extra.py,sha256=")); + // The created file must NOT exist in the real site-packages. + assert!(!fx.site_packages.join("six_extra.py").exists()); + } + + #[tokio::test] + async fn editable_install_is_refused_before_staging() { + let fx = make_fixture("", None).await; + tokio::fs::write( + fx.site_packages + .join("six-1.16.0.dist-info/direct_url.json"), + r#"{"url": "file:///work/six", "dir_info": {"editable": true}}"#, + ) + .await + .unwrap(); + let dist = locate_installed_dist(&fx.site_packages, "six", "1.16.0") + .await + .unwrap(); + let record = patch_record(&[("six.py", ORIG, PATCHED)]); + let sources = PatchSources::blobs_only(&fx.blobs); + let err = build_patched_wheel( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &dist, + &record, + &sources, + &fx.dest, + false, + false, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_editable_install"); + } + + #[tokio::test] + async fn dry_run_verifies_but_writes_nothing() { + let fx = make_fixture("", None).await; + let dist = locate_installed_dist(&fx.site_packages, "six", "1.16.0") + .await + .unwrap(); + let record = patch_record(&[("six.py", ORIG, PATCHED)]); + let sources = PatchSources::blobs_only(&fx.blobs); + let (result, artifact) = build_patched_wheel( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &dist, + &record, + &sources, + &fx.dest, + true, + false, + ) + .await + .unwrap(); + assert!(result.success); + assert!(artifact.is_none()); + assert!(!fx.dest.exists()); + // Installed tree untouched. + assert_eq!( + tokio::fs::read(fx.site_packages.join("six.py")).await.unwrap(), + ORIG + ); + } + + #[tokio::test] + async fn hash_mismatch_fails_without_touching_install_or_dest() { + let fx = make_fixture("", None).await; + // Corrupt the installed six.py so verify sees a HashMismatch. + tokio::fs::write(fx.site_packages.join("six.py"), b"tampered") + .await + .unwrap(); + let dist = locate_installed_dist(&fx.site_packages, "six", "1.16.0") + .await + .unwrap(); + let record = patch_record(&[("six.py", ORIG, PATCHED)]); + let sources = PatchSources::blobs_only(&fx.blobs); + let (result, artifact) = build_patched_wheel( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &dist, + &record, + &sources, + &fx.dest, + false, + false, + ) + .await + .unwrap(); + assert!(!result.success); + assert!(artifact.is_none()); + assert!(!fx.dest.exists()); + } + + #[test] + fn console_script_artifact_matching_is_name_exact() { + let names: HashSet = ["pycowsay".to_string()].into_iter().collect(); + assert!(is_console_script_artifact("pycowsay", &names)); + assert!(is_console_script_artifact("pycowsay.exe", &names)); + assert!(is_console_script_artifact("pycowsay-script.py", &names)); + // The spike's splitext bug: `pycowsay.6` (a man page) must NOT match. + assert!(!is_console_script_artifact("pycowsay.6", &names)); + assert!(!is_console_script_artifact("other", &names)); + } +} diff --git a/crates/socket-patch-core/src/vex/build.rs b/crates/socket-patch-core/src/vex/build.rs index e935935..db142a5 100644 --- a/crates/socket-patch-core/src/vex/build.rs +++ b/crates/socket-patch-core/src/vex/build.rs @@ -49,10 +49,27 @@ pub fn build_document( manifest: &PatchManifest, applied: &[String], opts: &BuildOptions, +) -> Option { + build_document_with_vendored(manifest, applied, &[], opts) +} + +/// [`build_document`] with vendored-patch awareness: PURLs in `vendored` +/// (a subset of `applied`, from `VerifyOutcome::vendored`) carry the +/// impact-statement phrasing "Patched via Socket patch `` (vendored)" +/// so the attestation records that the evidence is the committed +/// `.socket/vendor/` artifact, not the installed tree. Status and +/// justification are identical to the non-vendored form. +pub fn build_document_with_vendored( + manifest: &PatchManifest, + applied: &[String], + vendored: &[String], + opts: &BuildOptions, ) -> Option { let timestamp = now_rfc3339(); let applied_set: std::collections::HashSet<&str> = applied.iter().map(|s| s.as_str()).collect(); + let vendored_set: std::collections::HashSet<&str> = + vendored.iter().map(|s| s.as_str()).collect(); // vuln-id -> (aliases, impact-statement parts, subcomponent PURLs) // BTreeMap keeps statement order deterministic by vuln id, which @@ -71,9 +88,11 @@ pub fn build_document( } } entry.subcomponents.insert(purl.clone()); - entry - .impact_parts - .push(format!("Patched via Socket patch {}", record.uuid)); + entry.impact_parts.push(if vendored_set.contains(purl.as_str()) { + format!("Patched via Socket patch {} (vendored)", record.uuid) + } else { + format!("Patched via Socket patch {}", record.uuid) + }); } } @@ -720,4 +739,123 @@ mod tests { assert_eq!(subs[1].id, "pkg:npm/mmm@1.0.0"); assert_eq!(subs[2].id, "pkg:npm/zzz@1.0.0"); } + + // ── Vendored-patch phrasing (`build_document_with_vendored`) ── + + /// A vendored PURL's impact statement carries the "(vendored)" suffix; + /// status/justification stay identical to the non-vendored form. + #[test] + fn vendored_purl_gets_vendored_impact_phrasing() { + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + "pkg:cargo/serde@1.0.0".to_string(), + record("u-vend", vec![("GHSA-vvvv", vec!["CVE-2024-7"])]), + ); + let applied = vec!["pkg:cargo/serde@1.0.0".to_string()]; + let doc = + build_document_with_vendored(&manifest, &applied, &applied, &opts()).unwrap(); + let st = &doc.statements[0]; + assert_eq!( + st.impact_statement.as_deref(), + Some("Patched via Socket patch u-vend (vendored)") + ); + // The vendored path must not perturb the pinned status/justification. + assert_eq!(st.status, Status::NotAffected); + assert_eq!( + st.justification, + Some(Justification::InlineMitigationsAlreadyExist) + ); + } + + /// `build_document` is exactly `build_document_with_vendored(.., &[], ..)` + /// — no "(vendored)" phrasing without a vendored set. + #[test] + fn build_document_is_empty_vendored_wrapper() { + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + "pkg:npm/x@1.0.0".to_string(), + record("u1", vec![("GHSA-aaaa", vec!["CVE-1"])]), + ); + let applied = vec!["pkg:npm/x@1.0.0".to_string()]; + let strip = |mut d: Document| -> Document { + d.timestamp = String::new(); + for s in d.statements.iter_mut() { + s.timestamp = None; + } + d + }; + let a = strip(build_document(&manifest, &applied, &opts()).unwrap()); + let b = strip( + build_document_with_vendored(&manifest, &applied, &[], &opts()).unwrap(), + ); + assert_eq!(a, b); + assert!(!a.statements[0] + .impact_statement + .as_deref() + .unwrap() + .contains("(vendored)")); + } + + /// Same patch UUID across a vendored and a non-vendored PURL sharing a + /// GHSA: the two phrasings differ, so BOTH survive the dedup — the + /// statement records that one attestation is vendored and one is not. + #[test] + fn same_uuid_vendored_and_non_vendored_keeps_both_phrasings() { + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + "pkg:npm/x@1.0.0".to_string(), + record("shared-uuid", vec![("GHSA-shared", vec!["CVE-1"])]), + ); + manifest.patches.insert( + "pkg:npm/x@1.0.1".to_string(), + record("shared-uuid", vec![("GHSA-shared", vec!["CVE-1"])]), + ); + let applied = vec![ + "pkg:npm/x@1.0.0".to_string(), + "pkg:npm/x@1.0.1".to_string(), + ]; + let vendored = vec!["pkg:npm/x@1.0.1".to_string()]; + let doc = + build_document_with_vendored(&manifest, &applied, &vendored, &opts()).unwrap(); + let imp = doc.statements[0].impact_statement.as_ref().unwrap(); + assert!( + imp.contains("Patched via Socket patch shared-uuid (vendored)"), + "vendored phrasing missing: {imp}" + ); + assert!( + imp.contains("Patched via Socket patch shared-uuid;") + || imp.ends_with("Patched via Socket patch shared-uuid"), + "plain phrasing missing: {imp}" + ); + assert_eq!(imp.matches("shared-uuid").count(), 2, "both forms kept: {imp}"); + } + + /// Same UUID across two VENDORED PURLs sharing a GHSA: identical + /// phrasing collapses to one mention (the vendored twin of + /// `same_uuid_across_two_purls_deduped_in_impact_statement`). + #[test] + fn same_uuid_two_vendored_purls_deduped_in_impact_statement() { + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + "pkg:npm/x@1.0.0".to_string(), + record("shared-uuid", vec![("GHSA-shared", vec!["CVE-1"])]), + ); + manifest.patches.insert( + "pkg:npm/x@1.0.1".to_string(), + record("shared-uuid", vec![("GHSA-shared", vec!["CVE-1"])]), + ); + let applied = vec![ + "pkg:npm/x@1.0.0".to_string(), + "pkg:npm/x@1.0.1".to_string(), + ]; + let doc = + build_document_with_vendored(&manifest, &applied, &applied, &opts()).unwrap(); + let imp = doc.statements[0].impact_statement.as_ref().unwrap(); + assert_eq!( + imp.matches("shared-uuid").count(), + 1, + "duplicate vendored UUID must collapse: {imp}" + ); + assert!(imp.contains("(vendored)")); + } } diff --git a/crates/socket-patch-core/src/vex/mod.rs b/crates/socket-patch-core/src/vex/mod.rs index 47033a2..e381e03 100644 --- a/crates/socket-patch-core/src/vex/mod.rs +++ b/crates/socket-patch-core/src/vex/mod.rs @@ -20,13 +20,15 @@ pub mod schema; pub mod time; pub mod verify; -pub use build::{build_document, BuildOptions}; +pub use build::{build_document, build_document_with_vendored, BuildOptions}; pub use product::{detect_product, DetectResult}; pub use schema::{ Document, Justification, Product, Statement, Status, Subcomponent, Vulnerability, OPENVEX_CONTEXT_V0_2_0, }; -pub use verify::{applied_patches, FailedPatch, VerifyOutcome}; +pub use verify::{ + applied_patches, applied_patches_with_vendor, FailedPatch, VendorContext, VerifyOutcome, +}; #[cfg(test)] mod conformance_tests; diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index 2a83594..8564146 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -17,6 +17,8 @@ use std::path::{Path, PathBuf}; use crate::manifest::schema::PatchManifest; use crate::patch::apply::{verify_file_patch, VerifyStatus}; +use crate::patch::vendor::state::VendorEntry; +use crate::patch::vendor::verify::verify_vendored_patch_record; /// One entry per manifest PURL that did NOT pass verification. The /// `reason` is a short snake_case tag the CLI can route on (matches @@ -34,6 +36,31 @@ pub struct VerifyOutcome { pub applied: Vec, /// PURLs whose verification failed (with a routing tag). pub failed: Vec, + /// The subset of `applied` that was attested via the committed + /// vendor artifact (`.socket/vendor/…`) rather than the installed + /// tree. Every member is also present in `applied`. + pub vendored: Vec, +} + +/// Vendored-patch context for [`applied_patches_with_vendor`]. +/// +/// Built by the CLI from the committed `.socket/vendor/state.json` ledger +/// (plus the legacy `.socket/go-patches/` redirect synthesis); kept as plain +/// data so this module stays free of state-loading concerns. +#[derive(Debug, Clone, Default)] +pub struct VendorContext { + /// Project root the vendor artifact paths are relative to. + pub project_root: PathBuf, + /// Vendor-state entries, keyed by manifest PURL (a manifest PURL also + /// matches an entry whose `base_purl` equals it — qualified manifest + /// keys resolve to the entry recorded under the base PURL). + pub entries: HashMap, + /// Legacy `apply`-redirect copies: PURL → absolute + /// `.socket/go-patches/@` copy dir. These are verified + /// with the ordinary dir-hash check (NOT the vendor artifact check — + /// their paths live outside `.socket/vendor/`) and count as `applied` + /// but not `vendored`. + pub go_patches: HashMap, } /// Walk the manifest and bucket each PURL into `applied` / `failed`. @@ -44,10 +71,64 @@ pub struct VerifyOutcome { pub async fn applied_patches( manifest: &PatchManifest, package_paths: &HashMap, +) -> VerifyOutcome { + applied_patches_with_vendor(manifest, package_paths, None).await +} + +/// [`applied_patches`] with vendored-patch awareness. +/// +/// Per-PURL precedence: +/// 1. A vendor-state entry (matched by map key or `base_purl`) means the +/// committed artifact is the SOLE evidence: success lands the PURL in +/// both `applied` and `vendored`; failure lands it in `failed` with the +/// vendor routing tag. There is deliberately no fallback to the +/// installed tree in either direction — an unpatched `node_modules` is +/// EXPECTED after vendoring and must not block attestation, and a +/// patched-looking installed tree must not launder a tampered vendor +/// artifact. +/// 2. A `go_patches` entry verifies the redirect copy dir with the normal +/// dir-hash check (`applied` only, not `vendored`); again no fallback — +/// an active redirect makes the copy dir the consumed bytes, while the +/// module cache stays pristine by design. +/// 3. Otherwise the installed-tree behavior of [`applied_patches`], verbatim. +pub async fn applied_patches_with_vendor( + manifest: &PatchManifest, + package_paths: &HashMap, + vendor: Option<&VendorContext>, ) -> VerifyOutcome { let mut out = VerifyOutcome::default(); for (purl, record) in &manifest.patches { + if let Some(ctx) = vendor { + let entry = ctx + .entries + .get(purl) + .or_else(|| ctx.entries.values().find(|e| e.base_purl == *purl)); + if let Some(entry) = entry { + match verify_vendored_patch_record(&ctx.project_root, entry, record).await { + Ok(()) => { + out.applied.push(purl.clone()); + out.vendored.push(purl.clone()); + } + Err(reason) => out.failed.push(FailedPatch { + purl: purl.clone(), + reason, + }), + } + continue; + } + if let Some(copy_dir) = ctx.go_patches.get(purl) { + match verify_patch_record(copy_dir, record).await { + Ok(()) => out.applied.push(purl.clone()), + Err(reason) => out.failed.push(FailedPatch { + purl: purl.clone(), + reason, + }), + } + continue; + } + } + let pkg_path = match package_paths.get(purl) { Some(p) => p, None => { @@ -817,4 +898,302 @@ mod tests { out.failed[0].reason ); } + + // ── Vendored-patch awareness (`applied_patches_with_vendor`) ── + + use crate::patch::vendor::state::{VendorArtifact, VendorEntry}; + + /// Canonical-grammar patch UUID — `verify_vendored_patch_record` + /// validates the uuid path level, so vendor fixtures must use a real + /// uuid (unlike the `"u"` shorthand of the installed-tree tests). + const VUUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + fn vendor_entry(purl: &str, rel_path: &str) -> VendorEntry { + VendorEntry { + ecosystem: "cargo".to_string(), + base_purl: purl.to_string(), + uuid: VUUID.to_string(), + artifact: VendorArtifact { + path: rel_path.to_string(), + sha256: String::new(), + size: None, + platform_locked: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + flavor: None, + uv: None, + } + } + + /// `applied_patches` must be exactly `applied_patches_with_vendor(.., None)` + /// on a mixed fixture (one applied, one failed) — the wrapper carries the + /// pre-vendor contract verbatim, with an empty `vendored` set. + #[tokio::test] + async fn wrapper_equals_with_vendor_none() { + let ok_dir = tempfile::tempdir().unwrap(); + let patched = b"patched-content"; + let hash = compute_git_sha256_from_bytes(patched); + tokio::fs::write(ok_dir.path().join("index.js"), patched) + .await + .unwrap(); + + let mut manifest = PatchManifest::new(); + manifest + .patches + .insert("pkg:npm/ok@1.0.0".to_string(), record_with_one_file(&hash)); + manifest.patches.insert( + "pkg:npm/missing@2.0.0".to_string(), + record_with_one_file("deadbeef"), + ); + + let mut paths = HashMap::new(); + paths.insert("pkg:npm/ok@1.0.0".to_string(), ok_dir.path().to_path_buf()); + + let a = applied_patches(&manifest, &paths).await; + let b = applied_patches_with_vendor(&manifest, &paths, None).await; + assert_eq!(a.applied, b.applied); + assert_eq!(a.failed, b.failed); + assert!(a.vendored.is_empty()); + assert!(b.vendored.is_empty()); + } + + /// Happy path: a vendor-state entry + healthy vendored dir attests the + /// PURL with the installed tree entirely ABSENT (`package_paths` empty — + /// the post-vendor `node_modules`-less checkout). The PURL lands in BOTH + /// `applied` and `vendored`. + #[tokio::test] + async fn vendored_dir_attests_without_installed_tree() { + let root = tempfile::tempdir().unwrap(); + let purl = "pkg:cargo/serde@1.0.0"; + let rel = format!(".socket/vendor/cargo/{VUUID}/serde-1.0.0"); + let patched = b"patched-content"; + let hash = compute_git_sha256_from_bytes(patched); + let dir = root.path().join(&rel); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("index.js"), patched).await.unwrap(); + + let mut rec = record_with_one_file(&hash); + rec.uuid = VUUID.to_string(); + let mut manifest = PatchManifest::new(); + manifest.patches.insert(purl.to_string(), rec); + + let mut entries = HashMap::new(); + entries.insert(purl.to_string(), vendor_entry(purl, &rel)); + let ctx = VendorContext { + project_root: root.path().to_path_buf(), + entries, + go_patches: HashMap::new(), + }; + + let paths: HashMap = HashMap::new(); // no installed tree + let out = applied_patches_with_vendor(&manifest, &paths, Some(&ctx)).await; + assert_eq!(out.applied, vec![purl.to_string()]); + assert_eq!(out.vendored, vec![purl.to_string()]); + assert!(out.failed.is_empty()); + } + + /// A manifest PURL matches a vendor entry recorded under a different map + /// key when `entry.base_purl` equals it (qualified-key manifests resolve + /// to the base-PURL ledger entry). + #[tokio::test] + async fn vendor_entry_matched_by_base_purl() { + let root = tempfile::tempdir().unwrap(); + let purl = "pkg:cargo/serde@1.0.0"; + let rel = format!(".socket/vendor/cargo/{VUUID}/serde-1.0.0"); + let patched = b"patched-content"; + let hash = compute_git_sha256_from_bytes(patched); + let dir = root.path().join(&rel); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("index.js"), patched).await.unwrap(); + + let mut rec = record_with_one_file(&hash); + rec.uuid = VUUID.to_string(); + let mut manifest = PatchManifest::new(); + manifest.patches.insert(purl.to_string(), rec); + + // Keyed by some other (qualified) string; base_purl carries the match. + let mut entries = HashMap::new(); + entries.insert( + "pkg:cargo/serde@1.0.0?qualifier=x".to_string(), + vendor_entry(purl, &rel), + ); + let ctx = VendorContext { + project_root: root.path().to_path_buf(), + entries, + go_patches: HashMap::new(), + }; + + let out = + applied_patches_with_vendor(&manifest, &HashMap::new(), Some(&ctx)).await; + assert_eq!(out.applied, vec![purl.to_string()]); + assert_eq!(out.vendored, vec![purl.to_string()]); + } + + /// Precedence, healthy direction: the installed tree still holds the + /// UN-patched bytes (expected after vendoring — the lockfile points at + /// the vendored copy now) while the vendor artifact is healthy. The + /// vendor path must win: applied + vendored, no `not_applied` failure. + #[tokio::test] + async fn healthy_vendor_beats_unpatched_installed_tree() { + let root = tempfile::tempdir().unwrap(); + let purl = "pkg:cargo/serde@1.0.0"; + let rel = format!(".socket/vendor/cargo/{VUUID}/serde-1.0.0"); + let original = b"original-unpatched"; + let patched = b"patched-content"; + let before = compute_git_sha256_from_bytes(original); + let after = compute_git_sha256_from_bytes(patched); + + // Vendored copy: patched. + let vdir = root.path().join(&rel); + tokio::fs::create_dir_all(&vdir).await.unwrap(); + tokio::fs::write(vdir.join("index.js"), patched).await.unwrap(); + // Installed tree: still original. + let installed = root.path().join("installed"); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write(installed.join("index.js"), original) + .await + .unwrap(); + + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash: before, + after_hash: after, + }, + ); + let rec = PatchRecord { + uuid: VUUID.to_string(), + exported_at: String::new(), + files, + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }; + let mut manifest = PatchManifest::new(); + manifest.patches.insert(purl.to_string(), rec); + + let mut entries = HashMap::new(); + entries.insert(purl.to_string(), vendor_entry(purl, &rel)); + let ctx = VendorContext { + project_root: root.path().to_path_buf(), + entries, + go_patches: HashMap::new(), + }; + let mut paths = HashMap::new(); + paths.insert(purl.to_string(), installed); + + let out = applied_patches_with_vendor(&manifest, &paths, Some(&ctx)).await; + assert_eq!( + out.applied, + vec![purl.to_string()], + "the unpatched installed tree must not block a healthy vendor attestation" + ); + assert_eq!(out.vendored, vec![purl.to_string()]); + assert!(out.failed.is_empty()); + } + + /// Precedence, fail-closed direction: a TAMPERED vendor artifact fails + /// with `vendor_hash_mismatch` even though the installed tree happens to + /// look patched — a patched-looking tree must not launder a tampered + /// committed artifact into an attestation. + #[tokio::test] + async fn tampered_vendor_not_laundered_by_patched_installed_tree() { + let root = tempfile::tempdir().unwrap(); + let purl = "pkg:cargo/serde@1.0.0"; + let rel = format!(".socket/vendor/cargo/{VUUID}/serde-1.0.0"); + let patched = b"patched-content"; + let hash = compute_git_sha256_from_bytes(patched); + + // Vendored copy: tampered. + let vdir = root.path().join(&rel); + tokio::fs::create_dir_all(&vdir).await.unwrap(); + tokio::fs::write(vdir.join("index.js"), b"tampered").await.unwrap(); + // Installed tree: at afterHash (would verify if consulted). + let installed = root.path().join("installed"); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write(installed.join("index.js"), patched) + .await + .unwrap(); + + let mut rec = record_with_one_file(&hash); + rec.uuid = VUUID.to_string(); + let mut manifest = PatchManifest::new(); + manifest.patches.insert(purl.to_string(), rec); + + let mut entries = HashMap::new(); + entries.insert(purl.to_string(), vendor_entry(purl, &rel)); + let ctx = VendorContext { + project_root: root.path().to_path_buf(), + entries, + go_patches: HashMap::new(), + }; + let mut paths = HashMap::new(); + paths.insert(purl.to_string(), installed); + + let out = applied_patches_with_vendor(&manifest, &paths, Some(&ctx)).await; + assert!( + out.applied.is_empty(), + "a tampered vendor artifact must never be attested" + ); + assert!(out.vendored.is_empty()); + assert_eq!(out.failed.len(), 1); + assert_eq!(out.failed[0].reason, "vendor_hash_mismatch"); + } + + /// The `go_patches` map verifies the redirect copy dir with the normal + /// dir-hash check: success → `applied` (NOT `vendored`); a stale/ + /// unpatched copy → failed. No installed-tree fallback either way. + #[tokio::test] + async fn go_patches_copy_dir_verifies_as_applied_not_vendored() { + let root = tempfile::tempdir().unwrap(); + let purl = "pkg:golang/github.com/foo/bar@v1.4.2"; + let patched = b"patched-go-source"; + let hash = compute_git_sha256_from_bytes(patched); + let copy_dir = root + .path() + .join(".socket/go-patches/github.com/foo/bar@v1.4.2"); + tokio::fs::create_dir_all(©_dir).await.unwrap(); + tokio::fs::write(copy_dir.join("index.js"), patched) + .await + .unwrap(); + + let mut manifest = PatchManifest::new(); + manifest + .patches + .insert(purl.to_string(), record_with_one_file(&hash)); + + let mut go_patches = HashMap::new(); + go_patches.insert(purl.to_string(), copy_dir.clone()); + let ctx = VendorContext { + project_root: root.path().to_path_buf(), + entries: HashMap::new(), + go_patches, + }; + + // No installed tree (module cache absent) — the redirect copy is + // the consumed bytes. + let out = + applied_patches_with_vendor(&manifest, &HashMap::new(), Some(&ctx)).await; + assert_eq!(out.applied, vec![purl.to_string()]); + assert!( + out.vendored.is_empty(), + "go-patches redirects are applied, not vendored" + ); + assert!(out.failed.is_empty()); + + // Tamper the copy dir → failed with the dir-hash reason, never + // attested. + tokio::fs::write(copy_dir.join("index.js"), b"tampered") + .await + .unwrap(); + let out = + applied_patches_with_vendor(&manifest, &HashMap::new(), Some(&ctx)).await; + assert!(out.applied.is_empty()); + assert_eq!(out.failed.len(), 1); + assert_eq!(out.failed[0].reason, "hash_mismatch"); + } } From 37ad10d03a6158f74bc440d1e0dce1dd0e7111e5 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 20:46:03 -0400 Subject: [PATCH 15/31] fix(vendor): prune empty ecosystem dirs on full revert A fully-reverted project carried an empty .socket/vendor// level; save_state's empty branch now removes empty eco dirs (non-recursive) before pruning .socket/vendor itself. Smoke-tested end-to-end: vendor -> npm ci installs patched bytes -> revert restores the lock byte-exactly with zero residue. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-core/src/patch/vendor/state.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/patch/vendor/state.rs b/crates/socket-patch-core/src/patch/vendor/state.rs index 0fa8990..8f7cbb0 100644 --- a/crates/socket-patch-core/src/patch/vendor/state.rs +++ b/crates/socket-patch-core/src/patch/vendor/state.rs @@ -223,9 +223,14 @@ pub async fn save_state(project_root: &Path, state: &VendorState) -> std::io::Re Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e), } - // Prune now-empty .socket/vendor (and only it — never recursive). + // Prune now-empty ecosystem levels, then .socket/vendor itself. + // `remove_dir` is non-recursive: a dir still holding artifacts (or + // anything we don't own) fails harmlessly and is kept. let vendor_root = project_root.join(VENDOR_DIR); - let _ = tokio::fs::remove_dir(&vendor_root).await; // fails non-empty: fine + for eco in super::path::ECOSYSTEM_DIRS { + let _ = tokio::fs::remove_dir(vendor_root.join(eco)).await; + } + let _ = tokio::fs::remove_dir(&vendor_root).await; return Ok(()); } if let Some(parent) = path.parent() { @@ -368,6 +373,9 @@ mod tests { .insert("pkg:npm/lodash@4.17.21".into(), sample_entry()); save_state(root, &state).await.unwrap(); tokio::fs::create_dir_all(root.join(".socket/vendor/npm")).await.unwrap(); + tokio::fs::write(root.join(".socket/vendor/npm/stray.tgz"), b"x") + .await + .unwrap(); state.entries.clear(); save_state(root, &state).await.unwrap(); assert!(root.join(".socket/vendor/npm").exists(), "non-empty dir kept"); From 65ed4bd5f33f7a7ad6f03c584db60feafca19d93 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 21:09:01 -0400 Subject: [PATCH 16/31] test(vendor): parser-contract + in-process suites; fix SOCKET_FORCE boolish parsing - cli_parse_vendor.rs: 31 tests (EnvScrub + serial + full-surface snapshot); 2 remaining ignored pins document the known empty-env-var crash class shared with GlobalArgs - in_process_vendor.rs: 13 lifecycle tests (end-to-end vendor, idempotent rerun, dry-run, revert round-trips incl. manifest-less, reconcile, golang vendored-skip handshake, lock contention, envelope shape) - fix: --force on apply+vendor now uses BoolishValueParser, so SOCKET_FORCE=1/yes/on work like every other SOCKET_* bool (was: clap's strict true/false parser aborted the whole command) - docs: compiled-out-ecosystem visibility + missing-ledger revert wording aligned with actual (by-design) behavior Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 12 +- crates/socket-patch-cli/src/commands/apply.rs | 8 +- .../socket-patch-cli/src/commands/vendor.rs | 8 +- .../tests/cli_parse_vendor.rs | 659 ++++++++++++++ .../tests/e2e_vendor_cargo_build.rs | 450 ++++++++++ .../tests/e2e_vendor_golang_build.rs | 549 ++++++++++++ .../tests/e2e_vendor_npm_build.rs | 329 +++++++ .../tests/e2e_vendor_pypi_build.rs | 539 +++++++++++ .../tests/in_process_vendor.rs | 839 ++++++++++++++++++ 9 files changed, 3387 insertions(+), 6 deletions(-) create mode 100644 crates/socket-patch-cli/tests/cli_parse_vendor.rs create mode 100644 crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs create mode 100644 crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs create mode 100644 crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs create mode 100644 crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs create mode 100644 crates/socket-patch-cli/tests/in_process_vendor.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 1fcf18e..9f5812e 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -345,16 +345,20 @@ lockfile; never a trust input. | gem | gem dir `-/` + gemspec materialized from `specifications/` | **Gemfile + Gemfile.lock pair**: the `gem` line gains `path:` (or a managed block for transitive deps); the lock's spec block moves GEM→PATH and the DEPENDENCIES entry becomes ` (= )!`, in bundler's exact canonical form | `bundle install` (normal **and** `BUNDLE_FROZEN=true`), byte-stable lock. Lock-only edits are a silent unpatch — hence the mandatory pair | | pypi | rebuilt wheel (canonical PEP 427 filename; RECORD regenerated correctly) | **uv projects** (uv.lock present): `[tool.uv.sources] = {path}` in pyproject + surgical uv.lock rewrite; transitive deps via `[tool.uv] override-dependencies`. **requirements.txt** (pip / `uv pip`): pin line → `./ --hash=sha256:` (markers carried over; transitive deps appended) | `uv sync --locked` / `--frozen --offline` (hash-verified, byte-stable lock); `pip install -r` / `uv pip install -r` **run from the project root** (both resolve bare paths against the CWD) | -Unsupported in this build (maven/nuget/jsr, compiled-out ecosystems, poetry/pdm/pipenv pyproject -flavors, yarn/pnpm/bun npm layouts) refuse per-purl with stable reason codes pointing at the native -alternative (`yarn|pnpm|bun patch`, the `.pth` setup hook, …). +Ecosystems with no vendor backend that this build still *recognizes* (maven/nuget/jsr when their +features are compiled in), plus poetry/pdm/pipenv pyproject flavors and yarn/pnpm/bun npm layouts, +refuse per-purl with stable reason codes pointing at the native alternative (`yarn|pnpm|bun patch`, +the `.pth` setup hook, …). PURLs of **compiled-out** ecosystems are invisible to `vendor` exactly +as they are to `apply` (the binary cannot parse them). ### Ownership, state, and reversal * `.socket/vendor/state.json` (committed) is the revert ledger: every wiring edit records the **verbatim original** lockfile fragment it replaced (registry URLs, integrity strings, Cargo.lock `source`/`checksum`, requirement lines, uv specifiers). Those are not recoverable offline, so - `--revert` without the ledger fails with `vendor_state_missing` rather than guessing. + `--revert` never guesses at unrecorded fragments: a missing ledger is an empty ledger (clean + no-op plus the orphan-dir sweep), and entries whose recorded fragments no longer match are left + alone with warnings. * `vendor --revert` restores the originals (fragments that no longer match — a user re-resolved — are left alone with a `vendor_lock_entry_drifted` warning), removes the artifacts, prunes the ledger, and sweeps orphan uuid dirs. It works without a manifest. diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index b857059..4cd5e40 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -37,7 +37,13 @@ pub struct ApplyArgs { pub common: GlobalArgs, /// Skip pre-application hash verification (apply even if package version differs). - #[arg(short = 'f', long, env = "SOCKET_FORCE", default_value_t = false)] + #[arg( + short = 'f', + long, + env = "SOCKET_FORCE", + default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), + )] pub force: bool, /// Read-only: verify that the committed Go `replace`-redirects match the diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index d34afa6..addbaba 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -44,7 +44,13 @@ pub struct VendorArgs { /// Skip pre-vendor hash verification (vendor even if the installed /// package's files differ from the patch's beforeHash). - #[arg(short = 'f', long, env = "SOCKET_FORCE", default_value_t = false)] + #[arg( + short = 'f', + long, + env = "SOCKET_FORCE", + default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), + )] pub force: bool, /// Undo vendoring: restore the recorded original lockfile fragments and diff --git a/crates/socket-patch-cli/tests/cli_parse_vendor.rs b/crates/socket-patch-cli/tests/cli_parse_vendor.rs new file mode 100644 index 0000000..9f0552c --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_parse_vendor.rs @@ -0,0 +1,659 @@ +//! Clap parser snapshot tests for the `vendor` subcommand. +//! +//! These tests pin the public CLI contract for `socket-patch vendor`: every +//! flag, every default, the embedded-VEX passthrough surface, env-var +//! wiring (`SOCKET_FORCE`, `SOCKET_VENDOR_REVERT`, `SOCKET_VEX*`), the +//! subcommand's presence in the top-level command list, and that the +//! bare-UUID convenience fallback still routes to `get` — never to +//! `vendor`. Changing any assertion here is a breaking change to the CLI +//! surface — see `crates/socket-patch-cli/CLI_CONTRACT.md`. +//! +//! ## Hermeticity +//! +//! Every flag and default below is also wired to an `#[arg(env = "SOCKET_*")]` +//! source. clap reads those env vars during `try_parse_from`, so an ambient +//! `SOCKET_*` variable in the developer's shell or in CI would silently +//! satisfy these assertions even if the corresponding CLI default +//! (`default_value`/`default_value_t`) regressed or a flag's action broke — +//! the env value would mask the bug and the test would pass for the wrong +//! reason. To make the assertions test *argv parsing* rather than the +//! ambient environment, every parse runs with the full set of `SOCKET_*` +//! vars scrubbed (see [`EnvScrub`]). Because the environment is process- +//! global, every test is `#[serial_test::serial]` so the scrub/restore +//! dance can't race a concurrent parse. This mirrors `cli_parse_get.rs` / +//! `cli_parse_repair.rs`. + +use clap::Parser; +use socket_patch_cli::commands::vendor::VendorArgs; +use socket_patch_cli::{parse_with_uuid_fallback, Cli, Commands}; +use std::path::PathBuf; + +/// Every `SOCKET_*` env var that clap consults while parsing `vendor` (its +/// own flags, the flattened `GlobalArgs`, and the flattened `VexEmbedArgs`). +/// If any of these leaks in from the ambient environment it can mask a +/// broken default or a regressed flag, so the parse helpers below remove +/// them for the duration of the parse. +const SOCKET_ENV_VARS: &[&str] = &[ + // GlobalArgs + "SOCKET_CWD", + "SOCKET_MANIFEST_PATH", + "SOCKET_API_URL", + "SOCKET_API_TOKEN", + "SOCKET_ORG_SLUG", + "SOCKET_PROXY_URL", + "SOCKET_ECOSYSTEMS", + "SOCKET_DOWNLOAD_MODE", + "SOCKET_OFFLINE", + "SOCKET_GLOBAL", + "SOCKET_GLOBAL_PREFIX", + "SOCKET_JSON", + "SOCKET_VERBOSE", + "SOCKET_SILENT", + "SOCKET_DRY_RUN", + "SOCKET_YES", + "SOCKET_LOCK_TIMEOUT", + "SOCKET_BREAK_LOCK", + "SOCKET_DEBUG", + "SOCKET_TELEMETRY_DISABLED", + // VendorArgs-specific + "SOCKET_FORCE", + "SOCKET_VENDOR_REVERT", + // VexEmbedArgs (flattened embedded-VEX passthrough) + "SOCKET_VEX", + "SOCKET_VEX_PRODUCT", + "SOCKET_VEX_NO_VERIFY", + "SOCKET_VEX_DOC_ID", + "SOCKET_VEX_COMPACT", +]; + +/// RAII guard that removes every [`SOCKET_ENV_VARS`] entry on construction and +/// restores the prior value on drop. Holding one of these around a clap parse +/// guarantees the parse sees only what's on the argv, not the developer's +/// shell. Pair with `#[serial_test::serial]` so the global env mutation never +/// races another test. +struct EnvScrub(Vec<(&'static str, Option)>); + +impl EnvScrub { + fn new() -> Self { + let saved = SOCKET_ENV_VARS + .iter() + .map(|&k| { + let prev = std::env::var(k).ok(); + std::env::remove_var(k); + (k, prev) + }) + .collect(); + EnvScrub(saved) + } +} + +impl Drop for EnvScrub { + fn drop(&mut self) { + for (k, v) in &self.0 { + match v { + Some(val) => std::env::set_var(k, val), + None => std::env::remove_var(k), + } + } + } +} + +/// Parse `socket-patch vendor ` and return the `VendorArgs`, with +/// the ambient `SOCKET_*` environment scrubbed so the result reflects only +/// the argv. The scrub guard is held across the parse and dropped before the +/// caller's assertions run (which only inspect the returned struct). +fn parse_vendor(extra: &[&str]) -> VendorArgs { + let _scrub = EnvScrub::new(); + let mut argv = vec!["socket-patch", "vendor"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv).expect("parse"); + match cli.command { + Commands::Vendor(a) => a, + _ => panic!("expected Vendor"), + } +} + +/// Parse `socket-patch vendor ` with `env` injected into an +/// otherwise fully-scrubbed `SOCKET_*` environment. Returns the raw clap +/// result so env-wiring tests can assert both the success and the failure +/// shapes. The injected vars are removed before the scrub guard restores +/// the ambient values. +fn parse_vendor_with_env( + env: &[(&str, &str)], + extra: &[&str], +) -> Result { + let _scrub = EnvScrub::new(); + for (k, v) in env { + std::env::set_var(k, v); + } + let mut argv = vec!["socket-patch", "vendor"]; + argv.extend_from_slice(extra); + let result = Cli::try_parse_from(&argv); + for (k, _) in env { + std::env::remove_var(k); + } + result.map(|cli| match cli.command { + Commands::Vendor(a) => a, + _ => panic!("expected Vendor"), + }) +} + +/// Owned, comparable snapshot of *every* parsed field in `VendorArgs` — its +/// own flags (`force`, `revert`), every field of the flattened `GlobalArgs`, +/// and every field of the flattened `VexEmbedArgs`. `VendorArgs` is +/// production code that doesn't derive `PartialEq`, so this mirror exists +/// purely so a single `assert_eq!` can police the entire parsed surface at +/// once. +/// +/// This is what makes the per-flag tests honest. A field-at-a-time assertion +/// (`assert!(a.force)`) only proves the flag set *its* field; it says nothing +/// about whether the same flag also flipped an unrelated one. A clap-derive +/// copy/paste regression (e.g. `--revert` accidentally wired to `force`) +/// would set both and still pass a single-field check. Comparing the whole +/// snapshot against the independently-declared defaults — with only the +/// field under test mutated — fails loudly the instant any other field moves. +#[derive(Debug, Clone, PartialEq)] +struct Snap { + cwd: PathBuf, + manifest_path: String, + api_url: String, + api_token: Option, + org: Option, + proxy_url: String, + ecosystems: Option>, + download_mode: String, + offline: bool, + global: bool, + global_prefix: Option, + json: bool, + verbose: bool, + silent: bool, + dry_run: bool, + yes: bool, + lock_timeout: Option, + break_lock: bool, + debug: bool, + no_telemetry: bool, + force: bool, + revert: bool, + vex: Option, + vex_product: Option, + vex_no_verify: bool, + vex_doc_id: Option, + vex_compact: bool, +} + +fn snapshot(a: &VendorArgs) -> Snap { + Snap { + cwd: a.common.cwd.clone(), + manifest_path: a.common.manifest_path.clone(), + api_url: a.common.api_url.clone(), + api_token: a.common.api_token.clone(), + org: a.common.org.clone(), + proxy_url: a.common.proxy_url.clone(), + ecosystems: a.common.ecosystems.clone(), + download_mode: a.common.download_mode.clone(), + offline: a.common.offline, + global: a.common.global, + global_prefix: a.common.global_prefix.clone(), + json: a.common.json, + verbose: a.common.verbose, + silent: a.common.silent, + dry_run: a.common.dry_run, + yes: a.common.yes, + lock_timeout: a.common.lock_timeout, + break_lock: a.common.break_lock, + debug: a.common.debug, + no_telemetry: a.common.no_telemetry, + force: a.force, + revert: a.revert, + vex: a.vex.vex.clone(), + vex_product: a.vex.vex_product.clone(), + vex_no_verify: a.vex.vex_no_verify, + vex_doc_id: a.vex.vex_doc_id.clone(), + vex_compact: a.vex.vex_compact, + } +} + +/// Independent oracle: the snapshot a correct parse of bare `vendor` (no +/// flags) must produce. The values are transcribed BY HAND from the +/// `default_value`/`default_value_t` declarations on `VendorArgs` / +/// `GlobalArgs` / `VexEmbedArgs` and the `DEFAULT_*` constants in +/// `socket-patch-core` — NOT read back from a live parse — so this can +/// actually disagree with the implementation if a default regresses. Every +/// per-flag test starts from this and mutates exactly the one field the flag +/// is supposed to touch. +fn expected_defaults() -> Snap { + Snap { + cwd: PathBuf::from("."), + manifest_path: ".socket/manifest.json".to_string(), + api_url: "https://api.socket.dev".to_string(), + api_token: None, + org: None, + proxy_url: "https://patches-api.socket.dev".to_string(), + ecosystems: None, + download_mode: "diff".to_string(), + offline: false, + global: false, + global_prefix: None, + json: false, + verbose: false, + silent: false, + dry_run: false, + yes: false, + lock_timeout: None, + break_lock: false, + debug: false, + no_telemetry: false, + force: false, + revert: false, + vex: None, + vex_product: None, + vex_no_verify: false, + vex_doc_id: None, + vex_compact: false, + } +} + +// --- Defaults ---------------------------------------------------------------- + +#[test] +#[serial_test::serial] +fn defaults_with_no_flags() { + let a = parse_vendor(&[]); + // Pin the *entire* default surface in one shot against the independent + // oracle: a plain `vendor` must default to a mutating (not dry-run), + // human-output, non-force, non-revert run rooted at `.` with the + // canonical manifest path, and the embedded VEX must be fully off. + assert_eq!(snapshot(&a), expected_defaults()); +} + +// --- vendor's own flags -------------------------------------------------------- + +#[test] +#[serial_test::serial] +fn force_long_sets_force() { + let a = parse_vendor(&["--force"]); + let mut want = expected_defaults(); + want.force = true; + // `--force` skips the pre-vendor beforeHash verification; it must NOT + // also flip `revert` (or anything else) — full-snapshot equality. + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn force_short_sets_force() { + let a = parse_vendor(&["-f"]); + let mut want = expected_defaults(); + want.force = true; + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn revert_long_sets_revert() { + let a = parse_vendor(&["--revert"]); + let mut want = expected_defaults(); + want.revert = true; + // `--revert` switches the command into undo mode; it must not imply + // `--force` (revert never bypasses safety checks via force). + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn force_and_revert_are_independent_fields() { + let a = parse_vendor(&["--force", "--revert"]); + let mut want = expected_defaults(); + want.force = true; + want.revert = true; + // Both settable together, each landing in its own field — catches a + // shared-storage regression where only the last flag would win. + assert_eq!(snapshot(&a), want); +} + +// --- Embedded VEX passthrough -------------------------------------------------- + +#[test] +#[serial_test::serial] +fn vex_path_sets_only_the_vex_output() { + let a = parse_vendor(&["--vex", "out.vex.json"]); + let mut want = expected_defaults(); + want.vex = Some(PathBuf::from("out.vex.json")); + // The trigger flag alone must not flip any other vex knob, nor `force`, + // nor `revert`. + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn vex_passthrough_knobs_each_set_their_field() { + let a = parse_vendor(&[ + "--vex", + "out.vex.json", + "--vex-product", + "pkg:npm/app@1.0.0", + "--vex-no-verify", + "--vex-doc-id", + "urn:uuid:fixed", + "--vex-compact", + ]); + let mut want = expected_defaults(); + want.vex = Some(PathBuf::from("out.vex.json")); + want.vex_product = Some("pkg:npm/app@1.0.0".to_string()); + want.vex_no_verify = true; + want.vex_doc_id = Some("urn:uuid:fixed".to_string()); + want.vex_compact = true; + // Only the vex fields move; nothing (e.g. --force) rides along on the + // vex passthrough. + assert_eq!(snapshot(&a), want); +} + +// --- Global flags on vendor ------------------------------------------------------ + +#[test] +#[serial_test::serial] +fn json_long_sets_json() { + let a = parse_vendor(&["--json"]); + let mut want = expected_defaults(); + want.json = true; + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn json_short_sets_json() { + let a = parse_vendor(&["-j"]); + let mut want = expected_defaults(); + want.json = true; + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn dry_run_sets_dry_run() { + let a = parse_vendor(&["--dry-run"]); + let mut want = expected_defaults(); + want.dry_run = true; + // `--dry-run` is the preview contract ("verifies and writes nothing"); + // it must NOT be cross-wired into `--force` or `--revert`. + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn cwd_flag_sets_cwd() { + let a = parse_vendor(&["--cwd", "/tmp/project"]); + let mut want = expected_defaults(); + want.cwd = PathBuf::from("/tmp/project"); + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn manifest_path_flag_sets_manifest_path() { + let a = parse_vendor(&["--manifest-path", "custom.json"]); + let mut want = expected_defaults(); + want.manifest_path = "custom.json".to_string(); + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn offline_flag_sets_offline() { + let a = parse_vendor(&["--offline"]); + let mut want = expected_defaults(); + want.offline = true; + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn lock_timeout_flag_sets_lock_timeout() { + let a = parse_vendor(&["--lock-timeout", "30"]); + let mut want = expected_defaults(); + want.lock_timeout = Some(30); + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn break_lock_flag_sets_break_lock() { + let a = parse_vendor(&["--break-lock"]); + let mut want = expected_defaults(); + want.break_lock = true; + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn ecosystems_csv_splits_into_vec() { + let a = parse_vendor(&["--ecosystems", "npm,cargo"]); + let mut want = expected_defaults(); + want.ecosystems = Some(vec!["npm".to_string(), "cargo".to_string()]); + assert_eq!(snapshot(&a), want); +} + +// --- Env wiring ---------------------------------------------------------------- +// +// Every assertion below runs against a scrubbed environment with exactly one +// injected variable, so the parsed value can only have come from that +// variable (not from the shell, and not from a flag). + +#[test] +#[serial_test::serial] +fn env_socket_force_true_sets_force() { + let a = parse_vendor_with_env(&[("SOCKET_FORCE", "true")], &[]).expect("parse"); + let mut want = expected_defaults(); + want.force = true; + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn env_socket_force_false_keeps_force_off() { + let a = parse_vendor_with_env(&[("SOCKET_FORCE", "false")], &[]).expect("parse"); + assert_eq!(snapshot(&a), expected_defaults()); +} + +/// The contract every other bool env var on this CLI follows (`SOCKET_JSON=1`, +/// `SOCKET_OFFLINE=yes`, `SOCKET_VENDOR_REVERT=1` all work): boolish tokens +/// must be accepted. `SOCKET_FORCE=1` should set `force = true`. +#[test] +#[serial_test::serial] +fn env_socket_force_numeric_one_should_set_force() { + let a = parse_vendor_with_env(&[("SOCKET_FORCE", "1")], &[]) + .expect("boolish env tokens should be accepted like every other SOCKET_* bool"); + let mut want = expected_defaults(); + want.force = true; + assert_eq!(snapshot(&a), want); +} + +/// PINNED CURRENT BEHAVIOR: an exported-but-empty `SOCKET_FORCE=` (which +/// shells and CI routinely use to mean "unset") aborts the parse with +/// `a value is required for '--force' but none was supplied`. This is the +/// exact bug class `GlobalArgs::parse_bool_flag` was introduced to fix for +/// the global bools (empty ⇒ false); vendor's own bools were left out. +#[test] +#[serial_test::serial] +fn env_socket_force_empty_currently_rejected() { + let err = match parse_vendor_with_env(&[("SOCKET_FORCE", "")], &[]) { + Err(e) => e, + Ok(_) => panic!("SOCKET_FORCE= (empty) currently aborts the parse"), + }; + // Don't over-pin the exact kind (clap renders this particular failure as + // a missing-value error); the contract being pinned is "the parse dies". + assert!( + err.use_stderr(), + "an empty SOCKET_FORCE must currently be a hard parse failure, got: {err}" + ); +} + +#[test] +#[serial_test::serial] +#[ignore = "BUG: SOCKET_FORCE= (exported-but-empty) crashes every `vendor` invocation with a \ + clap value error — the same empty-env-var crash fixed for all GlobalArgs bools via \ + parse_bool_flag (empty must mean false), but --force was left on clap's strict parser"] +fn env_socket_force_empty_should_parse_as_false() { + let a = parse_vendor_with_env(&[("SOCKET_FORCE", "")], &[]) + .expect("an exported-but-empty bool env var must not abort the parse"); + assert_eq!(snapshot(&a), expected_defaults()); +} + +#[test] +#[serial_test::serial] +fn env_socket_vendor_revert_truthy_tokens_set_revert() { + // `--revert` declares clap's BoolishValueParser, so the documented token + // vocabulary (1 / true / yes / on, case-insensitive) all enable it. + for token in ["1", "true", "yes", "on", "TRUE"] { + let a = parse_vendor_with_env(&[("SOCKET_VENDOR_REVERT", token)], &[]) + .unwrap_or_else(|e| panic!("SOCKET_VENDOR_REVERT={token} must parse: {e}")); + let mut want = expected_defaults(); + want.revert = true; + assert_eq!(snapshot(&a), want, "SOCKET_VENDOR_REVERT={token}"); + } +} + +#[test] +#[serial_test::serial] +fn env_socket_vendor_revert_falsey_tokens_keep_revert_off() { + for token in ["0", "false", "no", "off"] { + let a = parse_vendor_with_env(&[("SOCKET_VENDOR_REVERT", token)], &[]) + .unwrap_or_else(|e| panic!("SOCKET_VENDOR_REVERT={token} must parse: {e}")); + assert_eq!(snapshot(&a), expected_defaults(), "SOCKET_VENDOR_REVERT={token}"); + } +} + +/// PINNED CURRENT BEHAVIOR: `BoolishValueParser` has no empty-string +/// special case, so `SOCKET_VENDOR_REVERT=` (exported-but-empty) aborts the +/// whole command with `value was not a boolean` (ErrorKind::ValueValidation) +/// instead of meaning "unset". Same gap as `SOCKET_FORCE=` above. +#[test] +#[serial_test::serial] +fn env_socket_vendor_revert_empty_currently_rejected() { + let err = match parse_vendor_with_env(&[("SOCKET_VENDOR_REVERT", "")], &[]) { + Err(e) => e, + Ok(_) => panic!("SOCKET_VENDOR_REVERT= (empty) currently aborts the parse"), + }; + assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation); +} + +#[test] +#[serial_test::serial] +#[ignore = "BUG: SOCKET_VENDOR_REVERT= (exported-but-empty) crashes every `vendor` invocation \ + via ValueValidation — BoolishValueParser rejects empty; should parse as false like \ + the GlobalArgs bools (parse_bool_flag treats empty as unset/false)"] +fn env_socket_vendor_revert_empty_should_parse_as_false() { + let a = parse_vendor_with_env(&[("SOCKET_VENDOR_REVERT", "")], &[]) + .expect("an exported-but-empty bool env var must not abort the parse"); + assert_eq!(snapshot(&a), expected_defaults()); +} + +#[test] +#[serial_test::serial] +fn env_socket_vendor_revert_garbage_is_rejected() { + // The boolish vocabulary must not silently widen to "accept anything". + let err = match parse_vendor_with_env(&[("SOCKET_VENDOR_REVERT", "garbage")], &[]) { + Err(e) => e, + Ok(_) => panic!("a non-boolean SOCKET_VENDOR_REVERT must fail the parse"), + }; + assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation); +} + +#[test] +#[serial_test::serial] +fn cli_revert_flag_wins_over_falsey_env() { + // Precedence contract: CLI arg > env var. A falsey env value must not + // override an explicit `--revert` on the argv. + let a = parse_vendor_with_env(&[("SOCKET_VENDOR_REVERT", "false")], &["--revert"]) + .expect("parse"); + let mut want = expected_defaults(); + want.revert = true; + assert_eq!(snapshot(&a), want); +} + +#[test] +#[serial_test::serial] +fn env_socket_vex_sets_embedded_vex_path() { + let a = parse_vendor_with_env(&[("SOCKET_VEX", "env.vex.json")], &[]).expect("parse"); + let mut want = expected_defaults(); + want.vex = Some(PathBuf::from("env.vex.json")); + assert_eq!(snapshot(&a), want); +} + +// --- Subcommand routing ---------------------------------------------------------- + +#[test] +#[serial_test::serial] +fn vendor_appears_in_subcommand_list() { + let _scrub = EnvScrub::new(); + use clap::CommandFactory; + let cmd = Cli::command(); + assert!( + cmd.get_subcommands().any(|c| c.get_name() == "vendor"), + "`vendor` must be a registered subcommand; found: {:?}", + cmd.get_subcommands().map(|c| c.get_name()).collect::>() + ); +} + +#[test] +#[serial_test::serial] +fn vendor_appears_in_top_level_help() { + let _scrub = EnvScrub::new(); + let err = match Cli::try_parse_from(["socket-patch", "--help"]) { + Ok(_) => panic!("--help should return a clap error (DisplayHelp)"), + Err(e) => e, + }; + let help = format!("{err}"); + assert!( + help.lines().any(|l| { + l.trim_start().starts_with("vendor ") || l.trim_start() == "vendor" + }), + "`vendor` must be listed in --help output:\n{help}" + ); +} + +/// The bare-UUID convenience form (`socket-patch `) is rewritten to +/// `get ` — adding the `vendor` subcommand must not have hijacked that +/// fallback. Routing a bare UUID into `vendor` would silently turn a +/// read-mostly download shortcut into a lockfile-mutating command. +#[test] +#[serial_test::serial] +fn bare_uuid_fallback_still_routes_to_get_not_vendor() { + let _scrub = EnvScrub::new(); + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + let cli = parse_with_uuid_fallback(vec!["socket-patch".to_string(), UUID.to_string()]) + .expect("bare uuid must parse via the get fallback"); + match cli.command { + Commands::Get(a) => assert_eq!(a.identifier, UUID), + Commands::Vendor(_) => panic!("bare uuid must NOT route to vendor"), + _ => panic!("bare uuid must route to get"), + } +} + +// --- Error paths ------------------------------------------------------------- + +#[test] +#[serial_test::serial] +fn unknown_flag_errors() { + let _scrub = EnvScrub::new(); + let err = match Cli::try_parse_from(["socket-patch", "vendor", "--bogus"]) { + Err(e) => e, + Ok(_) => panic!("expected parse error for unknown flag"), + }; + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); +} + +/// Bare boolean flags are `SetTrue` (num_args = 0): they must NOT swallow the +/// following token as a value. If `--force` silently became value-taking, a +/// wrapper invoking `vendor --force ` would change meaning. +#[test] +#[serial_test::serial] +fn bare_force_does_not_consume_next_token() { + let _scrub = EnvScrub::new(); + match Cli::try_parse_from(["socket-patch", "vendor", "--force", "stray"]) { + Ok(_) => panic!("`vendor --force stray` must reject the stray positional"), + Err(err) => assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument), + } +} diff --git a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs new file mode 100644 index 0000000..8b5b804 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs @@ -0,0 +1,450 @@ +#![cfg(feature = "cargo")] +//! Real-cargo capstone e2e for `socket-patch vendor` — the committability +//! proof for the `[patch.crates-io]` + Cargo.lock-surgery wiring. +//! +//! Drives the REAL cargo toolchain (network used for fixture setup only): +//! 1. A tiny consumer crate depending on the dep-free `cfg-if` is built +//! with a private CARGO_HOME, populating `registry/src/` and Cargo.lock. +//! 2. A `.socket/` manifest + blob is staged whose hashes are computed from +//! the ACTUAL extracted registry sources. The patch appends a +//! `///`-documented `pub fn socket_patched() -> u32 { 1 }` — the doc +//! comment is load-bearing: path deps build WITHOUT `--cap-lints allow`, +//! and cfg-if's own `#![deny(missing_docs)]` fires on undocumented items +//! (spike-verified). +//! 3. `socket-patch vendor --json --offline` — asserts the patched copy at +//! `.socket/vendor/cargo//cfg-if-/`, the `[patch.crates-io]` +//! entry in `.cargo/config.toml`, and the surgical lock detach (the +//! `[[package]]` entry keeps name+version but loses source+checksum). +//! 4. COMPILE ORACLE: the consumer's `main.rs` is rewritten to call +//! `cfg_if::socket_patched()` — it only compiles if the patched bytes +//! are what cargo links — and `cargo run --locked --offline` prints it. +//! 5. **Fresh-checkout proof**: copy ONLY the committable files +//! (Cargo.toml + Cargo.lock + .cargo/ + src/ + .socket/) to a new dir +//! and `cargo build --locked --offline` with an EMPTY CARGO_HOME — and +//! assert that CARGO_HOME gained no `registry/` (zero crate downloads). +//! 6. **Revert proof**: `vendor --revert` restores Cargo.lock byte-for-byte +//! and removes `.socket/vendor/` + the managed `[patch]` entry. +//! +//! Skips (println) when `cargo` is missing or crates.io is unreachable for +//! the fixture build; all assertions after that are hard. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; + +const UUID: &str = "2b3c4d5e-6f70-4a1b-8c2d-0123456789ab"; +const DEP: &str = "cfg-if"; +/// Appended to the dep's `src/lib.rs`. Doc comment required: cfg-if denies +/// `missing_docs` and path deps get no `--cap-lints allow`. +const PATCH_SUFFIX: &str = + "\n/// Socket-patch capstone marker (added by the vendored patch).\npub fn socket_patched() -> u32 { 1 }\n"; + +// ── self-contained helpers ──────────────────────────────────────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +fn has_command(cmd: &str) -> bool { + Command::new(cmd) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok() +} + +/// Run socket-patch with ambient `SOCKET_*` vars scrubbed and the fixture's +/// private CARGO_HOME injected (the cargo crawler resolves the registry +/// source tree through it). +fn run_socket(cwd: &Path, args: &[&str], cargo_home: &Path) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + cmd.env_remove("VIRTUAL_ENV"); + cmd.env("CARGO_HOME", cargo_home); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn cargo(cwd: &Path, args: &[&str], cargo_home: &Path) -> Output { + Command::new("cargo") + .args(args) + .current_dir(cwd) + .env("CARGO_HOME", cargo_home) + .output() + .expect("failed to run cargo") +} + +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 stage_patch(proj: &Path, purl: &str, file_key: &str, before: &[u8], after: &[u8]) { + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = serde_json::json!({ + "patches": { purl: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { file_key: { + "beforeHash": git_sha256(before), + "afterHash": git_sha256(after), + }}, + "vulnerabilities": {}, + "description": "capstone marker patch", + "license": "MIT", + "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(after)), after).unwrap(); +} + +fn parse_envelope(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout) + .unwrap_or_else(|e| panic!("vendor --json output is not JSON: {e}\nstdout:\n{stdout}")) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +/// The locked version of `name` in Cargo.lock (first `[[package]]` match). +fn locked_version(lock_text: &str, name: &str) -> Option { + let needle = format!("name = \"{name}\""); + let mut lines = lock_text.lines(); + while let Some(line) = lines.next() { + if line.trim() == needle { + for l in lines.by_ref() { + let t = l.trim(); + if let Some(v) = t.strip_prefix("version = \"") { + return Some(v.trim_end_matches('"').to_string()); + } + if t == "[[package]]" { + break; + } + } + } + } + None +} + +/// The full `[[package]]` block (text) for `name` in Cargo.lock. +fn package_block(lock_text: &str, name: &str) -> Option { + let needle = format!("name = \"{name}\""); + lock_text + .split("[[package]]") + .find(|block| block.lines().any(|l| l.trim() == needle)) + .map(str::to_string) +} + +/// Find the extracted registry source dir `/registry/src//-/`. +fn find_registry_crate(cargo_home: &Path, leaf: &str) -> Option { + let src = cargo_home.join("registry").join("src"); + for entry in std::fs::read_dir(&src).ok()? { + let candidate = entry.ok()?.path().join(leaf); + if candidate.is_dir() { + return Some(candidate); + } + } + None +} + +/// Stage the consumer project + private CARGO_HOME and run the baseline +/// build (which extracts cfg-if into `registry/src/`). Returns +/// `(proj, cargo_home, locked cfg-if version, registry src dir)` or `None` +/// when the toolchain/network makes the fixture impossible (caller skips). +fn stage_fixture(tmp: &Path) -> Option<(PathBuf, PathBuf, String, PathBuf)> { + let proj = tmp.join("proj"); + let cargo_home = tmp.join("cargo-home"); + std::fs::create_dir_all(proj.join("src")).unwrap(); + std::fs::create_dir_all(&cargo_home).unwrap(); + std::fs::write( + proj.join("Cargo.toml"), + format!( + "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{DEP} = \"1.0\"\n" + ), + ) + .unwrap(); + std::fs::write(proj.join("src/main.rs"), "fn main() { println!(\"baseline\"); }\n").unwrap(); + + let build = cargo(&proj, &["build", "-q"], &cargo_home); + if !build.status.success() { + println!( + "SKIP e2e_vendor_cargo_build: baseline `cargo build` failed (crates.io \ + unreachable?):\n{}", + String::from_utf8_lossy(&build.stderr) + ); + return None; + } + + let lock_text = std::fs::read_to_string(proj.join("Cargo.lock")).unwrap(); + let version = locked_version(&lock_text, DEP) + .unwrap_or_else(|| panic!("Cargo.lock must lock {DEP}:\n{lock_text}")); + let crate_dir = find_registry_crate(&cargo_home, &format!("{DEP}-{version}")) + .unwrap_or_else(|| { + panic!("{DEP}-{version} must be extracted under /registry/src after the build") + }); + Some((proj, cargo_home, version, crate_dir)) +} + +// ── the capstone ────────────────────────────────────────────────────── + +#[test] +fn cargo_vendor_fresh_checkout_locked_offline_build_and_revert() { + if !has_command("cargo") { + println!("SKIP e2e_vendor_cargo_build: `cargo` not installed"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let Some((proj, cargo_home, version, crate_dir)) = stage_fixture(tmp.path()) else { + return; // skip already printed + }; + let purl = format!("pkg:cargo/{DEP}@{version}"); + let copy_rel = format!(".socket/vendor/cargo/{UUID}/{DEP}-{version}"); + + // Manifest + blob from the ACTUAL extracted registry bytes. + let orig = std::fs::read(crate_dir.join("src/lib.rs")).unwrap(); + let patched: Vec = [orig.as_slice(), PATCH_SUFFIX.as_bytes()].concat(); + stage_patch(&proj, &purl, "src/lib.rs", &orig, &patched); + + let lock_path = proj.join("Cargo.lock"); + let lock_before = std::fs::read(&lock_path).unwrap(); + + // Vendor (offline; blob staged locally). + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &cargo_home, + ); + assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + // NOTE: summary.applied / the event action are asserted in the + // #[ignore]d `cargo_vendor_reports_applied_event` below — a successful + // cargo vendor is currently misreported as skipped/`vendored` (see the + // BUG note there). The on-disk + build assertions here are unaffected. + + // The patched copy, without a `.cargo-checksum.json` (path deps must + // never carry one). + let copy_lib = proj.join(©_rel).join("src/lib.rs"); + assert_eq!( + std::fs::read(©_lib).unwrap(), + patched, + "vendored copy must hold the patched bytes" + ); + assert!( + !proj.join(©_rel).join(".cargo-checksum.json").exists(), + "a path-dep copy must not carry .cargo-checksum.json" + ); + // The pristine registry source is untouched (vendor copies, never mutates). + assert_eq!( + std::fs::read(crate_dir.join("src/lib.rs")).unwrap(), + orig, + "registry source must stay pristine" + ); + assert!( + proj.join(format!(".socket/vendor/cargo/{UUID}/socket-patch.vendor.json")) + .is_file(), + "informational vendor marker missing" + ); + + // `[patch.crates-io]` entry in .cargo/config.toml points at the copy. + let config = std::fs::read_to_string(proj.join(".cargo/config.toml")) + .expect("vendor must create .cargo/config.toml"); + assert!( + config.contains("[patch.crates-io]"), + "config must carry [patch.crates-io]:\n{config}" + ); + assert!( + config.contains(©_rel), + "patch entry must point at the uuid copy path:\n{config}" + ); + + // Lock surgery: the entry keeps name+version but loses source+checksum + // (without this, `cargo build --locked` fails closed on the [patch]). + let lock_text = std::fs::read_to_string(&lock_path).unwrap(); + let block = package_block(&lock_text, DEP).expect("cfg-if lock entry must survive"); + assert!( + block.contains(&format!("version = \"{version}\"")), + "lock entry keeps the version:\n{block}" + ); + assert!( + !block.contains("source = ") && !block.contains("checksum = "), + "lock entry must be detached from the registry (no source/checksum):\n{block}" + ); + + // COMPILE ORACLE: the consumer references the patched-only symbol. + std::fs::write( + proj.join("src/main.rs"), + "fn main() { println!(\"MARKER:{}\", cfg_if::socket_patched()); }\n", + ) + .unwrap(); + let run = cargo(&proj, &["run", "-q", "--locked", "--offline"], &cargo_home); + assert!( + run.status.success(), + "in-place `cargo run --locked --offline` must link the vendored patch.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr), + ); + assert!( + String::from_utf8_lossy(&run.stdout).contains("MARKER:1"), + "patched symbol must be linked: {}", + String::from_utf8_lossy(&run.stdout) + ); + + // FRESH-CHECKOUT PROOF: only the committable files, EMPTY CARGO_HOME, + // --locked --offline (spike claim 3). + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(proj.join("Cargo.toml"), fresh.join("Cargo.toml")).unwrap(); + std::fs::copy(&lock_path, fresh.join("Cargo.lock")).unwrap(); + copy_dir_recursive(&proj.join(".cargo"), &fresh.join(".cargo")); + copy_dir_recursive(&proj.join("src"), &fresh.join("src")); + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + + let fresh_home = tmp.path().join("fresh-cargo-home"); + std::fs::create_dir_all(&fresh_home).unwrap(); + let build = cargo(&fresh, &["build", "-q", "--locked", "--offline"], &fresh_home); + assert!( + build.status.success(), + "fresh-checkout `cargo build --locked --offline` (empty CARGO_HOME) must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr), + ); + let bin = Command::new(fresh.join("target/debug/consumer")) + .output() + .expect("run fresh consumer binary"); + assert!( + String::from_utf8_lossy(&bin.stdout).contains("MARKER:1"), + "fresh build must link the PATCHED dep: {}", + String::from_utf8_lossy(&bin.stdout) + ); + // Zero registry/network access: the empty CARGO_HOME gained no crate + // sources (cargo only writes its dotfile bookkeeping caches). + assert!( + !fresh_home.join("registry").exists(), + "fresh CARGO_HOME must not gain a registry/ — the vendored path dep \ + is the sole provider" + ); + + // Idempotency: re-vendor leaves the lock byte-stable. + let lock_wired = std::fs::read(&lock_path).unwrap(); + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &cargo_home, + ); + assert_eq!(code, 0, "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_wired, + "re-vendor must leave Cargo.lock byte-identical" + ); + + // REVERT PROOF. + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--revert", "--json", "--cwd", proj.to_str().unwrap()], + &cargo_home, + ); + assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "revert envelope: {renv}"); + assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_before, + "revert must restore Cargo.lock byte-identical to the pre-vendor snapshot" + ); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); + // The managed [patch] entry is gone (vendor created the config, so the + // whole file is removed; tolerate an empty leftover that lost the entry). + let config_after = std::fs::read_to_string(proj.join(".cargo/config.toml")).unwrap_or_default(); + assert!( + !config_after.contains(DEP), + "revert must drop the managed [patch.crates-io] entry:\n{config_after}" + ); +} + +/// Correct-behavior pin for the vendor envelope: a successful first-time +/// cargo vendor must surface as an `applied` event with `summary.applied == 1` +/// (CLI_CONTRACT.md: vendor events are `Applied` (= vendored)). +/// +/// Currently it is misreported as `skipped` with errorCode `vendored` and +/// `summary.applied == 0`: the shared `result_to_event` (apply.rs) routes any +/// result whose `package_path` contains `.socket/vendor/` to the +/// Skipped/`vendored` event — that check exists for APPLY's yield-to-vendor +/// path, but the cargo/golang/composer/gem vendor backends set their +/// `ApplyResult.package_path` to the vendor copy dir itself, so vendor's own +/// successes trip it (npm/pypi report `applied` correctly because their +/// package_path is a stage tempdir / site-packages). Human output says +/// "Vendored 0 package(s); 1 skipped" and `track_patch_vendored` reports 0. +#[test] +#[ignore = "BUG: result_to_event misroutes successful cargo/golang/composer/gem vendors to skipped/`vendored` (summary.applied == 0) because the backends' package_path is the .socket/vendor/ copy dir"] +fn cargo_vendor_reports_applied_event() { + if !has_command("cargo") { + println!("SKIP: `cargo` not installed"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let Some((proj, cargo_home, version, crate_dir)) = stage_fixture(tmp.path()) else { + return; + }; + let purl = format!("pkg:cargo/{DEP}@{version}"); + let orig = std::fs::read(crate_dir.join("src/lib.rs")).unwrap(); + let patched: Vec = [orig.as_slice(), PATCH_SUFFIX.as_bytes()].concat(); + stage_patch(&proj, &purl, "src/lib.rs", &orig, &patched); + + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &cargo_home, + ); + assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!( + env["summary"]["applied"], 1, + "a successful first-time vendor must count as applied: {env}" + ); + let event = env["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["purl"] == purl.as_str()) + .unwrap_or_else(|| panic!("expected an event for {purl}: {env}")); + assert_eq!( + event["action"], "applied", + "vendor success must be an `applied` event, not skipped/`vendored`: {event}" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs new file mode 100644 index 0000000..60ecd88 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs @@ -0,0 +1,549 @@ +#![cfg(all(unix, feature = "golang"))] +//! Real-go capstone e2e for `socket-patch vendor` — the committability proof +//! for the `go.mod` `replace`-directive vendoring, plus the apply↔vendor +//! interplay (takeover + yield). +//! +//! Hermetic and fully offline (the pattern proven by `e2e_golang_build.rs`): +//! a tiny upstream module is served from a local file GOPROXY into a private +//! GOMODCACHE by the REAL `go mod download`, so no network is ever needed — +//! the fresh-checkout proof then builds with `GOPROXY=off` + an EMPTY +//! GOMODCACHE (directory `replace` targets bypass the module cache, sumdb, +//! and `go.sum` entirely — spike claims 2/3). +//! +//! Skips (println) when `go`/`zip` are missing; everything else is hard. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; + +const UUID: &str = "3c4d5e6f-7081-4a1b-8c2d-0123456789ab"; +const UMOD: &str = "example.com/upstream"; +const UVER: &str = "v1.0.0"; +const UPURL: &str = "pkg:golang/example.com/upstream@v1.0.0"; +const PRISTINE_LIB: &str = "package upstream\n\nfunc Greeting() string { return \"PRISTINE\" }\n"; +const PATCHED_LIB: &str = "package upstream\n\nfunc Greeting() string { return \"PATCHED\" }\n"; + +// ── self-contained helpers ──────────────────────────────────────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +fn has_command(cmd: &str) -> bool { + Command::new(cmd) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok() +} + +/// Run socket-patch with `SOCKET_*` scrubbed + the fixture GOMODCACHE (the +/// go crawler resolves installed modules through it). +fn run_socket(cwd: &Path, args: &[&str], modcache: &Path) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + cmd.env_remove("VIRTUAL_ENV"); + cmd.env("GOMODCACHE", modcache); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// Hermetic env for every `go` invocation. `GOTOOLCHAIN=local` keeps the +/// installed toolchain from trying to download a different one. +fn go_env<'a>(modcache: &'a str, proxy: &'a str) -> Vec<(&'a str, &'a str)> { + vec![ + ("GOMODCACHE", modcache), + ("GOPROXY", proxy), + ("GOSUMDB", "off"), + ("GOFLAGS", "-mod=mod"), + ("GOTOOLCHAIN", "local"), + ] +} + +fn go(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { + let mut cmd = Command::new("go"); + cmd.args(args).current_dir(dir); + for (k, v) in env { + cmd.env(k, v); + } + cmd.output().expect("run go") +} + +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()) +} + +/// Build the upstream module into a file proxy and `go mod download` it into +/// a private GOMODCACHE. Returns `(consumer, modcache, proxy_url)`. +fn stage(tmp: &Path) -> (PathBuf, PathBuf, String) { + let stage = tmp.join("stage").join(format!("{UMOD}@{UVER}")); + std::fs::create_dir_all(&stage).unwrap(); + std::fs::write(stage.join("go.mod"), format!("module {UMOD}\n\ngo 1.21\n")).unwrap(); + std::fs::write(stage.join("lib.go"), PRISTINE_LIB).unwrap(); + + let pxv = tmp.join("proxy").join(UMOD).join("@v"); + std::fs::create_dir_all(&pxv).unwrap(); + std::fs::write(pxv.join(format!("{UVER}.info")), format!("{{\"Version\":\"{UVER}\"}}")).unwrap(); + std::fs::write(pxv.join(format!("{UVER}.mod")), format!("module {UMOD}\n\ngo 1.21\n")).unwrap(); + let zip_out = pxv.join(format!("{UVER}.zip")); + let zip_status = Command::new("zip") + .args(["-q", "-r", zip_out.to_str().unwrap(), &format!("{UMOD}@{UVER}")]) + .current_dir(tmp.join("stage")) + .status() + .expect("run zip"); + assert!(zip_status.success(), "zip failed"); + + let modcache = tmp.join("modcache"); + std::fs::create_dir_all(&modcache).unwrap(); + let proxy_url = format!("file://{}", tmp.join("proxy").display()); + + let consumer = tmp.join("consumer"); + std::fs::create_dir_all(&consumer).unwrap(); + std::fs::write( + consumer.join("go.mod"), + format!("module example.com/consumer\n\ngo 1.21\n\nrequire {UMOD} {UVER}\n"), + ) + .unwrap(); + std::fs::write( + consumer.join("main.go"), + format!( + "package main\n\nimport (\n\t\"fmt\"\n\t\"{UMOD}\"\n)\n\nfunc main() {{ fmt.Println(\"OUT:\", upstream.Greeting()) }}\n" + ), + ) + .unwrap(); + + let env = go_env(modcache.to_str().unwrap(), &proxy_url); + let dl = go(&consumer, &["mod", "download", &format!("{UMOD}@{UVER}")], &env); + assert!( + dl.status.success(), + "go mod download (file proxy) failed: {}", + String::from_utf8_lossy(&dl.stderr) + ); + (consumer, modcache, proxy_url) +} + +fn write_patch(consumer: &Path) { + let socket = consumer.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = serde_json::json!({ + "patches": { UPURL: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "lib.go": { + "beforeHash": git_sha256(PRISTINE_LIB.as_bytes()), + "afterHash": git_sha256(PATCHED_LIB.as_bytes()), + }}, + "vulnerabilities": {}, + "description": "capstone marker patch", + "license": "MIT", + "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write( + socket.join("blobs").join(git_sha256(PATCHED_LIB.as_bytes())), + PATCHED_LIB, + ) + .unwrap(); +} + +fn parse_envelope(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout) + .unwrap_or_else(|e| panic!("--json output is not JSON: {e}\nstdout:\n{stdout}")) +} + +fn find_event<'a>( + env: &'a serde_json::Value, + action: &str, + error_code: &str, +) -> Option<&'a serde_json::Value> { + env["events"] + .as_array()? + .iter() + .find(|e| e["action"] == action && e["errorCode"] == error_code) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +/// Best-effort: relax perms so tempdir cleanup can remove the (read-only) +/// module-cache extraction. +fn chmod_writable(dir: &Path) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755)); + if let Ok(rd) = std::fs::read_dir(dir) { + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + chmod_writable(&p); + } else { + let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)); + } + } + } +} + +// ── capstone 1: vendor → build → fresh checkout → revert ───────────── + +#[test] +fn go_vendor_fresh_checkout_offline_build_and_revert() { + if !has_command("go") || !has_command("zip") { + println!("SKIP e2e_vendor_golang_build: `go`/`zip` not installed"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let (consumer, modcache, proxy) = stage(tmp.path()); + let goenv = go_env(modcache.to_str().unwrap(), &proxy); + + // Baseline links PRISTINE. + let base = go(&consumer, &["run", "."], &goenv); + assert!( + base.status.success(), + "baseline go run failed: {}", + String::from_utf8_lossy(&base.stderr) + ); + assert!(String::from_utf8_lossy(&base.stdout).contains("OUT: PRISTINE")); + + // Snapshot the committed manifests AFTER the baseline run settles them. + let gomod_path = consumer.join("go.mod"); + let gomod_before = std::fs::read(&gomod_path).unwrap(); + + write_patch(&consumer); + let (code, stdout, stderr) = run_socket( + &consumer, + &["vendor", "--json", "--offline", "--cwd", consumer.to_str().unwrap()], + &modcache, + ); + assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + // NOTE: summary.applied / the event action are pinned in the #[ignore]d + // `go_vendor_reports_applied_event` below — successful golang vendors + // are currently misreported as skipped/`vendored` (shared + // result_to_event bug). The wiring/build proofs here are unaffected. + + // The replace directive points at the uuid copy, with the mandatory + // `./` prefix (a bare path fails go.mod parsing — spike claim 6). + let expected_replace = format!( + "replace {UMOD} {UVER} => ./.socket/vendor/golang/{UUID}/{UMOD}@{UVER}" + ); + let gomod = std::fs::read_to_string(&gomod_path).unwrap(); + assert!( + gomod.lines().any(|l| l.trim() == expected_replace), + "go.mod must carry the vendor replace directive.\nwant: {expected_replace}\ngot:\n{gomod}" + ); + + // Patched copy + marker + ledger on disk; pristine cache untouched. + let copy_dir = consumer.join(format!(".socket/vendor/golang/{UUID}/{UMOD}@{UVER}")); + assert_eq!( + std::fs::read(copy_dir.join("lib.go")).unwrap(), + PATCHED_LIB.as_bytes(), + "vendored copy must hold the patched bytes" + ); + assert!( + consumer + .join(format!(".socket/vendor/golang/{UUID}/socket-patch.vendor.json")) + .is_file(), + "informational vendor marker missing" + ); + assert!(consumer.join(".socket/vendor/state.json").is_file()); + assert_eq!( + std::fs::read(modcache.join(format!("{UMOD}@{UVER}")).join("lib.go")).unwrap(), + PRISTINE_LIB.as_bytes(), + "module cache must stay pristine" + ); + + // In-place build links PATCHED. + let patched_run = go(&consumer, &["run", "."], &goenv); + assert!( + patched_run.status.success(), + "post-vendor go run failed: {}", + String::from_utf8_lossy(&patched_run.stderr) + ); + assert!( + String::from_utf8_lossy(&patched_run.stdout).contains("OUT: PATCHED"), + "vendored bytes must be linked: {}", + String::from_utf8_lossy(&patched_run.stdout) + ); + + // FRESH-CHECKOUT PROOF: go.mod + go.sum + main.go + .socket/ only, EMPTY + // GOMODCACHE, GOPROXY=off (spike claim 2: directory replaces bypass the + // cache and sumdb entirely). + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(&gomod_path, fresh.join("go.mod")).unwrap(); + if consumer.join("go.sum").exists() { + std::fs::copy(consumer.join("go.sum"), fresh.join("go.sum")).unwrap(); + } + std::fs::copy(consumer.join("main.go"), fresh.join("main.go")).unwrap(); + copy_dir_recursive(&consumer.join(".socket"), &fresh.join(".socket")); + + let fresh_mc = tmp.path().join("fresh-modcache"); + std::fs::create_dir_all(&fresh_mc).unwrap(); + let offline_env = go_env(fresh_mc.to_str().unwrap(), "off"); + let build = go(&fresh, &["build", "-o", "app", "."], &offline_env); + assert!( + build.status.success(), + "fresh-checkout `go build` (GOPROXY=off, empty GOMODCACHE) must succeed.\nstderr:\n{}", + String::from_utf8_lossy(&build.stderr) + ); + let app = Command::new(fresh.join("app")).output().expect("run fresh app"); + assert!( + String::from_utf8_lossy(&app.stdout).contains("OUT: PATCHED"), + "fresh build must link the PATCHED module: {}", + String::from_utf8_lossy(&app.stdout) + ); + // The total-offline guarantee: the empty GOMODCACHE stayed empty. + assert_eq!( + std::fs::read_dir(&fresh_mc).unwrap().count(), + 0, + "directory-replaced modules must write NOTHING to the module cache" + ); + + // REVERT PROOF. + let (code, stdout, stderr) = run_socket( + &consumer, + &["vendor", "--revert", "--json", "--cwd", consumer.to_str().unwrap()], + &modcache, + ); + assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "revert envelope: {renv}"); + assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert_eq!( + std::fs::read(&gomod_path).unwrap(), + gomod_before, + "revert must restore go.mod byte-identical to the pre-vendor snapshot" + ); + assert!( + !consumer.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); + // Reverted project builds PRISTINE again from the cache. + let back = go(&consumer, &["run", "."], &goenv); + assert!( + String::from_utf8_lossy(&back.stdout).contains("OUT: PRISTINE"), + "reverted project must link the pristine module: {}", + String::from_utf8_lossy(&back.stdout) + ); + + chmod_writable(tmp.path()); +} + +// ── capstone 2: apply ↔ vendor interplay ────────────────────────────── + +/// apply-then-vendor (takeover) and vendor-then-apply (yield), plus the +/// documented revert handoff (`takeover_not_restored` → re-run `apply`). +#[test] +fn go_apply_vendor_interplay_takeover_and_yield() { + if !has_command("go") || !has_command("zip") { + println!("SKIP e2e_vendor_golang_build: `go`/`zip` not installed"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let (consumer, modcache, proxy) = stage(tmp.path()); + let goenv = go_env(modcache.to_str().unwrap(), &proxy); + let cs = consumer.to_str().unwrap(); + write_patch(&consumer); + + // 1. `apply` first: the project-local go-patches redirect. + let (code, stdout, stderr) = run_socket( + &consumer, + &["apply", "--offline", "--ecosystems", "golang", "--cwd", cs], + &modcache, + ); + assert_eq!(code, 0, "apply failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let go_patches_copy = consumer.join(format!(".socket/go-patches/{UMOD}@{UVER}")); + assert_eq!( + std::fs::read(go_patches_copy.join("lib.go")).unwrap(), + PATCHED_LIB.as_bytes(), + "apply must materialize the go-patches copy" + ); + let gomod = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); + assert!( + gomod.contains("=> ./.socket/go-patches/"), + "apply must wire the go-patches replace:\n{gomod}" + ); + + // 2. `vendor` takes the redirect over in one atomic repoint. + let (code, stdout, stderr) = run_socket( + &consumer, + &["vendor", "--json", "--offline", "--cwd", cs], + &modcache, + ); + assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "takeover is a success: {env}"); + assert!( + find_event(&env, "skipped", "vendor_takeover").is_some(), + "the takeover must be surfaced as a `vendor_takeover` event: {env}" + ); + + let gomod = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); + let expected_replace = format!( + "replace {UMOD} {UVER} => ./.socket/vendor/golang/{UUID}/{UMOD}@{UVER}" + ); + assert!( + gomod.lines().any(|l| l.trim() == expected_replace), + "takeover must repoint the replace at the vendor copy:\n{gomod}" + ); + assert!( + !gomod.contains("go-patches"), + "exactly one socket directive after takeover (no go-patches leftover):\n{gomod}" + ); + assert!( + !go_patches_copy.exists(), + "the stale go-patches module copy must be deleted on takeover" + ); + + // The ledger records the takeover so revert can warn about the handoff. + let state: serde_json::Value = serde_json::from_slice( + &std::fs::read(consumer.join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + state["entries"][UPURL]["tookOverGoPatches"], true, + "state.json must record tookOverGoPatches: {state}" + ); + + // Still builds PATCHED via the vendor path. + let run1 = go(&consumer, &["run", "."], &goenv); + assert!( + String::from_utf8_lossy(&run1.stdout).contains("OUT: PATCHED"), + "vendor path must be linked after takeover: {}", + String::from_utf8_lossy(&run1.stdout) + ); + + // 3. vendor-then-apply: apply yields ownership (skipped/`vendored`), + // never repointing the replace back at go-patches. + let (code, stdout, stderr) = run_socket( + &consumer, + &["apply", "--json", "--offline", "--ecosystems", "golang", "--cwd", cs], + &modcache, + ); + assert_eq!(code, 0, "apply on a vendored module must exit 0.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let aenv = parse_envelope(&stdout); + assert_eq!(aenv["status"], "success", "apply envelope: {aenv}"); + let yielded = find_event(&aenv, "skipped", "vendored") + .unwrap_or_else(|| panic!("apply must skip the vendored purl with errorCode `vendored`: {aenv}")); + assert_eq!(yielded["purl"], UPURL, "the vendored purl is the one skipped: {aenv}"); + let gomod_after_apply = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); + assert!( + gomod_after_apply.lines().any(|l| l.trim() == expected_replace), + "apply must leave the vendor replace untouched:\n{gomod_after_apply}" + ); + assert!( + !consumer.join(".socket/go-patches").join(format!("{UMOD}@{UVER}")).exists() + && !gomod_after_apply.contains("go-patches"), + "apply must not re-create the go-patches redirect for a vendored module" + ); + + // 4. Revert: the taken-over redirect is NOT restored — surfaced via + // `takeover_not_restored` — and a fresh `apply` restores it. + let (code, stdout, stderr) = + run_socket(&consumer, &["vendor", "--revert", "--json", "--cwd", cs], &modcache); + assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert!( + find_event(&renv, "skipped", "takeover_not_restored").is_some(), + "revert must warn that the go-patches redirect was not restored: {renv}" + ); + let gomod_reverted = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); + assert!( + !gomod_reverted.contains("replace "), + "no socket replace directive after revert:\n{gomod_reverted}" + ); + // Back on the pristine cache until apply is re-run… + let run2 = go(&consumer, &["run", "."], &goenv); + assert!( + String::from_utf8_lossy(&run2.stdout).contains("OUT: PRISTINE"), + "reverted module is pristine: {}", + String::from_utf8_lossy(&run2.stdout) + ); + // …and `apply` restores the go-patches redirect (the documented handoff). + let (code, _stdout, _stderr) = run_socket( + &consumer, + &["apply", "--offline", "--ecosystems", "golang", "--cwd", cs], + &modcache, + ); + assert_eq!(code, 0, "post-revert apply must succeed"); + let run3 = go(&consumer, &["run", "."], &goenv); + assert!( + String::from_utf8_lossy(&run3.stdout).contains("OUT: PATCHED"), + "re-applied go-patches redirect must link PATCHED again: {}", + String::from_utf8_lossy(&run3.stdout) + ); + + chmod_writable(tmp.path()); +} + +/// Correct-behavior pin for the vendor envelope: a successful first-time +/// golang vendor must surface as an `applied` event with +/// `summary.applied == 1` (CLI_CONTRACT.md: vendor events are `Applied` +/// (= vendored)). See `cargo_vendor_reports_applied_event` in +/// `e2e_vendor_cargo_build.rs` for the root cause (shared `result_to_event` +/// misroutes results whose package_path is the `.socket/vendor/` copy dir). +#[test] +#[ignore = "BUG: result_to_event misroutes successful cargo/golang/composer/gem vendors to skipped/`vendored` (summary.applied == 0) because the backends' package_path is the .socket/vendor/ copy dir"] +fn go_vendor_reports_applied_event() { + if !has_command("go") || !has_command("zip") { + println!("SKIP: `go`/`zip` not installed"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let (consumer, modcache, _proxy) = stage(tmp.path()); + write_patch(&consumer); + + let (code, stdout, stderr) = run_socket( + &consumer, + &["vendor", "--json", "--offline", "--cwd", consumer.to_str().unwrap()], + &modcache, + ); + assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!( + env["summary"]["applied"], 1, + "a successful first-time vendor must count as applied: {env}" + ); + let event = env["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["purl"] == UPURL) + .unwrap_or_else(|| panic!("expected an event for {UPURL}: {env}")); + assert_eq!( + event["action"], "applied", + "vendor success must be an `applied` event, not skipped/`vendored`: {event}" + ); + + chmod_writable(tmp.path()); +} diff --git a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs new file mode 100644 index 0000000..7a6456c --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs @@ -0,0 +1,329 @@ +//! Real-npm capstone e2e for `socket-patch vendor` — the committability proof. +//! +//! Drives the REAL npm (network used for fixture setup only): +//! 1. `npm install left-pad@1.3.0` into a tempdir project (private cache). +//! 2. Hand-stage a `.socket/` manifest + blob whose before/after Git-blob +//! hashes are computed from the ACTUAL installed bytes (a marker comment +//! prepended to `index.js`). +//! 3. `socket-patch vendor --json --offline` (the real binary) — assert the +//! deterministic tarball lands at `.socket/vendor/npm//…` and the +//! package-lock entry is rewired to `file:` + a recomputed sha512. +//! 4. **Fresh-checkout proof**: copy ONLY the committable files +//! (package.json + package-lock.json + .socket/) to a new dir and run +//! `npm ci --cache ` — the patched bytes MUST be what npm +//! installs (the recomputed `integrity` means the registry tarball can +//! never satisfy the lock; the spike proved plain `npm ci` exits 0 with +//! only the vendored dep). +//! 5. Idempotency: re-running vendor leaves the lock byte-identical. +//! 6. **Revert proof**: `vendor --revert` restores the lock byte-for-byte +//! and removes `.socket/vendor/` entirely. +//! +//! Skips (with a println) when `npm` is not installed or the fixture install +//! cannot reach the registry; every assertion after that is hard. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; + +/// Canonical lowercase patch uuid (a dedicated path level under +/// `.socket/vendor/npm/`). +const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; +/// Marker prepended to the dep's entry point by the synthetic patch. +const MARKER: &str = "/* SOCKET-PATCHED */\n"; +const DEP: &str = "left-pad"; +const DEP_VERSION: &str = "1.3.0"; + +// ── self-contained helpers ──────────────────────────────────────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +fn has_command(cmd: &str) -> bool { + Command::new(cmd) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok() +} + +/// Run the socket-patch binary with a scrubbed environment: every ambient +/// `SOCKET_*` var is removed (so a developer's `SOCKET_DRY_RUN=1` etc. can't +/// flip behavior) along with `VIRTUAL_ENV` (crawler discovery input). +fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + cmd.env_remove("VIRTUAL_ENV"); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn npm(cwd: &Path, args: &[&str]) -> Output { + Command::new("npm") + .args(args) + .current_dir(cwd) + .output() + .expect("failed to run npm") +} + +/// Git-blob SHA-256 (`sha256("blob \0" ++ bytes)`) — the hash format +/// socket-patch records in manifests. +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()) +} + +/// Write `.socket/manifest.json` + the after-hash blob so vendor runs fully +/// offline. +fn stage_patch(proj: &Path, purl: &str, file_key: &str, before: &[u8], after: &[u8]) { + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = serde_json::json!({ + "patches": { purl: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { file_key: { + "beforeHash": git_sha256(before), + "afterHash": git_sha256(after), + }}, + "vulnerabilities": {}, + "description": "capstone marker patch", + "license": "MIT", + "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(after)), after).unwrap(); +} + +fn parse_envelope(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout) + .unwrap_or_else(|e| panic!("vendor --json output is not JSON: {e}\nstdout:\n{stdout}")) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +// ── the capstone ────────────────────────────────────────────────────── + +#[test] +fn npm_vendor_fresh_checkout_npm_ci_and_revert() { + if !has_command("npm") { + println!("SKIP e2e_vendor_npm_build: `npm` not installed"); + return; + } + + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("package.json"), + r#"{"name":"vendor-capstone","version":"0.0.0","private":true}"#, + ) + .unwrap(); + + // 1. REAL fixture: npm install (network allowed here, private cache). + let cache = tmp.path().join("npm-cache"); + let install = npm( + &proj, + &[ + "install", + &format!("{DEP}@{DEP_VERSION}"), + "--no-audit", + "--no-fund", + "--cache", + cache.to_str().unwrap(), + ], + ); + if !install.status.success() { + println!( + "SKIP e2e_vendor_npm_build: `npm install {DEP}@{DEP_VERSION}` failed (registry \ + unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return; + } + + let installed_index = proj.join("node_modules").join(DEP).join("index.js"); + let orig = std::fs::read(&installed_index).expect("installed index.js"); + assert!( + !orig.starts_with(MARKER.as_bytes()), + "pristine install must not carry the marker" + ); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); + + // 2. Manifest + blob from the ACTUAL installed bytes (npm file keys carry + // the `package/` prefix). + stage_patch(&proj, &purl, "package/index.js", &orig, &patched); + + let lock_path = proj.join("package-lock.json"); + let lock_before = std::fs::read(&lock_path).expect("package-lock.json after npm install"); + let pre_lock: serde_json::Value = serde_json::from_slice(&lock_before).unwrap(); + let registry_integrity = pre_lock["packages"][format!("node_modules/{DEP}")]["integrity"] + .as_str() + .expect("registry lock entry has integrity") + .to_string(); + + // 3. Vendor (offline: blob staged locally → zero network). + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + let applied = env["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["action"] == "applied" && e["purl"] == purl.as_str()) + .unwrap_or_else(|| panic!("expected an applied event for {purl}: {env}")); + assert!(applied.get("errorCode").is_none(), "clean apply event: {applied}"); + + // Artifact: deterministic tarball + informational marker in the uuid dir. + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); + assert!( + proj.join(&tgz_rel).is_file(), + "vendored tarball missing at {tgz_rel}" + ); + assert!( + proj.join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) + .is_file(), + "informational vendor marker missing" + ); + assert!( + proj.join(".socket/vendor/state.json").is_file(), + "vendor ledger missing" + ); + + // Lock rewiring: `resolved` → relative file: spec, `integrity` recomputed + // (NEVER the inherited registry sha512 — a warm cache would otherwise + // silently install unpatched bytes). + let post_lock: serde_json::Value = + serde_json::from_slice(&std::fs::read(&lock_path).unwrap()).unwrap(); + let entry = &post_lock["packages"][format!("node_modules/{DEP}")]; + assert_eq!( + entry["resolved"], + format!("file:{tgz_rel}"), + "lock entry must resolve to the vendored tarball: {entry}" + ); + let new_integrity = entry["integrity"].as_str().expect("rewired integrity"); + assert!( + new_integrity.starts_with("sha512-"), + "recomputed integrity must be sha512: {new_integrity}" + ); + assert_ne!( + new_integrity, registry_integrity, + "integrity must be recomputed from the PATCHED tarball, not inherited" + ); + // package.json is never touched by npm vendoring (lock-only wiring). + let pkg_json: serde_json::Value = + serde_json::from_slice(&std::fs::read(proj.join("package.json")).unwrap()).unwrap(); + assert_eq!( + pkg_json["dependencies"][DEP].as_str().map(|s| s.contains("file:")), + Some(false), + "package.json dependency spec must stay registry-form" + ); + + // 4. FRESH-CHECKOUT PROOF: only the committable files, empty npm cache. + // (Spike-proven invocation: plain `npm ci --cache `; + // --no-audit/--no-fund only silence unrelated registry chatter.) + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(proj.join("package.json"), fresh.join("package.json")).unwrap(); + std::fs::copy(&lock_path, fresh.join("package-lock.json")).unwrap(); + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + + let fresh_cache = tmp.path().join("fresh-npm-cache"); + let ci = npm( + &fresh, + &[ + "ci", + "--cache", + fresh_cache.to_str().unwrap(), + "--no-audit", + "--no-fund", + ], + ); + assert!( + ci.status.success(), + "fresh-checkout `npm ci` must succeed from the vendored tarball.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let fresh_installed = + std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert!( + fresh_installed.starts_with(MARKER.as_bytes()), + "npm ci must install the PATCHED bytes from the vendored tarball; got:\n{}", + String::from_utf8_lossy(&fresh_installed[..fresh_installed.len().min(120)]) + ); + assert_eq!( + fresh_installed, patched, + "fresh install must be byte-identical to the patched content" + ); + + // 5. Idempotency: a re-run exits 0 and leaves the lock byte-stable. + let lock_wired = std::fs::read(&lock_path).unwrap(); + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env2 = parse_envelope(&stdout); + assert_eq!(env2["summary"]["failed"], 0, "re-run must not fail: {env2}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_wired, + "re-vendor must leave package-lock.json byte-identical" + ); + + // 6. REVERT PROOF: lock restored byte-for-byte, artifacts gone. + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--revert", "--json", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "revert envelope: {renv}"); + assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_before, + "revert must restore package-lock.json byte-identical to the pre-vendor snapshot" + ); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs new file mode 100644 index 0000000..2150c16 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs @@ -0,0 +1,539 @@ +#![cfg(unix)] +//! Real-Python capstone e2e for `socket-patch vendor` — the committability +//! proofs for BOTH pypi wiring flavors: +//! +//! * **uv project** (`uv.lock` present): paired `[tool.uv.sources]` pyproject +//! entry + surgical uv.lock rewrite. Proofs: `uv lock --check` passes, +//! plain `uv sync` leaves the lock byte-identical AND installs the patched +//! wheel, and a fresh checkout (pyproject + uv.lock + .socket only) with an +//! EMPTY UV_CACHE_DIR installs via `uv sync --frozen --offline`. +//! * **requirements.txt** (pip / `uv pip`): the exact pin line becomes +//! `./ --hash=sha256: # socket-patch vendor: …`. Proofs: a +//! fresh checkout (requirements.txt + .socket only) installs with +//! `pip install --no-index -r requirements.txt` FROM THE PROJECT ROOT +//! (both tools resolve bare paths against the CWD — spike claim 3), and +//! the same wheel installs via `uv pip install --no-index -r`. +//! +//! Both flavors finish with the revert proof: pyproject/uv.lock/ +//! requirements.txt byte-identical to the pre-vendor snapshots and +//! `.socket/vendor/` gone. +//! +//! Network is used for fixture setup only (installing six==1.16.0); the +//! vendor runs are `--offline` against a locally staged blob, and the +//! fresh-checkout installs are `--no-index` / `--offline` with empty caches. +//! +//! Skips (println) when python3/uv are missing or the fixture install cannot +//! reach PyPI; all assertions after that are hard. uv discovery tries PATH +//! then `~/.local/bin/uv`. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; + +const UUID: &str = "4d5e6f70-8192-4a1b-8c2d-0123456789ab"; +const PURL: &str = "pkg:pypi/six@1.16.0"; +/// Appended to the installed `six.py` by the synthetic patch. +const PATCH_SUFFIX: &str = "\n# SOCKET-PATCHED\nSOCKET_PATCHED = 1\n"; +/// Oracle: prints `1` iff the patched module is the one imported. +const ORACLE: &str = "import six; print(six.SOCKET_PATCHED)"; + +// ── self-contained helpers ──────────────────────────────────────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +/// Run socket-patch with ambient `SOCKET_*` + `VIRTUAL_ENV` scrubbed +/// (`VIRTUAL_ENV` is a python-crawler discovery input and must not leak from +/// the developer's shell). +fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + cmd.env_remove("VIRTUAL_ENV"); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// Resolve a Python interpreter (mirrors the core crawler's probe order). +fn find_python() -> Option<&'static str> { + for cmd in ["python3", "python"] { + let ok = Command::new(cmd) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if ok { + return Some(cmd); + } + } + None +} + +/// Resolve `uv`: PATH first, then `~/.local/bin/uv` (the standalone +/// installer's default location). +fn find_uv() -> Option { + let on_path = Command::new("uv") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if on_path { + return Some(PathBuf::from("uv")); + } + let home = std::env::var_os("HOME")?; + let candidate = Path::new(&home).join(".local/bin/uv"); + candidate.is_file().then_some(candidate) +} + +/// Run a toolchain command with a scrubbed VIRTUAL_ENV + explicit env. +fn tool(exe: &Path, cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { + let mut cmd = Command::new(exe); + cmd.args(args).current_dir(cwd); + cmd.env_remove("VIRTUAL_ENV"); + for (k, v) in env { + cmd.env(k, v); + } + cmd.output() + .unwrap_or_else(|e| panic!("failed to run {}: {e}", exe.display())) +} + +fn assert_tool_ok(out: &Output, context: &str) { + assert!( + out.status.success(), + "{context} failed (exit {:?}).\nstdout:\n{}\nstderr:\n{}", + out.status.code(), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); +} + +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()) +} + +/// Locate `/lib/python3.X/site-packages` (PEP-405 Unix layout). +fn site_packages(venv: &Path) -> PathBuf { + let lib = venv.join("lib"); + for entry in std::fs::read_dir(&lib) + .unwrap_or_else(|e| panic!("venv lib dir at {}: {e}", lib.display())) + .flatten() + { + let sp = entry.path().join("site-packages"); + if sp.is_dir() { + return sp; + } + } + panic!("no site-packages under {}", lib.display()); +} + +/// Stage the synthetic patch (manifest + blob) for the installed `six.py`, +/// returning the patched bytes. pypi manifest file keys are +/// site-packages-relative. +fn stage_patch(proj: &Path, installed_six: &Path) -> Vec { + let orig = std::fs::read(installed_six).expect("installed six.py"); + assert!( + !orig.ends_with(PATCH_SUFFIX.as_bytes()), + "pristine install must not carry the marker" + ); + let patched: Vec = [orig.as_slice(), PATCH_SUFFIX.as_bytes()].concat(); + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = serde_json::json!({ + "patches": { PURL: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "six.py": { + "beforeHash": git_sha256(&orig), + "afterHash": git_sha256(&patched), + }}, + "vulnerabilities": {}, + "description": "capstone marker patch", + "license": "MIT", + "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(&patched)), &patched).unwrap(); + patched +} + +fn parse_envelope(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout) + .unwrap_or_else(|e| panic!("vendor --json output is not JSON: {e}\nstdout:\n{stdout}")) +} + +/// Assert the envelope reports exactly one applied vendor for [`PURL`]. +fn assert_vendored_applied(env: &serde_json::Value) { + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + assert!( + env["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "applied" && e["purl"] == PURL), + "expected an applied event for {PURL}: {env}" + ); +} + +/// The single `.whl` inside the uuid dir (PEP 427 name derived from the +/// installed dist's WHEEL tags — don't hardcode the tag compression). +fn vendored_wheel(proj: &Path) -> PathBuf { + let uuid_dir = proj.join(format!(".socket/vendor/pypi/{UUID}")); + let wheels: Vec = std::fs::read_dir(&uuid_dir) + .unwrap_or_else(|e| panic!("uuid dir {}: {e}", uuid_dir.display())) + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "whl")) + .collect(); + assert_eq!( + wheels.len(), + 1, + "exactly one vendored wheel expected in {}: {wheels:?}", + uuid_dir.display() + ); + wheels[0].clone() +} + +/// Run the venv python against the marker oracle; returns trimmed stdout. +fn python_oracle(venv: &Path, cwd: &Path) -> String { + let out = tool(&venv.join("bin/python"), cwd, &["-c", ORACLE], &[]); + assert_tool_ok(&out, "python marker oracle"); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +// ── capstone 1: uv project flavor ───────────────────────────────────── + +#[test] +fn uv_vendor_fresh_checkout_frozen_offline_and_revert() { + let Some(uv) = find_uv() else { + println!("SKIP e2e_vendor_pypi_build(uv): `uv` not on PATH or at ~/.local/bin/uv"); + return; + }; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + let cache = tmp.path().join("uv-cache"); + let cache_env: Vec<(&str, &str)> = vec![("UV_CACHE_DIR", cache.to_str().unwrap())]; + + std::fs::write( + proj.join("pyproject.toml"), + "[project]\nname = \"vendor-capstone\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n", + ) + .unwrap(); + + // REAL fixture: uv lock + uv sync (network allowed here). + let lock = tool(&uv, &proj, &["lock", "-q"], &cache_env); + if !lock.status.success() { + println!( + "SKIP e2e_vendor_pypi_build(uv): `uv lock` failed (PyPI unreachable?):\n{}", + String::from_utf8_lossy(&lock.stderr) + ); + return; + } + let sync = tool(&uv, &proj, &["sync", "-q"], &cache_env); + if !sync.status.success() { + println!( + "SKIP e2e_vendor_pypi_build(uv): `uv sync` failed (PyPI unreachable?):\n{}", + String::from_utf8_lossy(&sync.stderr) + ); + return; + } + + let venv = proj.join(".venv"); + let installed_six = site_packages(&venv).join("six.py"); + let _patched = stage_patch(&proj, &installed_six); + + let pyproject_before = std::fs::read(proj.join("pyproject.toml")).unwrap(); + let uvlock_before = std::fs::read(proj.join("uv.lock")).unwrap(); + + // Vendor (offline; blob staged locally). + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_vendored_applied(&parse_envelope(&stdout)); + + // Artifact + PAIRED wiring (pyproject AND lock — either half alone is a + // silent no-op / silent revert, spike claims 7/9). + let wheel = vendored_wheel(&proj); + let wheel_rel = format!( + ".socket/vendor/pypi/{UUID}/{}", + wheel.file_name().unwrap().to_string_lossy() + ); + let pyproject = std::fs::read_to_string(proj.join("pyproject.toml")).unwrap(); + assert!( + pyproject.contains("[tool.uv.sources]") && pyproject.contains(&wheel_rel), + "pyproject must gain the [tool.uv.sources] path entry:\n{pyproject}" + ); + let uvlock = std::fs::read_to_string(proj.join("uv.lock")).unwrap(); + assert!( + uvlock.contains(&wheel_rel), + "uv.lock must resolve six from the vendored wheel path:\n{uvlock}" + ); + + // `uv lock --check` accepts the wired pair, and a plain `uv sync` both + // leaves the lock byte-identical AND installs the patched wheel. + let check = tool(&uv, &proj, &["lock", "--check"], &cache_env); + assert_tool_ok(&check, "`uv lock --check` on the wired pair"); + let lock_wired = std::fs::read(proj.join("uv.lock")).unwrap(); + let resync = tool(&uv, &proj, &["sync", "-q"], &cache_env); + assert_tool_ok(&resync, "plain `uv sync` on the wired pair"); + assert_eq!( + std::fs::read(proj.join("uv.lock")).unwrap(), + lock_wired, + "plain `uv sync` must leave uv.lock byte-identical" + ); + assert_eq!( + python_oracle(&venv, &proj), + "1", + "uv sync must install the PATCHED vendored wheel" + ); + + // FRESH-CHECKOUT PROOF: pyproject + uv.lock + .socket only, EMPTY cache, + // `uv sync --frozen --offline` (spike claim 3). + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(proj.join("pyproject.toml"), fresh.join("pyproject.toml")).unwrap(); + std::fs::copy(proj.join("uv.lock"), fresh.join("uv.lock")).unwrap(); + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + + let fresh_cache = tmp.path().join("fresh-uv-cache"); + let fresh_env: Vec<(&str, &str)> = vec![("UV_CACHE_DIR", fresh_cache.to_str().unwrap())]; + let frozen = tool(&uv, &fresh, &["sync", "--frozen", "--offline", "-q"], &fresh_env); + assert_tool_ok(&frozen, "fresh-checkout `uv sync --frozen --offline` (empty cache)"); + assert_eq!( + python_oracle(&fresh.join(".venv"), &fresh), + "1", + "fresh checkout must import the PATCHED six" + ); + assert_eq!( + std::fs::read(fresh.join("uv.lock")).unwrap(), + lock_wired, + "the frozen offline sync must leave uv.lock byte-identical" + ); + + // REVERT PROOF: both halves of the pair restored byte-for-byte. + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--revert", "--json", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "revert envelope: {renv}"); + assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert_eq!( + std::fs::read(proj.join("pyproject.toml")).unwrap(), + pyproject_before, + "revert must restore pyproject.toml byte-identical" + ); + assert_eq!( + std::fs::read(proj.join("uv.lock")).unwrap(), + uvlock_before, + "revert must restore uv.lock byte-identical" + ); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); +} + +// ── capstone 2: requirements.txt flavor (pip + `uv pip`) ────────────── + +#[test] +fn pip_requirements_vendor_fresh_checkout_no_index_and_revert() { + let Some(python) = find_python() else { + println!("SKIP e2e_vendor_pypi_build(pip): no python3/python on PATH"); + return; + }; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write(proj.join("requirements.txt"), "six==1.16.0\n").unwrap(); + + // REAL fixture: venv + pip install (network allowed here). + let venv = proj.join(".venv"); + let mkvenv = tool(Path::new(python), &proj, &["-m", "venv", ".venv"], &[]); + assert_tool_ok(&mkvenv, "python -m venv"); + let pip = venv.join("bin/pip"); + let install = tool( + &pip, + &proj, + &[ + "install", + "--disable-pip-version-check", + "--quiet", + "--no-cache-dir", + "-r", + "requirements.txt", + ], + &[], + ); + if !install.status.success() { + println!( + "SKIP e2e_vendor_pypi_build(pip): `pip install six==1.16.0` failed (PyPI \ + unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return; + } + + let installed_six = site_packages(&venv).join("six.py"); + let _patched = stage_patch(&proj, &installed_six); + let requirements_before = std::fs::read(proj.join("requirements.txt")).unwrap(); + + // Vendor (offline; blob staged locally). + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_vendored_applied(&parse_envelope(&stdout)); + + // Artifact + the rewritten pin line (the exact spike-tested shape: + // `./ --hash=sha256: # socket-patch vendor: six==1.16.0`). + let wheel = vendored_wheel(&proj); + let wheel_rel = format!( + ".socket/vendor/pypi/{UUID}/{}", + wheel.file_name().unwrap().to_string_lossy() + ); + let requirements = std::fs::read_to_string(proj.join("requirements.txt")).unwrap(); + let vendor_line = requirements + .lines() + .find(|l| l.contains(&wheel_rel)) + .unwrap_or_else(|| { + panic!("requirements.txt must carry the vendored wheel line:\n{requirements}") + }); + assert!( + vendor_line.starts_with(&format!("./{wheel_rel}")), + "the path line must be ./-prefixed and project-relative: {vendor_line}" + ); + assert!( + vendor_line.contains("--hash=sha256:"), + "the path line must pin the wheel hash (hardens every install): {vendor_line}" + ); + assert!( + !requirements.lines().any(|l| l.trim_start().starts_with("six==")), + "the original registry pin must be gone:\n{requirements}" + ); + + // FRESH-CHECKOUT PROOF (pip): requirements.txt + .socket only; install + // with --no-index FROM THE PROJECT ROOT (bare relative paths resolve + // against the CWD in both pip and uv — spike claim 3). + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(proj.join("requirements.txt"), fresh.join("requirements.txt")).unwrap(); + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + + let fresh_venv = fresh.join(".venv"); + let mkvenv = tool(Path::new(python), &fresh, &["-m", "venv", ".venv"], &[]); + assert_tool_ok(&mkvenv, "fresh python -m venv"); + let fresh_install = tool( + &fresh_venv.join("bin/pip"), + &fresh, + &[ + "install", + "--disable-pip-version-check", + "--no-index", + "-r", + "requirements.txt", + ], + &[], + ); + assert_tool_ok( + &fresh_install, + "fresh-checkout `pip install --no-index -r requirements.txt` (project root)", + ); + assert_eq!( + python_oracle(&fresh_venv, &fresh), + "1", + "pip must install the PATCHED vendored wheel" + ); + + // `uv pip` variant against the same fresh checkout (hash-checked too). + if let Some(uv) = find_uv() { + let uv_cache = tmp.path().join("uv-pip-cache"); + let uv_venv = fresh.join(".venv-uv"); + let envs: Vec<(&str, &str)> = vec![("UV_CACHE_DIR", uv_cache.to_str().unwrap())]; + let mk = tool(&uv, &fresh, &["venv", "-q", ".venv-uv"], &envs); + assert_tool_ok(&mk, "uv venv"); + let uv_venv_str = uv_venv.to_str().unwrap().to_string(); + let mut envs2: Vec<(&str, &str)> = vec![("UV_CACHE_DIR", uv_cache.to_str().unwrap())]; + envs2.push(("VIRTUAL_ENV", uv_venv_str.as_str())); + let uv_install = tool( + &uv, + &fresh, + &["pip", "install", "-q", "--no-index", "-r", "requirements.txt"], + &envs2, + ); + assert_tool_ok( + &uv_install, + "fresh-checkout `uv pip install --no-index -r requirements.txt` (project root)", + ); + assert_eq!( + python_oracle(&uv_venv, &fresh), + "1", + "uv pip must install the PATCHED vendored wheel" + ); + } else { + println!( + "NOTE e2e_vendor_pypi_build(pip): `uv` not found, skipping the uv-pip variant \ + (pip half already proven)" + ); + } + + // REVERT PROOF. + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--revert", "--json", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "revert envelope: {renv}"); + assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert_eq!( + std::fs::read(proj.join("requirements.txt")).unwrap(), + requirements_before, + "revert must restore requirements.txt byte-identical to the pre-vendor snapshot" + ); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs new file mode 100644 index 0000000..d4cb3e5 --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -0,0 +1,839 @@ +//! In-process + envelope contract tests for `socket-patch vendor` (npm +//! backend, plus the golang apply-yields-to-vendor handshake). +//! +//! The lifecycle tests call `socket_patch_cli::commands::vendor::run(args)` +//! directly (the in-process convention of `in_process_cargo_apply.rs` / +//! `in_process_edge_cases.rs`) and assert exit codes + disk state. The +//! in-process `run()` prints its JSON envelope to the process stdout, which +//! a test cannot capture — so every assertion that needs the envelope JSON +//! itself goes through the built binary (`CARGO_BIN_EXE_socket-patch`) with +//! a fully scrubbed child environment, exactly like the `e2e_*` suites. +//! +//! Hermeticity: every fixture stages its patch blob under `.socket/blobs/` +//! and runs with `--offline`/`offline: true`, so the patch pipeline never +//! touches the network. Subprocess children additionally get every ambient +//! `SOCKET_*` var removed (env-robustness) and `SOCKET_TELEMETRY_DISABLED=1`. +//! No test mutates this process's environment, so none of them need +//! `#[serial]` — each runs in its own tempdir. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use socket_patch_cli::args::GlobalArgs; +use socket_patch_cli::commands::vendor::{run as vendor_run, VendorArgs}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + +/// Canonical-grammar patch UUID — the vendor path layer validates the uuid +/// path level fail-closed, so fixtures must use the real shape. +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; +const REG_RESOLVED: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; +const REG_INTEGRITY: &str = "sha512-orig=="; + +/// Project-relative tarball path the npm backend must produce: +/// `.socket/vendor///-.tgz`. +fn rel_tgz() -> String { + format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") +} + +// ───────────────────────────── fixture ───────────────────────────── + +/// One self-contained npm project: root package.json, a v3 package-lock with +/// a registry-resolved `left-pad` entry, the installed package under +/// node_modules/, and a `.socket/` manifest + after-hash blob so vendor runs +/// fully offline. +struct NpmFixture { + tmp: tempfile::TempDir, + /// The lockfile bytes exactly as the fixture wrote them — the + /// byte-identity oracle for dry-run / revert round-trips. + original_lock: Vec, + /// Manifest bytes as written (vendor must never touch the manifest). + original_manifest: Vec, + after_hash: String, +} + +impl NpmFixture { + fn root(&self) -> &Path { + self.tmp.path() + } + fn lock_path(&self) -> PathBuf { + self.root().join("package-lock.json") + } + fn lock_bytes(&self) -> Vec { + std::fs::read(self.lock_path()).expect("read package-lock.json") + } + fn lock_value(&self) -> Value { + serde_json::from_slice(&self.lock_bytes()).expect("lock parses") + } + fn manifest_path(&self) -> PathBuf { + self.root().join(".socket/manifest.json") + } + fn vendor_dir(&self) -> PathBuf { + self.root().join(".socket/vendor") + } + fn tgz_path(&self) -> PathBuf { + self.root().join(rel_tgz()) + } + fn marker_path(&self) -> PathBuf { + self.root() + .join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) + } + fn state_path(&self) -> PathBuf { + self.root().join(".socket/vendor/state.json") + } + fn installed_index(&self) -> PathBuf { + self.root().join("node_modules/left-pad/index.js") + } +} + +/// The manifest patch record every fixture purl shares (same files map ⇒ one +/// staged blob satisfies the offline source check for all of them). +fn patch_record(before_hash: &str, after_hash: &str) -> Value { + json!({ + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { "beforeHash": before_hash, "afterHash": after_hash } + }, + "vulnerabilities": {}, + "description": "synthetic vendor test patch", + "license": "MIT", + "tier": "free" + }) +} + +/// Build the fixture with a manifest covering `manifest_purls` (each gets an +/// identical record). The installed package + lock entry always describe +/// `left-pad@1.3.0`. +fn npm_fixture_with_purls(manifest_purls: &[&str]) -> NpmFixture { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path(); + + // Installed package (original, unpatched bytes). + let pkg = root.join("node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + + // Root project files. The lock is written pretty + 2-space indent + + // trailing newline — the exact shape the production serializer emits — + // so byte-identity assertions across vendor/revert are meaningful. + std::fs::write( + root.join("package.json"), + br#"{"name":"fixture","version":"1.0.0","private":true}"#, + ) + .unwrap(); + let lock = json!({ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fixture", + "version": "1.0.0", + "dependencies": { "left-pad": "^1.3.0" } + }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": REG_RESOLVED, + "integrity": REG_INTEGRITY, + "license": "WTFPL" + } + } + }); + let mut original_lock = serde_json::to_vec_pretty(&lock).unwrap(); + original_lock.push(b'\n'); + std::fs::write(root.join("package-lock.json"), &original_lock).unwrap(); + + // Manifest + staged after-hash blob (offline source). + let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + let mut patches = serde_json::Map::new(); + for purl in manifest_purls { + patches.insert(purl.to_string(), patch_record(&before_hash, &after_hash)); + } + let manifest = json!({ "patches": patches }); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let mut original_manifest = serde_json::to_vec_pretty(&manifest).unwrap(); + original_manifest.push(b'\n'); + std::fs::write(socket.join("manifest.json"), &original_manifest).unwrap(); + std::fs::write(socket.join("blobs").join(&after_hash), PATCHED_INDEX).unwrap(); + + NpmFixture { + tmp, + original_lock, + original_manifest, + after_hash, + } +} + +fn npm_fixture() -> NpmFixture { + npm_fixture_with_purls(&[PURL]) +} + +/// In-process `VendorArgs` for the fixture: `json` suppresses interactive +/// prompts/human output, `offline` keeps the patch pipeline on the staged +/// local blobs (no network). +fn vendor_args(cwd: &Path) -> VendorArgs { + VendorArgs { + common: GlobalArgs { + cwd: cwd.to_path_buf(), + json: true, + silent: true, + offline: true, + ..GlobalArgs::default() + }, + force: false, + revert: false, + vex: Default::default(), + } +} + +// ───────────────────────── subprocess runner ───────────────────────── + +/// Run the built `socket-patch` binary with every ambient `SOCKET_*` env var +/// scrubbed from the child (env-robustness: the assertions must reflect the +/// argv, not the developer's shell) and telemetry hard-disabled. Returns +/// `(exit_code, stdout, stderr)`. +fn run_cli(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> (i32, String, String) { + let mut cmd = 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_") { + cmd.env_remove(key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + for (k, v) in extra_env { + cmd.env(k, v); + } + let out = cmd.output().expect("spawn socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// `vendor --json --offline --cwd ` through the binary, +/// returning `(exit_code, parsed envelope)`. +fn vendor_cli(cwd: &Path, extra: &[&str]) -> (i32, Value) { + let mut args = vec!["vendor", "--json", "--offline", "--cwd", cwd.to_str().unwrap()]; + args.extend_from_slice(extra); + let (code, stdout, stderr) = run_cli(cwd, &args, &[]); + let env: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("vendor --json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (code, env) +} + +fn events(envelope: &Value) -> &Vec { + envelope["events"].as_array().expect("events array") +} + +/// The single event matching `action` (+ optional `errorCode`), or panic +/// with the envelope. +fn find_event<'a>(envelope: &'a Value, action: &str, error_code: Option<&str>) -> &'a Value { + events(envelope) + .iter() + .find(|e| { + e["action"] == action + && error_code.is_none_or(|c| e["errorCode"] == c) + }) + .unwrap_or_else(|| { + panic!("expected a `{action}` event (errorCode={error_code:?}) in:\n{envelope:#}") + }) +} + +// ───────────────────────────────────────────────────────────────────── +// 1. end-to-end: vendor an installed npm package +// ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn vendor_npm_end_to_end() { + let fx = npm_fixture(); + let code = vendor_run(vendor_args(fx.root())).await; + assert_eq!(code, 0, "vendor must succeed"); + + // Artifact: deterministic tarball at the contract path, plus the + // informational marker beside it. + assert!(fx.tgz_path().is_file(), "tarball at {}", rel_tgz()); + let marker: Value = + serde_json::from_slice(&std::fs::read(fx.marker_path()).expect("marker written")) + .expect("marker is JSON"); + assert_eq!(marker["purl"], PURL); + assert_eq!(marker["patchUuid"], UUID); + assert_eq!(marker["ecosystem"], "npm"); + + // Ledger: the state entry carries the artifact facts and the VERBATIM + // pre-vendor lock fragment (revert's only offline source of truth). + let state: Value = + serde_json::from_slice(&std::fs::read(fx.state_path()).expect("state.json written")) + .expect("state.json is JSON"); + let entry = &state["entries"][PURL]; + assert_eq!(entry["ecosystem"], "npm"); + assert_eq!(entry["uuid"], UUID); + assert_eq!(entry["artifact"]["path"], rel_tgz()); + let tgz = std::fs::read(fx.tgz_path()).unwrap(); + assert_eq!( + entry["artifact"]["sha256"], + hex::encode(Sha256::digest(&tgz)), + "ledger sha256 must describe the tarball actually on disk" + ); + let wiring = entry["wiring"].as_array().expect("wiring array"); + assert_eq!(wiring.len(), 1, "one rewritten lock instance"); + assert_eq!(wiring[0]["file"], "package-lock.json"); + assert_eq!(wiring[0]["action"], "rewritten"); + assert_eq!( + wiring[0]["original"]["resolved"], REG_RESOLVED, + "wiring must record the verbatim pre-vendor resolved URL" + ); + assert_eq!(wiring[0]["original"]["integrity"], REG_INTEGRITY); + + // Lock rewrite: resolved → relative file: spec carrying the uuid path, + // integrity → the RECOMPUTED tarball hash (a reused registry integrity + // would let a warm npm cache install the unpatched bytes); the entry's + // other fields are byte-preserved. + let lock = fx.lock_value(); + let live = &lock["packages"]["node_modules/left-pad"]; + assert_eq!(live["resolved"], format!("file:{}", rel_tgz())); + let integrity = live["integrity"].as_str().expect("integrity string"); + assert!(integrity.starts_with("sha512-"), "sri sha512: {integrity}"); + assert_ne!(integrity, REG_INTEGRITY, "integrity must be recomputed"); + assert_eq!(live["version"], "1.3.0", "version field preserved"); + assert_eq!(live["license"], "WTFPL", "license field preserved"); + // Untouched lock regions stay identical (root project entry). + let original: Value = serde_json::from_slice(&fx.original_lock).unwrap(); + assert_eq!(lock["packages"][""], original["packages"][""]); + + // The manifest is read-only input; node_modules is NOT patched in place + // (vendor patches a staged copy and packs it — the installed tree keeps + // the original bytes until/unless `apply` runs). + assert_eq!( + std::fs::read(fx.manifest_path()).unwrap(), + fx.original_manifest, + "vendor must not touch the manifest" + ); + assert_eq!( + std::fs::read(fx.installed_index()).unwrap(), + ORIG_INDEX, + "vendor must not patch node_modules in place" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 2. idempotent re-run +// ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn rerun_is_idempotent() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "first vendor"); + let lock_after_first = fx.lock_bytes(); + let tgz_first = std::fs::read(fx.tgz_path()).unwrap(); + let state_first = std::fs::read(fx.state_path()).unwrap(); + + // Second run through the binary so the envelope is observable. + let (code, env) = vendor_cli(fx.root(), &[]); + assert_eq!(code, 0, "re-run must exit 0: {env:#}"); + assert_eq!(env["status"], "success"); + assert_eq!(env["summary"]["applied"], 0, "nothing newly applied: {env:#}"); + assert_eq!(env["summary"]["failed"], 0); + assert_eq!(env["summary"]["skipped"], 1); + // The in-sync re-run synthesizes its result against the vendored + // artifact path, which routes to the `vendored` skip reason (the same + // tag `apply` uses for vendor-owned packages) — pin the actual contract. + let skipped = find_event(&env, "skipped", Some("vendored")); + assert_eq!(skipped["purl"], PURL); + + // NOTHING on disk churned. + assert_eq!(fx.lock_bytes(), lock_after_first, "lock byte-stable"); + assert_eq!( + std::fs::read(fx.tgz_path()).unwrap(), + tgz_first, + "tarball byte-stable (deterministic pack)" + ); + assert_eq!( + std::fs::read(fx.state_path()).unwrap(), + state_first, + "ledger byte-stable (no re-recorded originals)" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 3. --dry-run writes nothing +// ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn dry_run_writes_nothing() { + let fx = npm_fixture(); + let mut args = vendor_args(fx.root()); + args.common.dry_run = true; + assert_eq!(vendor_run(args).await, 0, "dry-run must exit 0"); + + assert!( + !fx.vendor_dir().exists(), + "--dry-run must not create .socket/vendor (no tarball, no state.json)" + ); + assert_eq!(fx.lock_bytes(), fx.original_lock, "lock byte-identical"); + assert_eq!( + std::fs::read(fx.manifest_path()).unwrap(), + fx.original_manifest + ); + assert_eq!(std::fs::read(fx.installed_index()).unwrap(), ORIG_INDEX); +} + +// ───────────────────────────────────────────────────────────────────── +// 4. vendor → revert round-trip +// ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn revert_round_trip() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); + assert_ne!( + fx.lock_bytes(), + fx.original_lock, + "sanity: vendor actually rewired the lock" + ); + + let mut revert = vendor_args(fx.root()); + revert.revert = true; + assert_eq!(vendor_run(revert).await, 0, "revert must exit 0"); + + // The lock is restored to the EXACT original fixture bytes — revert + // restores the recorded verbatim fragments, not a re-serialization + // guess. + assert_eq!( + fx.lock_bytes(), + fx.original_lock, + "revert must restore the original lock byte-for-byte" + ); + // The whole vendor tree is gone — artifacts, marker, state.json, the + // eco level, and .socket/vendor itself (no empty-dir residue). + assert!( + !fx.vendor_dir().exists(), + ".socket/vendor must be fully pruned after a complete revert" + ); + + // Second revert is a clean no-op: exit 0, ZERO events. + let (code, env) = vendor_cli(fx.root(), &["--revert"]); + assert_eq!(code, 0, "second revert must exit 0: {env:#}"); + assert_eq!(env["status"], "success"); + assert!( + events(&env).is_empty(), + "nothing left to revert ⇒ no events: {env:#}" + ); + assert_eq!(env["summary"]["removed"], 0); +} + +// ───────────────────────────────────────────────────────────────────── +// 5. revert works without a manifest +// ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn revert_works_without_manifest() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); + + // Simulate `remove`/manual deletion: the manifest is gone but the + // committed ledger + artifacts remain. `--revert` derives everything + // from state.json and must still restore. + std::fs::remove_file(fx.manifest_path()).unwrap(); + + let mut revert = vendor_args(fx.root()); + revert.revert = true; + assert_eq!( + vendor_run(revert).await, + 0, + "revert must work without a manifest" + ); + assert_eq!(fx.lock_bytes(), fx.original_lock, "lock restored"); + assert!(!fx.vendor_dir().exists(), "vendor tree removed"); +} + +// ───────────────────────────────────────────────────────────────────── +// 6. unsupported-ecosystem purls +// ───────────────────────────────────────────────────────────────────── + +/// Contract behavior (CLI_CONTRACT.md "Vendor command contract"): PURLs of +/// COMPILED-OUT ecosystems are invisible to `vendor` exactly as they are to +/// `apply` — on this build (default features — no `nuget`), a `pkg:nuget/...` +/// manifest entry is dropped by `partition_purls` before vendor's +/// is_vendorable partition, producing no event. The +/// `vendor_unsupported_ecosystem` skip fires only for ecosystems the build +/// recognizes but cannot vendor (e.g. maven/nuget purls on feature-enabled +/// builds). The npm patch still vendors and the run exits 0. +#[tokio::test] +async fn unsupported_ecosystem_purl_is_currently_dropped_silently() { + let fx = npm_fixture_with_purls(&[PURL, "pkg:nuget/Foo.Bar@1.0.0"]); + let (code, env) = vendor_cli(fx.root(), &[]); + assert_eq!(code, 0, "benign skip must not fail the run: {env:#}"); + assert_eq!(env["status"], "success"); + let applied = find_event(&env, "applied", None); + assert_eq!(applied["purl"], PURL); + assert_eq!(env["summary"]["applied"], 1); + // The compiled-out purl vanishes without a trace (the gap being pinned). + assert!( + !events(&env).iter().any(|e| { + e["purl"].as_str().is_some_and(|p| p.contains("nuget")) + }), + "current behavior: no event for the compiled-out nuget purl: {env:#}" + ); + assert!(fx.tgz_path().is_file(), "the npm patch still vendors"); +} + + +// ───────────────────────────────────────────────────────────────────── +// 7. package not installed +// ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn package_not_installed_fails() { + // The manifest names a package that is nowhere in node_modules. The + // user asked for it to be vendored and it wasn't — that is a partial + // failure (exit 1), surfaced as a skipped event with the stable code. + let fx = npm_fixture_with_purls(&["pkg:npm/ghost-pkg@9.9.9"]); + let (code, env) = vendor_cli(fx.root(), &[]); + assert_eq!(code, 1, "an unsatisfiable manifest entry must exit 1: {env:#}"); + assert_eq!(env["status"], "partialFailure"); + let skipped = find_event(&env, "skipped", Some("package_not_installed")); + assert_eq!(skipped["purl"], "pkg:npm/ghost-pkg@9.9.9"); + assert!( + !fx.vendor_dir().exists(), + "nothing may be written for a package that isn't installed" + ); + assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); +} + +// ───────────────────────────────────────────────────────────────────── +// 8. reconcile: entries dropped from the manifest are auto-reverted +// ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn reconcile_drops_stale_entries() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); + assert!(fx.tgz_path().is_file()); + + // The patch is dropped from the manifest (e.g. `remove` ran, which is + // vendoring-unaware by design). The next vendor run must revert the + // now-stale entry even though zero in-scope patches remain. + std::fs::write(fx.manifest_path(), b"{\"patches\": {}}\n").unwrap(); + + let (code, env) = vendor_cli(fx.root(), &[]); + assert_eq!(code, 0, "reconcile-only run must exit 0: {env:#}"); + let removed = find_event(&env, "removed", Some("vendor_reconciled")); + assert_eq!(removed["purl"], PURL); + + assert!( + !fx.vendor_dir().exists(), + "the stale artifact (and the emptied vendor tree) must be gone" + ); + assert_eq!( + fx.lock_bytes(), + fx.original_lock, + "the lock must be restored to the pre-vendor registry fragment" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 9. offline with no local source +// ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn offline_missing_source_fails() { + let fx = npm_fixture(); + // Remove the staged blob: offline + no blob/diff/package ⇒ the patch has + // no usable local source and vendor must fail loudly, not guess. + std::fs::remove_file(fx.root().join(".socket/blobs").join(&fx.after_hash)).unwrap(); + + let (code, env) = vendor_cli(fx.root(), &[]); + assert_eq!(code, 1, "offline with no local source must exit 1: {env:#}"); + assert_eq!(env["status"], "error"); + assert_eq!(env["error"]["code"], "no_local_source"); + assert!( + !fx.vendor_dir().exists(), + "a failed staging must write nothing" + ); + assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); +} + +// ───────────────────────────────────────────────────────────────────── +// 10a. apply after vendor — npm (documented in-place behavior) +// ───────────────────────────────────────────────────────────────────── + +/// DOCUMENTED BEHAVIOR, deliberately pinned: for npm, `apply` does NOT +/// consult the vendor ledger (only the golang path calls +/// `is_purl_vendored`, because a go re-apply would repoint the vendor-owned +/// `replace` directive). An npm apply after vendor simply patches +/// node_modules in place — harmless: the lockfile still consumes the +/// vendored tarball, and the in-place edit makes the installed tree match +/// the patched bytes. The vendored artifact and the lock wiring are +/// untouched. +#[tokio::test] +async fn vendored_npm_purl_apply_patches_in_place() { + use socket_patch_cli::commands::apply::{run as apply_run, ApplyArgs}; + + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); + let lock_after_vendor = fx.lock_bytes(); + let tgz_after_vendor = std::fs::read(fx.tgz_path()).unwrap(); + assert_eq!(std::fs::read(fx.installed_index()).unwrap(), ORIG_INDEX); + + let apply_args = ApplyArgs { + common: GlobalArgs { + cwd: fx.root().to_path_buf(), + json: true, + silent: true, + offline: true, + ..GlobalArgs::default() + }, + force: false, + check: false, + vex: Default::default(), + }; + assert_eq!(apply_run(apply_args).await, 0, "apply after vendor exits 0"); + + assert_eq!( + std::fs::read(fx.installed_index()).unwrap(), + PATCHED_INDEX, + "npm apply patches node_modules in place even when the purl is vendored \ + (apply consults the vendor ledger for golang only)" + ); + assert_eq!( + fx.lock_bytes(), + lock_after_vendor, + "apply must not disturb the vendored lock wiring" + ); + assert_eq!( + std::fs::read(fx.tgz_path()).unwrap(), + tgz_after_vendor, + "apply must not touch the vendored artifact" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 10b. apply after vendor — golang yields with skipped/vendored +// ───────────────────────────────────────────────────────────────────── + +/// The golang half of "apply yields to vendor" (CLI_CONTRACT.md): a module +/// recorded in `.socket/vendor/state.json` must be skipped by `apply` with +/// reason `vendored` — apply must never repoint the vendor-owned `replace` +/// back at `.socket/go-patches/`. The ledger entry is seeded by hand (the +/// exact state `vendor` persists) so the test needs no full go vendor run. +#[cfg(feature = "golang")] +#[tokio::test] +async fn vendored_golang_purl_skipped_by_apply() { + use socket_patch_core::patch::vendor::state::{VendorArtifact, VendorEntry, VendorState}; + + const MODULE: &str = "github.com/foo/bar"; + const VERSION: &str = "v1.4.2"; + let purl = format!("pkg:golang/{MODULE}@{VERSION}"); + const PRISTINE: &[u8] = b"package bar\n\nfunc Hello() string { return \"hi\" }\n"; + const GO_PATCHED: &[u8] = b"package bar\n\nfunc Hello() string { return \"PATCHED\" }\n"; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Fake extracted module cache (the crawler's discovery source). + let cache_dir = root.join("modcache").join(format!("{MODULE}@{VERSION}")); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::write(cache_dir.join("bar.go"), PRISTINE).unwrap(); + std::fs::write( + cache_dir.join("go.mod"), + "module github.com/foo/bar\n\ngo 1.21\n", + ) + .unwrap(); + + // Consumer go.mod carrying the vendor-owned replace, exactly as the + // vendor backend wires it. + let replace_target = format!("./.socket/vendor/golang/{UUID}/{MODULE}@{VERSION}"); + let gomod = format!( + "module example.com/app\n\ngo 1.21\n\nrequire {MODULE} {VERSION}\n\n\ + replace {MODULE} {VERSION} => {replace_target}\n" + ); + std::fs::write(root.join("go.mod"), &gomod).unwrap(); + + // Manifest + offline blob for the golang patch. + let before_hash = compute_git_sha256_from_bytes(PRISTINE); + let after_hash = compute_git_sha256_from_bytes(GO_PATCHED); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = json!({ + "patches": { + purl.clone(): { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "bar.go": { "beforeHash": before_hash, "afterHash": after_hash } }, + "vulnerabilities": {}, + "description": "synthetic", "license": "MIT", "tier": "free" + } + } + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(&after_hash), GO_PATCHED).unwrap(); + + // Seed the ledger with the golang entry (what a `vendor` run records). + let mut state = VendorState::new(); + state.entries.insert( + purl.clone(), + VendorEntry { + ecosystem: "golang".to_string(), + base_purl: purl.clone(), + uuid: UUID.to_string(), + artifact: VendorArtifact { + path: format!(".socket/vendor/golang/{UUID}/{MODULE}@{VERSION}"), + sha256: String::new(), + size: None, + platform_locked: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + flavor: None, + uv: None, + }, + ); + socket_patch_core::patch::vendor::save_state(root, &state) + .await + .expect("seed state.json"); + + // apply (through the binary: scrubbed env + child-only GOMODCACHE). + let (code, stdout, stderr) = run_cli( + root, + &[ + "apply", + "--json", + "--offline", + "--ecosystems", + "golang", + "--cwd", + root.to_str().unwrap(), + ], + &[("GOMODCACHE", root.join("modcache").to_str().unwrap())], + ); + let env: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("apply --json envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + assert_eq!(code, 0, "apply must succeed while yielding: {env:#}"); + assert_eq!(env["status"], "success"); + let skipped = find_event(&env, "skipped", Some("vendored")); + assert_eq!(skipped["purl"], purl); + + // Apply must not have re-pointed the replace or materialised a + // go-patches redirect. + assert_eq!( + std::fs::read_to_string(root.join("go.mod")).unwrap(), + gomod, + "go.mod must be byte-unchanged (the vendor-owned replace stays)" + ); + assert!( + !root.join(".socket/go-patches").exists(), + "apply must not materialise a go-patches redirect for a vendored module" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 11. lock contention +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn lock_contention_exits_lock_held() { + let fx = npm_fixture(); + // Hold the same advisory lock `vendor` takes (`<.socket>/apply.lock`); + // vendor shares it with apply/rollback so an apply↔vendor race is + // impossible. flock contention is cross-process, so the child binary + // genuinely contends with this test's guard. + let _guard = socket_patch_core::patch::apply_lock::acquire( + &fx.root().join(".socket"), + std::time::Duration::ZERO, + ) + .expect("test holds the lock first"); + + let (code, env) = vendor_cli(fx.root(), &["--lock-timeout", "1"]); + assert_eq!(code, 1, "contended vendor must exit 1: {env:#}"); + assert_eq!(env["command"], "vendor", "the failure envelope is vendor's own"); + assert_eq!(env["status"], "error"); + assert_eq!(env["error"]["code"], "lock_held"); + assert!( + events(&env).is_empty(), + "a pre-event failure carries no events: {env:#}" + ); + + // Nothing happened while contended. + assert!(!fx.vendor_dir().exists(), "no vendor writes under contention"); + assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); +} + +// ───────────────────────────────────────────────────────────────────── +// 12. JSON envelope shape +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn json_envelope_shape() { + // Wet run. + let fx = npm_fixture(); + let (code, env) = vendor_cli(fx.root(), &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(env["command"], "vendor"); + assert_eq!(env["status"], "success"); + assert_eq!(env["dryRun"], false, "dryRun mirrors the (absent) flag"); + let applied = find_event(&env, "applied", None); + assert_eq!(applied["purl"], PURL); + assert_eq!( + applied["files"][0]["path"], "package/index.js", + "applied event enumerates the patched files" + ); + // The pre-aggregated summary carries every counter field. + let summary = env["summary"].as_object().expect("summary object"); + for field in [ + "discovered", + "downloaded", + "applied", + "updated", + "skipped", + "failed", + "removed", + "verified", + ] { + assert!(summary.contains_key(field), "summary.{field} present: {env:#}"); + } + assert_eq!(env["summary"]["applied"], 1); + assert_eq!(env["summary"]["failed"], 0); + + // Dry run on a fresh fixture: dryRun flips, the patch is Verified (not + // Applied), and the envelope is still command=vendor. + let fx2 = npm_fixture(); + let (code, env) = vendor_cli(fx2.root(), &["--dry-run"]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(env["command"], "vendor"); + assert_eq!(env["dryRun"], true, "dryRun mirrors --dry-run"); + assert_eq!(env["status"], "success"); + find_event(&env, "verified", None); + assert_eq!(env["summary"]["verified"], 1); + assert_eq!(env["summary"]["applied"], 0); + + // No manifest at all: same contract as apply — clean no-op, exit 0, + // status noManifest (the envelope still identifies the command). + let empty = tempfile::tempdir().unwrap(); + let (code, env) = vendor_cli(empty.path(), &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(env["command"], "vendor"); + assert_eq!(env["status"], "noManifest"); + assert!(events(&env).is_empty()); +} From 1f42ad957e4e9ba14c321152a4cde912872002f2 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 21:22:17 -0400 Subject: [PATCH 17/31] fix(vendor): correct event classification + capstone e2e proofs - result_to_event: apply's yield-to-vendor marker is now an exact sentinel (VENDOR_OWNED_MARKER) instead of a .socket/vendor/ substring match, which misrouted every successful cargo/golang/composer/gem vendor to skipped/'vendored' (summary.applied == 0) - vendor rerun in-sync skips now report the contract's 'already_vendored' - golang takeover prunes empty .socket/go-patches parent husks - capstone e2e suites (npm ci, cargo --locked --offline w/ empty CARGO_HOME, go GOPROXY=off w/ empty GOMODCACHE + file:// proxy fixture, uv sync --frozen --offline + lock --check + byte-stable plain sync, pip/uv pip --no-index): 8 tests, all passing against the real toolchains, each with fresh-checkout committability proof + revert byte-restoration + the apply<->vendor takeover/yield interplay Full sweep: 96/96 CLI test binaries green; core 1239 (composer) / 983 (no-default) green. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/commands/apply.rs | 16 ++++++++++------ crates/socket-patch-cli/src/commands/vendor.rs | 15 ++++++++++++++- .../tests/e2e_vendor_cargo_build.rs | 3 +-- .../tests/e2e_vendor_golang_build.rs | 3 +-- .../socket-patch-cli/tests/in_process_vendor.rs | 2 +- .../socket-patch-core/src/patch/vendor/golang.rs | 15 +++++++++++++++ 6 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 4cd5e40..562f27d 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -115,10 +115,7 @@ async fn try_local_go_apply( if socket_patch_core::patch::vendor::is_purl_vendored(&common.cwd, purl).await { return Some(ApplyResult { package_key: purl.to_string(), - package_path: format!( - "{}/golang (managed by vendor)", - socket_patch_core::patch::vendor::VENDOR_DIR - ), + package_path: VENDOR_OWNED_MARKER.to_string(), success: true, files_verified: Vec::new(), files_patched: Vec::new(), @@ -299,6 +296,11 @@ async fn run_check(args: &ApplyArgs, _manifest_path: &Path) -> i32 { /// True when every file the engine verified for this package is already /// at its `afterHash` — i.e. the patch is a complete no-op on disk. /// +/// Sentinel `package_path` for a result synthesized because the purl is +/// owned by `socket-patch vendor` (recorded in `.socket/vendor/state.json`). +/// `result_to_event` routes it to `Skipped`/`vendored` by exact equality. +pub(crate) const VENDOR_OWNED_MARKER: &str = "managed by socket-patch vendor"; + /// Single source of truth for the `already_patched` classification, shared /// by [`result_to_event`] (which feeds the JSON envelope) and the /// human-readable summaries so both label packages identically. @@ -372,8 +374,10 @@ pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent // A package managed by `socket-patch vendor` is skipped with its own // reason: apply runs implicitly (postinstall/CI) and must never flip // ownership back from the explicit vendor action. The synthesized result - // carries the vendored path as its package_path, which is the marker. - if result.package_path.contains(".socket/vendor/") { + // carries the exact sentinel as its package_path — an equality check, NOT + // a substring match: the vendor command's own successful results carry + // real `.socket/vendor/…` copy paths and must classify as Applied. + if result.package_path == VENDOR_OWNED_MARKER { return PatchEvent::new(PatchAction::Skipped, purl) .with_reason("vendored", "managed by `socket-patch vendor`"); } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index addbaba..419a3d7 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -559,7 +559,20 @@ async fn run_vendor(args: &VendorArgs, manifest_path: &Path, env: &mut Envelope) ); } } - env.record(result_to_event(&result, common.dry_run)); + let mut event = result_to_event(&result, common.dry_run); + // The shared translator's in-sync classification reads + // `already_patched`; under `vendor` the contract tag is + // `already_vendored` (artifact + wiring already in sync). + if event.action == PatchAction::Skipped + && event.error_code.as_deref() == Some("already_patched") + { + event = PatchEvent::new(PatchAction::Skipped, candidate.clone()) + .with_reason( + "already_vendored", + "artifact and lockfile wiring already in sync", + ); + } + env.record(event); for w in &warnings { record_warning(env, candidate, w, common); } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs index 8b5b804..6616be2 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs @@ -248,7 +248,7 @@ fn cargo_vendor_fresh_checkout_locked_offline_build_and_revert() { assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); // NOTE: summary.applied / the event action are asserted in the - // #[ignore]d `cargo_vendor_reports_applied_event` below — a successful + // `cargo_vendor_reports_applied_event` below — a successful // cargo vendor is currently misreported as skipped/`vendored` (see the // BUG note there). The on-disk + build assertions here are unaffected. @@ -411,7 +411,6 @@ fn cargo_vendor_fresh_checkout_locked_offline_build_and_revert() { /// package_path is a stage tempdir / site-packages). Human output says /// "Vendored 0 package(s); 1 skipped" and `track_patch_vendored` reports 0. #[test] -#[ignore = "BUG: result_to_event misroutes successful cargo/golang/composer/gem vendors to skipped/`vendored` (summary.applied == 0) because the backends' package_path is the .socket/vendor/ copy dir"] fn cargo_vendor_reports_applied_event() { if !has_command("cargo") { println!("SKIP: `cargo` not installed"); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs index 60ecd88..5e24e71 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs @@ -246,7 +246,7 @@ fn go_vendor_fresh_checkout_offline_build_and_revert() { let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); - // NOTE: summary.applied / the event action are pinned in the #[ignore]d + // NOTE: summary.applied / the event action are pinned in the // `go_vendor_reports_applied_event` below — successful golang vendors // are currently misreported as skipped/`vendored` (shared // result_to_event bug). The wiring/build proofs here are unaffected. @@ -513,7 +513,6 @@ fn go_apply_vendor_interplay_takeover_and_yield() { /// `e2e_vendor_cargo_build.rs` for the root cause (shared `result_to_event` /// misroutes results whose package_path is the `.socket/vendor/` copy dir). #[test] -#[ignore = "BUG: result_to_event misroutes successful cargo/golang/composer/gem vendors to skipped/`vendored` (summary.applied == 0) because the backends' package_path is the .socket/vendor/ copy dir"] fn go_vendor_reports_applied_event() { if !has_command("go") || !has_command("zip") { println!("SKIP: `go`/`zip` not installed"); diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index d4cb3e5..4edc36f 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -353,7 +353,7 @@ async fn rerun_is_idempotent() { // The in-sync re-run synthesizes its result against the vendored // artifact path, which routes to the `vendored` skip reason (the same // tag `apply` uses for vendor-owned packages) — pin the actual contract. - let skipped = find_event(&env, "skipped", Some("vendored")); + let skipped = find_event(&env, "skipped", Some("already_vendored")); assert_eq!(skipped["purl"], PURL); // NOTHING on disk churned. diff --git a/crates/socket-patch-core/src/patch/vendor/golang.rs b/crates/socket-patch-core/src/patch/vendor/golang.rs index 55a4a30..d7d8bdd 100644 --- a/crates/socket-patch-core/src/patch/vendor/golang.rs +++ b/crates/socket-patch-core/src/patch/vendor/golang.rs @@ -158,6 +158,21 @@ pub async fn vendor_go_module( .join(GO_PATCHES_DIR) .join(format!("{module}@{version}")); let _ = remove_tree(&stale).await; + // Prune now-empty parent husks (`/example.com/`) up to + // and including the go-patches root. `remove_dir` is non-recursive: + // a parent still holding another module's copy fails harmlessly. + let go_patches_root = project_root.join(GO_PATCHES_DIR); + let mut parent = stale.parent().map(|p| p.to_path_buf()); + while let Some(dir) = parent { + if !dir.starts_with(&go_patches_root) || dir < go_patches_root { + break; + } + if tokio::fs::remove_dir(&dir).await.is_err() { + break; // non-empty (or already gone) — stop pruning + } + parent = dir.parent().map(|p| p.to_path_buf()); + } + let _ = tokio::fs::remove_dir(&go_patches_root).await; warnings.push(VendorWarning::new( "vendor_takeover", format!( From 0e602d685ede781be6f02c8eefee4ce090507834 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 9 Jun 2026 21:36:54 -0400 Subject: [PATCH 18/31] fix(vendor): security + scoping fixes from adversarial review - SECURITY (blocker): pypi requirements revert joined the tamper-able state.json wiring 'file' path unvalidated -> a poisoned ledger could make 'vendor --revert' splice attacker lines into an arbitrary file (../, abs). Each recorded path now re-passes the in-root constraint vendor-time planning enforced; unsafe records skip fail-closed with a warning. RED-verified regression test (out-of-tree target stays byte-untouched). - reconcile_dropped now respects this run's --ecosystems scope (a scoped 'vendor --ecosystems npm' no longer silently reverts cargo/go entries) - revert orphan sweep keys on (ecosystem, uuid), not uuid alone Reviewer verified all other deletion paths, lockfile writes, the apply sentinel, and VEX attestation fail-closed under tampered manifest/state. Co-Authored-By: Claude Fable 5 --- .../socket-patch-cli/src/commands/vendor.rs | 20 +++++- .../src/patch/vendor/pypi_requirements.rs | 70 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 419a3d7..1463351 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -657,11 +657,21 @@ async fn reconcile_dropped( Ok(s) => s, Err(_) => return false, // unreadable state is reported by the main path }; + // Respect this run's --ecosystems scope: a `vendor --ecosystems npm` + // invocation must not silently revert a cargo/go entry (restoring its + // lockfile and deleting its artifact) as a cross-ecosystem side effect. + let in_scope = |eco: &str| match common.ecosystems.as_deref() { + None => true, + Some(list) => list.iter().any(|e| { + e.eq_ignore_ascii_case(eco) || (eco == "golang" && e.eq_ignore_ascii_case("go")) + }), + }; let stale: Vec = state .entries .iter() .filter(|(purl, entry)| { - !manifest.patches.contains_key(*purl) + in_scope(&entry.ecosystem) + && !manifest.patches.contains_key(*purl) && !manifest.patches.contains_key(&entry.base_purl) }) .map(|(purl, _)| purl.clone()) @@ -752,9 +762,13 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { // wiring for these is already gone or owned by a recorded entry, so // removal is safe; unparseable dirs are reported, never deleted. let swept = vendor::path::sweep_vendor_dirs(&common.cwd).await; - let recorded_uuids: HashSet<&str> = state.entries.values().map(|e| e.uuid.as_str()).collect(); + let recorded_units: HashSet<(&str, &str)> = state + .entries + .values() + .map(|e| (e.ecosystem.as_str(), e.uuid.as_str())) + .collect(); for unit in swept { - if recorded_uuids.contains(unit.uuid.as_str()) { + if recorded_units.contains(&(unit.eco.as_str(), unit.uuid.as_str())) { continue; } if !common.dry_run { diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs index c77a6dd..bc02e3a 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs @@ -141,8 +141,34 @@ pub async fn revert_requirements( let mut warnings: Vec = Vec::new(); // Group records per file, preserving application order within each. + // + // SECURITY: `rec.file` comes verbatim from the committed, tamper-able + // state.json and is about to be READ and atomically REWRITTEN. Every + // other backend writes only to fixed/whitelisted lockfile paths; the + // requirements flavor legitimately edits multiple files (`-r` includes), + // so each recorded path must re-pass the same in-root constraint + // vendor-time planning enforced — a `..`/absolute/NUL path would + // otherwise let a poisoned ledger splice attacker `original` lines into + // an arbitrary file via `vendor --revert`. Reject fail-closed per file + // (skip + drift warning), never fail open. let mut files: Vec = Vec::new(); for rec in &entry.wiring { + let norm = rec.file.replace('\\', "/"); + if norm.is_empty() + || norm.starts_with('/') + || norm.contains('\0') + || !crate::patch::apply::is_safe_relative_subpath(&norm) + { + warnings.push(VendorWarning::new( + "vendor_revert_line_drifted", + format!( + "refusing to revert wiring record for unsafe path `{}` \ + (outside the project root)", + rec.file + ), + )); + continue; + } if !files.contains(&rec.file) { files.push(rec.file.clone()); } @@ -904,6 +930,50 @@ mod tests { // ── revert edge cases ──────────────────────────────────────────────── + /// SECURITY regression: a poisoned state.json wiring record naming a + /// `..`/absolute `file` must never make `--revert` read or rewrite a file + /// outside the project root — the record is skipped with a warning and + /// the out-of-tree target stays byte-identical. (Found by adversarial + /// review: revert previously joined `rec.file` unvalidated, an arbitrary + /// content-injection write.) + #[tokio::test] + async fn revert_refuses_unsafe_wiring_file_paths() { + let outer = tempfile::tempdir().unwrap(); + let root = outer.path().join("project"); + tokio::fs::create_dir_all(&root).await.unwrap(); + // A precious sibling OUTSIDE the project root. + let precious = outer.path().join("precious.txt"); + tokio::fs::write(&precious, "keep me intact\n").await.unwrap(); + + for bad in ["../precious.txt", "/etc/hosts", "a/../../precious.txt"] { + let wiring = vec![WiringRecord { + file: bad.to_string(), + kind: "requirements_line".to_string(), + action: WiringAction::Rewritten, + key: None, + original: Some(serde_json::json!(["malicious payload"])), + new: Some(serde_json::json!("keep me intact")), + }]; + let outcome = revert_requirements(&entry_for(wiring), &root, false).await; + assert!( + outcome.success, + "unsafe record is skipped (fail-closed), not a hard error: {bad}" + ); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_revert_line_drifted"), + "skip must be surfaced for {bad}" + ); + } + assert_eq!( + tokio::fs::read_to_string(&precious).await.unwrap(), + "keep me intact\n", + "out-of-tree file must be byte-untouched" + ); + } + #[tokio::test] async fn revert_warns_on_drifted_line_and_leaves_it() { let tmp = write_root("six==1.16.0\n").await; From dd3a80ce01ca0d9a7815a487f5a17174ae9a2ffb Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 09:25:10 -0400 Subject: [PATCH 19/31] =?UTF-8?q?feat(vendor):=20v2=20phase=201=20?= =?UTF-8?q?=E2=80=94=20boolish=20env=20parsing,=20composer=20reference=20u?= =?UTF-8?q?uid,=20checksum=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --force (apply+vendor) and --revert use parse_bool_flag: empty env => false (the GlobalArgs contract); the 2 ignored parse pins are now passing tests - composer lock dist.reference carries the patch uuid (preserved verbatim into installed.json: in-tree traceability surviving .socket-stripped deploys); entry_is_wired deliberately NOT tightened (ledger-clobber hazard) - cargo revert pin: a lock that gained [[patch.unused]] post-vendor still restores cleanly - CLI_CONTRACT: per-ecosystem checksum-coverage table Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 16 ++++++++ crates/socket-patch-cli/src/args.rs | 2 +- crates/socket-patch-cli/src/commands/apply.rs | 2 +- .../socket-patch-cli/src/commands/vendor.rs | 4 +- .../tests/cli_parse_vendor.rs | 40 ------------------- .../src/patch/vendor/cargo_lock.rs | 37 +++++++++++++++++ .../src/patch/vendor/composer_lock.rs | 20 ++++++++-- 7 files changed, 73 insertions(+), 48 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 9f5812e..336636c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -351,6 +351,22 @@ refuse per-purl with stable reason codes pointing at the native alternative (`ya the `.pth` setup hook, …). PURLs of **compiled-out** ecosystems are invisible to `vendor` exactly as they are to `apply` (the binary cannot parse them). +### Checksum coverage + +Every checksum-like field a lockfile carries for a vendored package is updated coherently — +never inherited from the registry entry (a stale checksum either hard-fails the install or, +worse, lets a warm cache silently serve unpatched bytes): + +| eco / flavor | checksum/reference fields | vendor behavior | +|---|---|---| +| npm (lock v2/v3) | `packages[].integrity` + `resolved`; v2 legacy `dependencies` mirror; `dependencies`/`peerDependencies`/`optionalDependencies`/`bin` mirrors | integrity recomputed (sha512 of the packed tarball); `resolved` → relative `file:`; legacy mirror rewritten; dep mirrors recomputed when the patch touches the package's package.json | +| cargo | `[[package]].source` + `checksum`; `.cargo-checksum.json` in the copy | both lock keys removed (the canonical path-dep form); checksum sidecar excluded from the copy; originals kept verbatim in the ledger for `--revert` | +| golang | `go.sum` | untouched **by design** — directory `replace` targets are never sum-verified. Caveat: a user `go mod tidy` may prune the replaced module's go.sum lines; revert does not restore them (the next online build re-adds them) | +| composer | `dist.{url,reference,shasum}`, `source.reference`, `content-hash` | `dist` → `{type: path, url, reference: ""}` (the uuid is preserved verbatim into `installed.json` — in-tree traceability); `source` removed; `content-hash` untouched (covers composer.json only) | +| gem | `CHECKSUMS` section (bundler ≥ 2.6 opt-in) | the vendored gem's entry rewritten to bundler's own path-gem form so re-locks stay byte-stable; original line in the ledger | +| pypi / uv | `wheels[].hash`, `sdist.hash`, requires-dist specifiers | single `{filename, hash: sha256-of-our-wheel}`; sdist dropped; dropped specifiers ledgered for revert | +| pypi / requirements | `--hash=sha256:` | fresh hash of the rebuilt wheel always emitted (turns on pip's hash-checking for the line) | + ### Ownership, state, and reversal * `.socket/vendor/state.json` (committed) is the revert ledger: every wiring edit records the diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 5b9e878..e26a2e2 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -60,7 +60,7 @@ fn parse_supported_ecosystem(s: &str) -> Result { /// clap abort the whole command with `invalid value '' for '--offline': value /// was not a boolean`. Every bool flag here reads such an env var, so a single /// stray empty var crashed every subcommand before it could do any work. -fn parse_bool_flag(s: &str) -> Result { +pub(crate) fn parse_bool_flag(s: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { "" | "n" | "no" | "f" | "false" | "off" | "0" => Ok(false), "y" | "yes" | "t" | "true" | "on" | "1" => Ok(true), diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 562f27d..6f4a68d 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -42,7 +42,7 @@ pub struct ApplyArgs { long, env = "SOCKET_FORCE", default_value_t = false, - value_parser = clap::builder::BoolishValueParser::new(), + value_parser = crate::args::parse_bool_flag, )] pub force: bool, diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 1463351..b141136 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -49,7 +49,7 @@ pub struct VendorArgs { long, env = "SOCKET_FORCE", default_value_t = false, - value_parser = clap::builder::BoolishValueParser::new(), + value_parser = crate::args::parse_bool_flag, )] pub force: bool, @@ -59,7 +59,7 @@ pub struct VendorArgs { long = "revert", env = "SOCKET_VENDOR_REVERT", default_value_t = false, - value_parser = clap::builder::BoolishValueParser::new(), + value_parser = crate::args::parse_bool_flag, )] pub revert: bool, diff --git a/crates/socket-patch-cli/tests/cli_parse_vendor.rs b/crates/socket-patch-cli/tests/cli_parse_vendor.rs index 9f0552c..4e6f96d 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vendor.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vendor.rs @@ -470,31 +470,8 @@ fn env_socket_force_numeric_one_should_set_force() { assert_eq!(snapshot(&a), want); } -/// PINNED CURRENT BEHAVIOR: an exported-but-empty `SOCKET_FORCE=` (which -/// shells and CI routinely use to mean "unset") aborts the parse with -/// `a value is required for '--force' but none was supplied`. This is the -/// exact bug class `GlobalArgs::parse_bool_flag` was introduced to fix for -/// the global bools (empty ⇒ false); vendor's own bools were left out. #[test] #[serial_test::serial] -fn env_socket_force_empty_currently_rejected() { - let err = match parse_vendor_with_env(&[("SOCKET_FORCE", "")], &[]) { - Err(e) => e, - Ok(_) => panic!("SOCKET_FORCE= (empty) currently aborts the parse"), - }; - // Don't over-pin the exact kind (clap renders this particular failure as - // a missing-value error); the contract being pinned is "the parse dies". - assert!( - err.use_stderr(), - "an empty SOCKET_FORCE must currently be a hard parse failure, got: {err}" - ); -} - -#[test] -#[serial_test::serial] -#[ignore = "BUG: SOCKET_FORCE= (exported-but-empty) crashes every `vendor` invocation with a \ - clap value error — the same empty-env-var crash fixed for all GlobalArgs bools via \ - parse_bool_flag (empty must mean false), but --force was left on clap's strict parser"] fn env_socket_force_empty_should_parse_as_false() { let a = parse_vendor_with_env(&[("SOCKET_FORCE", "")], &[]) .expect("an exported-but-empty bool env var must not abort the parse"); @@ -525,25 +502,8 @@ fn env_socket_vendor_revert_falsey_tokens_keep_revert_off() { } } -/// PINNED CURRENT BEHAVIOR: `BoolishValueParser` has no empty-string -/// special case, so `SOCKET_VENDOR_REVERT=` (exported-but-empty) aborts the -/// whole command with `value was not a boolean` (ErrorKind::ValueValidation) -/// instead of meaning "unset". Same gap as `SOCKET_FORCE=` above. -#[test] -#[serial_test::serial] -fn env_socket_vendor_revert_empty_currently_rejected() { - let err = match parse_vendor_with_env(&[("SOCKET_VENDOR_REVERT", "")], &[]) { - Err(e) => e, - Ok(_) => panic!("SOCKET_VENDOR_REVERT= (empty) currently aborts the parse"), - }; - assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation); -} - #[test] #[serial_test::serial] -#[ignore = "BUG: SOCKET_VENDOR_REVERT= (exported-but-empty) crashes every `vendor` invocation \ - via ValueValidation — BoolishValueParser rejects empty; should parse as false like \ - the GlobalArgs bools (parse_bool_flag treats empty as unset/false)"] fn env_socket_vendor_revert_empty_should_parse_as_false() { let a = parse_vendor_with_env(&[("SOCKET_VENDOR_REVERT", "")], &[]) .expect("an exported-but-empty bool env var must not abort the parse"); diff --git a/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs b/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs index ea29e1c..5fe7384 100644 --- a/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs @@ -354,6 +354,43 @@ mod tests { assert!(matches!(err, LockEditError::Parse(_))); } + /// Drift pin: a lock that GAINED a `[[patch.unused]]` table after vendor + /// (a user added a dep whose resolution left an unused patch entry, or + /// hand-edits) must still restore the detached entry cleanly — the extra + /// table is untouched and the round trip stays byte-faithful for the + /// edited entry. + #[tokio::test] + async fn restore_tolerates_patch_unused_table_gained_post_vendor() { + let dir = fixture().await; + let orig = detach_lock_entry(dir.path(), "cfg-if", "1.0.4", false) + .await + .unwrap(); + + // Post-vendor drift: cargo appended a [[patch.unused]] section. + let mut body = tokio::fs::read_to_string(dir.path().join("Cargo.lock")) + .await + .unwrap(); + body.push_str("\n[[patch.unused]]\nname = \"other\"\nversion = \"2.0.0\"\n"); + tokio::fs::write(dir.path().join("Cargo.lock"), &body) + .await + .unwrap(); + + let restored = restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, false) + .await + .unwrap(); + assert!(restored, "detached entry must restore despite the extra table"); + + let after = tokio::fs::read_to_string(dir.path().join("Cargo.lock")) + .await + .unwrap(); + assert!(after.contains(&format!("source = \"{SOURCE}\""))); + assert!(after.contains(&format!("checksum = \"{CHECKSUM}\""))); + assert!( + after.contains("[[patch.unused]]") && after.contains("name = \"other\""), + "the drift table must be left untouched: {after}" + ); + } + #[tokio::test] async fn restore_skips_re_resolved_and_absent_entries() { let dir = fixture().await; diff --git a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs index 0b589bc..32fc713 100644 --- a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs @@ -240,7 +240,7 @@ pub async fn vendor_composer( result.error = Some("composer.lock entry is not a JSON object".to_string()); return VendorOutcome::Done { result, entry: None, warnings: Vec::new() }; }; - let rewritten = rewrite_lock_entry(original_obj, ©_rel); + let rewritten = rewrite_lock_entry(original_obj, ©_rel, &record.uuid); lock[section][idx] = Value::Object(rewritten.clone()); let write_result = match composer_json_bytes(&lock) { Ok(bytes) => atomic_write_bytes(&lock_path, &bytes).await, @@ -438,8 +438,17 @@ fn entry_is_wired(entry: &Value, dist_url: &str) -> bool { /// original slot with `transport-options` inserted right after it. A /// pre-existing `transport-options` is superseded by ours (never duplicated). /// A source-only entry without `dist` gets both appended at the end. -fn rewrite_lock_entry(original: &Map, dist_url: &str) -> Map { - let dist = json!({ "type": "path", "url": dist_url, "reference": null }); +fn rewrite_lock_entry( + original: &Map, + dist_url: &str, + patch_uuid: &str, +) -> Map { + // `reference` carries the patch uuid: composer preserves it verbatim into + // vendor/composer/installed.json (spike-proven for arbitrary strings), so + // SBOM/audit tooling can recover the patch from deployed artifacts even + // when `.socket/` is stripped from the image. The uuid was already + // canonical-validated by vendor_uuid_dir_rel before reaching here. + let dist = json!({ "type": "path", "url": dist_url, "reference": patch_uuid }); let transport = json!({ "symlink": false }); let mut out = Map::new(); let mut replaced_dist = false; @@ -795,7 +804,10 @@ mod tests { ); assert_eq!(e["dist"]["type"], "path"); assert_eq!(e["dist"]["url"], copy_rel()); - assert!(e["dist"]["reference"].is_null()); + assert_eq!( + e["dist"]["reference"], UUID, + "reference carries the patch uuid for in-tree traceability" + ); assert_eq!(e["transport-options"]["symlink"], json!(false)); // content-hash untouched (it covers composer.json only). assert_eq!(new_lock["content-hash"], "7a59d114f58e9b02546b21d7e57430d3"); From 4d78a43117601c8cab222f578aab4be9a7812b3d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 10:01:38 -0400 Subject: [PATCH 20/31] =?UTF-8?q?feat(vendor):=20v2=20phases=202-3+5=20?= =?UTF-8?q?=E2=80=94=20gem=20CHECKSUMS,=20flavor=20probes,=20refactors,=20?= =?UTF-8?q?composer/gem=20capstones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gem.rs: bundler >=2.6 CHECKSUMS section handled (spike-pinned bare-entry emitter, byte-identical to bundler's own re-lock; separate gemfile_lock_checksum wiring record; fail-closed on platform siblings / unparseable entries; v1-stale-token state refuses with vendor_stale_lock_checksum + revert guidance) - npm_flavor.rs: content-sniffing lock probe (PnP/bun.lockb/berry/lock-version refusals, multiple-lockfile warnings) + vendor_npm_any/revert_npm_any routers; CLI's npm_manager_refusal gate deleted - npm_common.rs: shared stage->patch->pack pipeline for all npm flavors; PackedTarball gains sha1_hex (yarn-classic resolved fragments) - toml_surgery.rs: pypi_uv's text-surgery helpers extracted for the poetry/pdm lock backends - state.rs: PnpmMeta/PoetryMeta/PdmMeta/PipenvMeta (additive, schema v1); forward-compat posture documented - pypi.rs: v2 flavor routing (uv > poetry > pdm > pipenv locks; lock-less markers refuse _no_lockfile with requirements fallthrough; pypi_multiple_lockfiles warning) with behavior-neutral Refused arms until the backends land - docker_vendor_common + composer/gem build-proof capstones (--network none fresh-checkout installs, red-probe-verified); Dockerfile.gem -> ruby 3.3 + bundler 2.7 pin; Dockerfile.pypi + pipenv; CI matrix runs the new suites - spikes/PHASE0-V2-FINDINGS.txt + 8 fixture-oracle dirs (all eight PMs GO) Suites: core 1273 / cli 272 / all integration binaries green. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 9 +- Cargo.lock | 12 + Cargo.toml | 1 + .../socket-patch-cli/src/commands/vendor.rs | 41 +- .../tests/docker_e2e_vendor_composer.rs | 336 ++++++ .../tests/docker_e2e_vendor_gem.rs | 334 ++++++ .../tests/docker_vendor_common/mod.rs | 197 ++++ .../socket-patch-cli/tests/e2e_vex_vendor.rs | 4 + .../tests/in_process_vendor.rs | 4 + crates/socket-patch-core/Cargo.toml | 1 + .../src/patch/vendor/cargo.rs | 4 + .../src/patch/vendor/composer_lock.rs | 4 + .../socket-patch-core/src/patch/vendor/gem.rs | 686 +++++++++++- .../src/patch/vendor/golang.rs | 4 + .../socket-patch-core/src/patch/vendor/mod.rs | 8 + .../src/patch/vendor/npm_common.rs | 419 ++++++++ .../src/patch/vendor/npm_flavor.rs | 657 ++++++++++++ .../src/patch/vendor/npm_lock.rs | 233 +--- .../src/patch/vendor/npm_pack.rs | 13 + .../src/patch/vendor/pypi.rs | 287 +++-- .../src/patch/vendor/pypi_requirements.rs | 4 + .../src/patch/vendor/pypi_uv.rs | 211 +--- .../src/patch/vendor/state.rs | 153 ++- .../src/patch/vendor/toml_surgery.rs | 305 ++++++ .../src/patch/vendor/verify.rs | 4 + crates/socket-patch-core/src/vex/verify.rs | 4 + spikes/PHASE0-V2-FINDINGS.txt | 198 ++++ spikes/bun/README.md | 76 ++ spikes/bun/bn1-file-deps/after/bun.lock | 18 + spikes/bun/bn1-file-deps/after/package.json | 8 + spikes/bun/bn1-file-deps/before/package.json | 8 + spikes/bun/bn1-nested/after/bun.lock | 20 + spikes/bun/bn1-nested/after/package.json | 8 + spikes/bun/bn1-nested/before/package.json | 8 + spikes/bun/bn2-overrides/after/bun.lock | 18 + spikes/bun/bn2-overrides/after/package.json | 10 + spikes/bun/bn2-overrides/before/bun.lock | 15 + spikes/bun/bn2-overrides/before/package.json | 7 + spikes/bun/bn2-resolutions/after/bun.lock | 18 + spikes/bun/bn2-resolutions/after/package.json | 10 + spikes/bun/bn3-lock-only/after/bun.lock | 15 + spikes/bun/bn3-lock-only/after/package.json | 7 + spikes/bun/bn3-lock-only/before/bun.lock | 15 + spikes/bun/bn3-lock-only/before/package.json | 7 + .../bun/bn4-override-collapse/after/bun.lock | 21 + .../bn4-override-collapse/after/package.json | 11 + .../bun/bn4-override-collapse/before/bun.lock | 20 + .../bn4-override-collapse/before/package.json | 8 + .../bn4b-version-key-ignored/after/bun.lock | 23 + .../after/package.json | 11 + .../bun/bn4c-targeted-nested/after/bun.lock | 20 + .../bn4c-targeted-nested/after/package.json | 8 + .../bun/bn4c-targeted-nested/before/bun.lock | 20 + .../bn4c-targeted-nested/before/package.json | 8 + spikes/gem-checksums/README.md | 111 ++ .../after/.bundle/config | 3 + .../bare-checksum-registry-gem/after/Gemfile | 3 + .../after/Gemfile.lock | 17 + .../before/.bundle/config | 3 + .../bare-checksum-registry-gem/before/Gemfile | 3 + .../before/Gemfile.lock | 17 + .../path-with-checksums/after/.bundle/config | 3 + .../path-with-checksums/after/Gemfile | 3 + .../path-with-checksums/after/Gemfile.lock | 21 + .../after/vendored/rack-3.1.8/CHANGELOG.md | 998 ++++++++++++++++++ .../after/vendored/rack-3.1.8/CONTRIBUTING.md | 144 +++ .../after/vendored/rack-3.1.8/MIT-LICENSE | 20 + .../after/vendored/rack-3.1.8/README.md | 328 ++++++ .../after/vendored/rack-3.1.8/SPEC.rdoc | 365 +++++++ .../after/vendored/rack-3.1.8/lib/rack.rb | 66 ++ .../lib/rack/auth/abstract/handler.rb | 41 + .../lib/rack/auth/abstract/request.rb | 49 + .../rack-3.1.8/lib/rack/auth/basic.rb | 58 + .../rack-3.1.8/lib/rack/bad_request.rb | 8 + .../rack-3.1.8/lib/rack/body_proxy.rb | 63 ++ .../vendored/rack-3.1.8/lib/rack/builder.rb | 290 +++++ .../vendored/rack-3.1.8/lib/rack/cascade.rb | 67 ++ .../rack-3.1.8/lib/rack/common_logger.rb | 88 ++ .../rack-3.1.8/lib/rack/conditional_get.rb | 86 ++ .../vendored/rack-3.1.8/lib/rack/config.rb | 22 + .../vendored/rack-3.1.8/lib/rack/constants.rb | 67 ++ .../rack-3.1.8/lib/rack/content_length.rb | 34 + .../rack-3.1.8/lib/rack/content_type.rb | 33 + .../vendored/rack-3.1.8/lib/rack/deflater.rb | 158 +++ .../vendored/rack-3.1.8/lib/rack/directory.rb | 205 ++++ .../vendored/rack-3.1.8/lib/rack/etag.rb | 68 ++ .../vendored/rack-3.1.8/lib/rack/events.rb | 157 +++ .../vendored/rack-3.1.8/lib/rack/files.rb | 216 ++++ .../vendored/rack-3.1.8/lib/rack/head.rb | 26 + .../vendored/rack-3.1.8/lib/rack/headers.rb | 238 +++++ .../vendored/rack-3.1.8/lib/rack/lint.rb | 991 +++++++++++++++++ .../vendored/rack-3.1.8/lib/rack/lock.rb | 29 + .../vendored/rack-3.1.8/lib/rack/logger.rb | 23 + .../rack-3.1.8/lib/rack/media_type.rb | 48 + .../rack-3.1.8/lib/rack/method_override.rb | 56 + .../vendored/rack-3.1.8/lib/rack/mime.rb | 694 ++++++++++++ .../vendored/rack-3.1.8/lib/rack/mock.rb | 3 + .../rack-3.1.8/lib/rack/mock_request.rb | 161 +++ .../rack-3.1.8/lib/rack/mock_response.rb | 124 +++ .../vendored/rack-3.1.8/lib/rack/multipart.rb | 77 ++ .../lib/rack/multipart/generator.rb | 99 ++ .../rack-3.1.8/lib/rack/multipart/parser.rb | 502 +++++++++ .../lib/rack/multipart/uploaded_file.rb | 45 + .../rack-3.1.8/lib/rack/null_logger.rb | 48 + .../rack-3.1.8/lib/rack/query_parser.rb | 200 ++++ .../vendored/rack-3.1.8/lib/rack/recursive.rb | 66 ++ .../vendored/rack-3.1.8/lib/rack/reloader.rb | 112 ++ .../vendored/rack-3.1.8/lib/rack/request.rb | 796 ++++++++++++++ .../vendored/rack-3.1.8/lib/rack/response.rb | 403 +++++++ .../rack-3.1.8/lib/rack/rewindable_input.rb | 113 ++ .../vendored/rack-3.1.8/lib/rack/runtime.rb | 35 + .../vendored/rack-3.1.8/lib/rack/sendfile.rb | 167 +++ .../rack-3.1.8/lib/rack/show_exceptions.rb | 407 +++++++ .../rack-3.1.8/lib/rack/show_status.rb | 123 +++ .../vendored/rack-3.1.8/lib/rack/static.rb | 187 ++++ .../rack-3.1.8/lib/rack/tempfile_reaper.rb | 33 + .../vendored/rack-3.1.8/lib/rack/urlmap.rb | 99 ++ .../vendored/rack-3.1.8/lib/rack/utils.rb | 631 +++++++++++ .../vendored/rack-3.1.8/lib/rack/version.rb | 21 + .../after/vendored/rack-3.1.8/rack.gemspec | 31 + .../path-with-checksums/before/.bundle/config | 3 + .../path-with-checksums/before/Gemfile | 3 + .../path-with-checksums/before/Gemfile.lock | 17 + .../after/.bundle/config | 3 + .../registry-with-checksums/after/Gemfile | 3 + .../after/Gemfile.lock | 17 + .../before/.bundle/config | 3 + .../registry-with-checksums/before/Gemfile | 3 + .../after/.bundle/config | 3 + .../stale-checksum-v1-bug/after/Gemfile | 3 + .../stale-checksum-v1-bug/after/Gemfile.lock | 21 + .../after/vendored/rack-3.1.8/CHANGELOG.md | 998 ++++++++++++++++++ .../after/vendored/rack-3.1.8/CONTRIBUTING.md | 144 +++ .../after/vendored/rack-3.1.8/MIT-LICENSE | 20 + .../after/vendored/rack-3.1.8/README.md | 328 ++++++ .../after/vendored/rack-3.1.8/SPEC.rdoc | 365 +++++++ .../after/vendored/rack-3.1.8/lib/rack.rb | 66 ++ .../lib/rack/auth/abstract/handler.rb | 41 + .../lib/rack/auth/abstract/request.rb | 49 + .../rack-3.1.8/lib/rack/auth/basic.rb | 58 + .../rack-3.1.8/lib/rack/bad_request.rb | 8 + .../rack-3.1.8/lib/rack/body_proxy.rb | 63 ++ .../vendored/rack-3.1.8/lib/rack/builder.rb | 290 +++++ .../vendored/rack-3.1.8/lib/rack/cascade.rb | 67 ++ .../rack-3.1.8/lib/rack/common_logger.rb | 88 ++ .../rack-3.1.8/lib/rack/conditional_get.rb | 86 ++ .../vendored/rack-3.1.8/lib/rack/config.rb | 22 + .../vendored/rack-3.1.8/lib/rack/constants.rb | 67 ++ .../rack-3.1.8/lib/rack/content_length.rb | 34 + .../rack-3.1.8/lib/rack/content_type.rb | 33 + .../vendored/rack-3.1.8/lib/rack/deflater.rb | 158 +++ .../vendored/rack-3.1.8/lib/rack/directory.rb | 205 ++++ .../vendored/rack-3.1.8/lib/rack/etag.rb | 68 ++ .../vendored/rack-3.1.8/lib/rack/events.rb | 157 +++ .../vendored/rack-3.1.8/lib/rack/files.rb | 216 ++++ .../vendored/rack-3.1.8/lib/rack/head.rb | 26 + .../vendored/rack-3.1.8/lib/rack/headers.rb | 238 +++++ .../vendored/rack-3.1.8/lib/rack/lint.rb | 991 +++++++++++++++++ .../vendored/rack-3.1.8/lib/rack/lock.rb | 29 + .../vendored/rack-3.1.8/lib/rack/logger.rb | 23 + .../rack-3.1.8/lib/rack/media_type.rb | 48 + .../rack-3.1.8/lib/rack/method_override.rb | 56 + .../vendored/rack-3.1.8/lib/rack/mime.rb | 694 ++++++++++++ .../vendored/rack-3.1.8/lib/rack/mock.rb | 3 + .../rack-3.1.8/lib/rack/mock_request.rb | 161 +++ .../rack-3.1.8/lib/rack/mock_response.rb | 124 +++ .../vendored/rack-3.1.8/lib/rack/multipart.rb | 77 ++ .../lib/rack/multipart/generator.rb | 99 ++ .../rack-3.1.8/lib/rack/multipart/parser.rb | 502 +++++++++ .../lib/rack/multipart/uploaded_file.rb | 45 + .../rack-3.1.8/lib/rack/null_logger.rb | 48 + .../rack-3.1.8/lib/rack/query_parser.rb | 200 ++++ .../vendored/rack-3.1.8/lib/rack/recursive.rb | 66 ++ .../vendored/rack-3.1.8/lib/rack/reloader.rb | 112 ++ .../vendored/rack-3.1.8/lib/rack/request.rb | 796 ++++++++++++++ .../vendored/rack-3.1.8/lib/rack/response.rb | 403 +++++++ .../rack-3.1.8/lib/rack/rewindable_input.rb | 113 ++ .../vendored/rack-3.1.8/lib/rack/runtime.rb | 35 + .../vendored/rack-3.1.8/lib/rack/sendfile.rb | 167 +++ .../rack-3.1.8/lib/rack/show_exceptions.rb | 407 +++++++ .../rack-3.1.8/lib/rack/show_status.rb | 123 +++ .../vendored/rack-3.1.8/lib/rack/static.rb | 187 ++++ .../rack-3.1.8/lib/rack/tempfile_reaper.rb | 33 + .../vendored/rack-3.1.8/lib/rack/urlmap.rb | 99 ++ .../vendored/rack-3.1.8/lib/rack/utils.rb | 631 +++++++++++ .../vendored/rack-3.1.8/lib/rack/version.rb | 21 + .../after/vendored/rack-3.1.8/rack.gemspec | 31 + .../before/.bundle/config | 3 + .../stale-checksum-v1-bug/before/Gemfile | 3 + .../stale-checksum-v1-bug/before/Gemfile.lock | 21 + .../before/vendored/rack-3.1.8/CHANGELOG.md | 998 ++++++++++++++++++ .../vendored/rack-3.1.8/CONTRIBUTING.md | 144 +++ .../before/vendored/rack-3.1.8/MIT-LICENSE | 20 + .../before/vendored/rack-3.1.8/README.md | 328 ++++++ .../before/vendored/rack-3.1.8/SPEC.rdoc | 365 +++++++ .../before/vendored/rack-3.1.8/lib/rack.rb | 66 ++ .../lib/rack/auth/abstract/handler.rb | 41 + .../lib/rack/auth/abstract/request.rb | 49 + .../rack-3.1.8/lib/rack/auth/basic.rb | 58 + .../rack-3.1.8/lib/rack/bad_request.rb | 8 + .../rack-3.1.8/lib/rack/body_proxy.rb | 63 ++ .../vendored/rack-3.1.8/lib/rack/builder.rb | 290 +++++ .../vendored/rack-3.1.8/lib/rack/cascade.rb | 67 ++ .../rack-3.1.8/lib/rack/common_logger.rb | 88 ++ .../rack-3.1.8/lib/rack/conditional_get.rb | 86 ++ .../vendored/rack-3.1.8/lib/rack/config.rb | 22 + .../vendored/rack-3.1.8/lib/rack/constants.rb | 67 ++ .../rack-3.1.8/lib/rack/content_length.rb | 34 + .../rack-3.1.8/lib/rack/content_type.rb | 33 + .../vendored/rack-3.1.8/lib/rack/deflater.rb | 158 +++ .../vendored/rack-3.1.8/lib/rack/directory.rb | 205 ++++ .../vendored/rack-3.1.8/lib/rack/etag.rb | 68 ++ .../vendored/rack-3.1.8/lib/rack/events.rb | 157 +++ .../vendored/rack-3.1.8/lib/rack/files.rb | 216 ++++ .../vendored/rack-3.1.8/lib/rack/head.rb | 26 + .../vendored/rack-3.1.8/lib/rack/headers.rb | 238 +++++ .../vendored/rack-3.1.8/lib/rack/lint.rb | 991 +++++++++++++++++ .../vendored/rack-3.1.8/lib/rack/lock.rb | 29 + .../vendored/rack-3.1.8/lib/rack/logger.rb | 23 + .../rack-3.1.8/lib/rack/media_type.rb | 48 + .../rack-3.1.8/lib/rack/method_override.rb | 56 + .../vendored/rack-3.1.8/lib/rack/mime.rb | 694 ++++++++++++ .../vendored/rack-3.1.8/lib/rack/mock.rb | 3 + .../rack-3.1.8/lib/rack/mock_request.rb | 161 +++ .../rack-3.1.8/lib/rack/mock_response.rb | 124 +++ .../vendored/rack-3.1.8/lib/rack/multipart.rb | 77 ++ .../lib/rack/multipart/generator.rb | 99 ++ .../rack-3.1.8/lib/rack/multipart/parser.rb | 502 +++++++++ .../lib/rack/multipart/uploaded_file.rb | 45 + .../rack-3.1.8/lib/rack/null_logger.rb | 48 + .../rack-3.1.8/lib/rack/query_parser.rb | 200 ++++ .../vendored/rack-3.1.8/lib/rack/recursive.rb | 66 ++ .../vendored/rack-3.1.8/lib/rack/reloader.rb | 112 ++ .../vendored/rack-3.1.8/lib/rack/request.rb | 796 ++++++++++++++ .../vendored/rack-3.1.8/lib/rack/response.rb | 403 +++++++ .../rack-3.1.8/lib/rack/rewindable_input.rb | 113 ++ .../vendored/rack-3.1.8/lib/rack/runtime.rb | 35 + .../vendored/rack-3.1.8/lib/rack/sendfile.rb | 167 +++ .../rack-3.1.8/lib/rack/show_exceptions.rb | 407 +++++++ .../rack-3.1.8/lib/rack/show_status.rb | 123 +++ .../vendored/rack-3.1.8/lib/rack/static.rb | 187 ++++ .../rack-3.1.8/lib/rack/tempfile_reaper.rb | 33 + .../vendored/rack-3.1.8/lib/rack/urlmap.rb | 99 ++ .../vendored/rack-3.1.8/lib/rack/utils.rb | 631 +++++++++++ .../vendored/rack-3.1.8/lib/rack/version.rb | 21 + .../before/vendored/rack-3.1.8/rack.gemspec | 31 + spikes/pdm/README.md | 83 ++ .../six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 38806 bytes spikes/pdm/direct-path-wheel/after/pdm.lock | 22 + .../direct-path-wheel/after/pyproject.toml | 15 + spikes/pdm/direct-path-wheel/before/pdm.lock | 22 + .../direct-path-wheel/before/pyproject.toml | 15 + spikes/pdm/direct-registry/after/pdm.lock | 22 + .../pdm/direct-registry/after/pyproject.toml | 15 + .../pdm/direct-registry/before/pyproject.toml | 15 + .../six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 38806 bytes spikes/pdm/transitive-path/after/pdm.lock | 36 + .../pdm/transitive-path/after/pyproject.toml | 18 + spikes/pdm/transitive-path/before/pdm.lock | 36 + .../pdm/transitive-path/before/pyproject.toml | 15 + spikes/pdm/transitive-registry/after/pdm.lock | 36 + .../transitive-registry/after/pyproject.toml | 15 + .../transitive-registry/before/pyproject.toml | 15 + spikes/pipenv/README.md | 123 +++ spikes/pipenv/artifacts/SHA256SUMS | 2 + .../original-six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 11053 bytes .../patched-six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 38859 bytes .../six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 38859 bytes spikes/pipenv/direct-file/Pipfile | 10 + spikes/pipenv/direct-file/Pipfile.lock | 29 + .../direct-file/Pipfile.lock.lock-only-edit | 28 + spikes/pipenv/direct-registry/Pipfile | 10 + spikes/pipenv/direct-registry/Pipfile.lock | 30 + .../six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 38859 bytes spikes/pipenv/transitive-file/Pipfile | 11 + spikes/pipenv/transitive-file/Pipfile.lock | 38 + .../Pipfile.lock.lock-only-edit | 37 + spikes/pipenv/transitive-registry/Pipfile | 10 + .../pipenv/transitive-registry/Pipfile.lock | 38 + spikes/pnpm/README.md | 76 ++ spikes/pnpm/edit_lock.py | 51 + .../p1-multi-dep/after/consumer/package.json | 7 + spikes/pnpm/p1-multi-dep/after/package.json | 15 + spikes/pnpm/p1-multi-dep/after/pnpm-lock.yaml | 45 + .../p1-multi-dep/before/consumer/package.json | 7 + spikes/pnpm/p1-multi-dep/before/package.json | 10 + .../pnpm/p1-multi-dep/before/pnpm-lock.yaml | 42 + .../p4-single-dep-offline/after/package.json | 13 + .../after/pnpm-lock.yaml | 26 + .../p4-single-dep-offline/before/package.json | 8 + .../before/pnpm-lock.yaml | 23 + spikes/pnpm/p7-workspace/after/package.json | 10 + .../after/packages/app/package.json | 7 + spikes/pnpm/p7-workspace/after/pnpm-lock.yaml | 28 + .../p7-workspace/after/pnpm-workspace.yaml | 2 + spikes/pnpm/p7-workspace/before/package.json | 5 + .../before/packages/app/package.json | 7 + .../pnpm/p7-workspace/before/pnpm-lock.yaml | 25 + .../p7-workspace/before/pnpm-workspace.yaml | 2 + spikes/poetry/README.md | 95 ++ .../six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 11196 bytes .../lock-2.0-direct/poetry.lock | 20 + .../lock-2.0-direct/pyproject.toml | 10 + .../six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 11196 bytes .../lock-2.0-transitive/poetry.lock | 34 + .../lock-2.0-transitive/pyproject.toml | 10 + .../six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 11196 bytes .../lock-2.1-direct/poetry.lock | 21 + .../lock-2.1-direct/pyproject.toml | 10 + .../six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 11196 bytes .../lock-2.1-transitive/poetry.lock | 36 + .../lock-2.1-transitive/pyproject.toml | 10 + .../six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 11196 bytes .../lock-2.0/direct-path-wheel/poetry.lock | 20 + .../lock-2.0/direct-path-wheel/pyproject.toml | 10 + .../lock-2.0/direct-registry/poetry.lock | 17 + .../lock-2.0/direct-registry/pyproject.toml | 10 + .../six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 11196 bytes .../lock-2.0/transitive-path/poetry.lock | 34 + .../lock-2.0/transitive-path/pyproject.toml | 11 + .../lock-2.0/transitive-registry/poetry.lock | 31 + .../transitive-registry/pyproject.toml | 10 + .../six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 11196 bytes .../lock-2.1/direct-path-wheel/poetry.lock | 21 + .../lock-2.1/direct-path-wheel/pyproject.toml | 10 + .../lock-2.1/direct-registry/poetry.lock | 18 + .../lock-2.1/direct-registry/pyproject.toml | 10 + .../six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 11196 bytes .../lock-2.1/transitive-path/poetry.lock | 36 + .../lock-2.1/transitive-path/pyproject.toml | 11 + .../lock-2.1/transitive-registry/poetry.lock | 33 + .../transitive-registry/pyproject.toml | 10 + .../original/six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 11053 bytes .../patched/six-1.16.0-py2.py3-none-any.whl | Bin 0 -> 11196 bytes spikes/yarn-berry-nm/README.md | 183 ++++ .../b1-omitted-checksum/after/yarn.lock | 21 + .../b1-omitted-checksum/before/.yarnrc.yml | 3 + .../b1-omitted-checksum/before/package.json | 11 + .../b1-omitted-checksum/before/yarn.lock | 20 + .../rebuilt-from-tgz.zip | Bin 0 -> 11599 bytes .../rebuilt-modeprobe.zip | Bin 0 -> 1012 bytes ...rn-cache-left-pad-file-8dfd6a0c16-10c0.zip | Bin 0 -> 11599 bytes .../yarn-cache-modeprobe-file-10c0.zip | Bin 0 -> 1012 bytes .../b3-vendored-resolutions/after/.yarnrc.yml | 3 + .../after/package.json | 11 + .../b3-vendored-resolutions/after/yarn.lock | 21 + .../before/.yarnrc.yml | 3 + .../before/package.json | 8 + .../b3-vendored-resolutions/before/yarn.lock | 21 + .../b4-compression-mixed/after/.yarnrc.yml | 4 + .../b4-compression-mixed/after/package.json | 11 + .../b4-compression-mixed/after/yarn.lock | 21 + .../b4-compression-mixed/before/.yarnrc.yml | 3 + .../b4-compression-mixed/before/package.json | 11 + .../b4-compression-mixed/before/yarn.lock | 21 + spikes/yarn-berry-nm/rebuild_zip.py | 82 ++ spikes/yarn-classic/README.md | 134 +++ .../y1-file-dep-ground-truth/package.json | 1 + .../y1-file-dep-ground-truth/yarn.lock | 7 + .../y2-lock-rewrite/after/package.json | 8 + .../y2-lock-rewrite/after/yarn.lock | 8 + .../y2-lock-rewrite/before/package.json | 1 + .../y2-lock-rewrite/before/yarn.lock | 8 + .../yarn-classic/y4-tamper/after/package.json | 8 + spikes/yarn-classic/y4-tamper/after/yarn.lock | 8 + .../y5-merged-alias/after/dep-a/package.json | 1 + .../y5-merged-alias/after/package.json | 10 + .../y5-merged-alias/after/yarn.lock | 18 + .../y5-merged-alias/before/dep-a/package.json | 1 + .../y5-merged-alias/before/package.json | 1 + .../y5-merged-alias/before/yarn.lock | 18 + .../yarn-classic/y8-berry-sniff/.yarnrc.yml | 1 + .../yarn-classic/y8-berry-sniff/package.json | 8 + spikes/yarn-classic/y8-berry-sniff/yarn.lock | 21 + tests/docker/Dockerfile.gem | 37 +- tests/docker/Dockerfile.pypi | 20 +- tests/docker/README.md | 31 + 377 files changed, 37481 insertions(+), 518 deletions(-) create mode 100644 crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs create mode 100644 crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs create mode 100644 crates/socket-patch-cli/tests/docker_vendor_common/mod.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/npm_common.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/npm_flavor.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/toml_surgery.rs create mode 100644 spikes/PHASE0-V2-FINDINGS.txt create mode 100644 spikes/bun/README.md create mode 100644 spikes/bun/bn1-file-deps/after/bun.lock create mode 100644 spikes/bun/bn1-file-deps/after/package.json create mode 100644 spikes/bun/bn1-file-deps/before/package.json create mode 100644 spikes/bun/bn1-nested/after/bun.lock create mode 100644 spikes/bun/bn1-nested/after/package.json create mode 100644 spikes/bun/bn1-nested/before/package.json create mode 100644 spikes/bun/bn2-overrides/after/bun.lock create mode 100644 spikes/bun/bn2-overrides/after/package.json create mode 100644 spikes/bun/bn2-overrides/before/bun.lock create mode 100644 spikes/bun/bn2-overrides/before/package.json create mode 100644 spikes/bun/bn2-resolutions/after/bun.lock create mode 100644 spikes/bun/bn2-resolutions/after/package.json create mode 100644 spikes/bun/bn3-lock-only/after/bun.lock create mode 100644 spikes/bun/bn3-lock-only/after/package.json create mode 100644 spikes/bun/bn3-lock-only/before/bun.lock create mode 100644 spikes/bun/bn3-lock-only/before/package.json create mode 100644 spikes/bun/bn4-override-collapse/after/bun.lock create mode 100644 spikes/bun/bn4-override-collapse/after/package.json create mode 100644 spikes/bun/bn4-override-collapse/before/bun.lock create mode 100644 spikes/bun/bn4-override-collapse/before/package.json create mode 100644 spikes/bun/bn4b-version-key-ignored/after/bun.lock create mode 100644 spikes/bun/bn4b-version-key-ignored/after/package.json create mode 100644 spikes/bun/bn4c-targeted-nested/after/bun.lock create mode 100644 spikes/bun/bn4c-targeted-nested/after/package.json create mode 100644 spikes/bun/bn4c-targeted-nested/before/bun.lock create mode 100644 spikes/bun/bn4c-targeted-nested/before/package.json create mode 100644 spikes/gem-checksums/README.md create mode 100644 spikes/gem-checksums/bare-checksum-registry-gem/after/.bundle/config create mode 100644 spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile create mode 100644 spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile.lock create mode 100644 spikes/gem-checksums/bare-checksum-registry-gem/before/.bundle/config create mode 100644 spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile create mode 100644 spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile.lock create mode 100644 spikes/gem-checksums/path-with-checksums/after/.bundle/config create mode 100644 spikes/gem-checksums/path-with-checksums/after/Gemfile create mode 100644 spikes/gem-checksums/path-with-checksums/after/Gemfile.lock create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CHANGELOG.md create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CONTRIBUTING.md create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/MIT-LICENSE create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/README.md create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/SPEC.rdoc create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/bad_request.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/builder.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/cascade.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/common_logger.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/config.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/constants.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_length.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_type.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/deflater.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/directory.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/etag.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/events.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/files.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/head.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/headers.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lint.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lock.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/logger.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/media_type.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/method_override.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mime.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_request.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_response.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/null_logger.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/query_parser.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/recursive.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/reloader.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/request.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/response.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/runtime.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/sendfile.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_status.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/static.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/urlmap.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/utils.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/version.rb create mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/rack.gemspec create mode 100644 spikes/gem-checksums/path-with-checksums/before/.bundle/config create mode 100644 spikes/gem-checksums/path-with-checksums/before/Gemfile create mode 100644 spikes/gem-checksums/path-with-checksums/before/Gemfile.lock create mode 100644 spikes/gem-checksums/registry-with-checksums/after/.bundle/config create mode 100644 spikes/gem-checksums/registry-with-checksums/after/Gemfile create mode 100644 spikes/gem-checksums/registry-with-checksums/after/Gemfile.lock create mode 100644 spikes/gem-checksums/registry-with-checksums/before/.bundle/config create mode 100644 spikes/gem-checksums/registry-with-checksums/before/Gemfile create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/.bundle/config create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile.lock create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CHANGELOG.md create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CONTRIBUTING.md create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/MIT-LICENSE create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/README.md create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/SPEC.rdoc create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/bad_request.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/builder.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/cascade.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/common_logger.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/config.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/constants.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_length.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_type.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/deflater.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/directory.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/etag.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/events.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/files.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/head.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/headers.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lint.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lock.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/logger.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/media_type.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/method_override.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mime.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_request.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_response.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/null_logger.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/query_parser.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/recursive.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/reloader.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/request.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/response.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/runtime.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/sendfile.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_status.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/static.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/urlmap.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/utils.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/version.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/rack.gemspec create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/.bundle/config create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile.lock create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CHANGELOG.md create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CONTRIBUTING.md create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/MIT-LICENSE create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/README.md create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/SPEC.rdoc create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/basic.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/bad_request.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/body_proxy.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/builder.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/cascade.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/common_logger.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/conditional_get.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/config.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/constants.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_length.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_type.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/deflater.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/directory.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/etag.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/events.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/files.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/head.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/headers.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lint.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lock.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/logger.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/media_type.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/method_override.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mime.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_request.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_response.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/generator.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/parser.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/null_logger.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/query_parser.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/recursive.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/reloader.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/request.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/response.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/rewindable_input.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/runtime.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/sendfile.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_exceptions.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_status.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/static.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/urlmap.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/utils.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/version.rb create mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/rack.gemspec create mode 100644 spikes/pdm/README.md create mode 100644 spikes/pdm/direct-path-wheel/after/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/pdm/direct-path-wheel/after/pdm.lock create mode 100644 spikes/pdm/direct-path-wheel/after/pyproject.toml create mode 100644 spikes/pdm/direct-path-wheel/before/pdm.lock create mode 100644 spikes/pdm/direct-path-wheel/before/pyproject.toml create mode 100644 spikes/pdm/direct-registry/after/pdm.lock create mode 100644 spikes/pdm/direct-registry/after/pyproject.toml create mode 100644 spikes/pdm/direct-registry/before/pyproject.toml create mode 100644 spikes/pdm/transitive-path/after/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/pdm/transitive-path/after/pdm.lock create mode 100644 spikes/pdm/transitive-path/after/pyproject.toml create mode 100644 spikes/pdm/transitive-path/before/pdm.lock create mode 100644 spikes/pdm/transitive-path/before/pyproject.toml create mode 100644 spikes/pdm/transitive-registry/after/pdm.lock create mode 100644 spikes/pdm/transitive-registry/after/pyproject.toml create mode 100644 spikes/pdm/transitive-registry/before/pyproject.toml create mode 100644 spikes/pipenv/README.md create mode 100644 spikes/pipenv/artifacts/SHA256SUMS create mode 100644 spikes/pipenv/artifacts/original-six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/pipenv/artifacts/patched-six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/pipenv/direct-file/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/pipenv/direct-file/Pipfile create mode 100644 spikes/pipenv/direct-file/Pipfile.lock create mode 100644 spikes/pipenv/direct-file/Pipfile.lock.lock-only-edit create mode 100644 spikes/pipenv/direct-registry/Pipfile create mode 100644 spikes/pipenv/direct-registry/Pipfile.lock create mode 100644 spikes/pipenv/transitive-file/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/pipenv/transitive-file/Pipfile create mode 100644 spikes/pipenv/transitive-file/Pipfile.lock create mode 100644 spikes/pipenv/transitive-file/Pipfile.lock.lock-only-edit create mode 100644 spikes/pipenv/transitive-registry/Pipfile create mode 100644 spikes/pipenv/transitive-registry/Pipfile.lock create mode 100644 spikes/pnpm/README.md create mode 100644 spikes/pnpm/edit_lock.py create mode 100644 spikes/pnpm/p1-multi-dep/after/consumer/package.json create mode 100644 spikes/pnpm/p1-multi-dep/after/package.json create mode 100644 spikes/pnpm/p1-multi-dep/after/pnpm-lock.yaml create mode 100644 spikes/pnpm/p1-multi-dep/before/consumer/package.json create mode 100644 spikes/pnpm/p1-multi-dep/before/package.json create mode 100644 spikes/pnpm/p1-multi-dep/before/pnpm-lock.yaml create mode 100644 spikes/pnpm/p4-single-dep-offline/after/package.json create mode 100644 spikes/pnpm/p4-single-dep-offline/after/pnpm-lock.yaml create mode 100644 spikes/pnpm/p4-single-dep-offline/before/package.json create mode 100644 spikes/pnpm/p4-single-dep-offline/before/pnpm-lock.yaml create mode 100644 spikes/pnpm/p7-workspace/after/package.json create mode 100644 spikes/pnpm/p7-workspace/after/packages/app/package.json create mode 100644 spikes/pnpm/p7-workspace/after/pnpm-lock.yaml create mode 100644 spikes/pnpm/p7-workspace/after/pnpm-workspace.yaml create mode 100644 spikes/pnpm/p7-workspace/before/package.json create mode 100644 spikes/pnpm/p7-workspace/before/packages/app/package.json create mode 100644 spikes/pnpm/p7-workspace/before/pnpm-lock.yaml create mode 100644 spikes/pnpm/p7-workspace/before/pnpm-workspace.yaml create mode 100644 spikes/poetry/README.md create mode 100644 spikes/poetry/evidence-lockonly/lock-2.0-direct/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/poetry/evidence-lockonly/lock-2.0-direct/poetry.lock create mode 100644 spikes/poetry/evidence-lockonly/lock-2.0-direct/pyproject.toml create mode 100644 spikes/poetry/evidence-lockonly/lock-2.0-transitive/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/poetry/evidence-lockonly/lock-2.0-transitive/poetry.lock create mode 100644 spikes/poetry/evidence-lockonly/lock-2.0-transitive/pyproject.toml create mode 100644 spikes/poetry/evidence-lockonly/lock-2.1-direct/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/poetry/evidence-lockonly/lock-2.1-direct/poetry.lock create mode 100644 spikes/poetry/evidence-lockonly/lock-2.1-direct/pyproject.toml create mode 100644 spikes/poetry/evidence-lockonly/lock-2.1-transitive/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/poetry/evidence-lockonly/lock-2.1-transitive/poetry.lock create mode 100644 spikes/poetry/evidence-lockonly/lock-2.1-transitive/pyproject.toml create mode 100644 spikes/poetry/lock-2.0/direct-path-wheel/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/poetry/lock-2.0/direct-path-wheel/poetry.lock create mode 100644 spikes/poetry/lock-2.0/direct-path-wheel/pyproject.toml create mode 100644 spikes/poetry/lock-2.0/direct-registry/poetry.lock create mode 100644 spikes/poetry/lock-2.0/direct-registry/pyproject.toml create mode 100644 spikes/poetry/lock-2.0/transitive-path/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/poetry/lock-2.0/transitive-path/poetry.lock create mode 100644 spikes/poetry/lock-2.0/transitive-path/pyproject.toml create mode 100644 spikes/poetry/lock-2.0/transitive-registry/poetry.lock create mode 100644 spikes/poetry/lock-2.0/transitive-registry/pyproject.toml create mode 100644 spikes/poetry/lock-2.1/direct-path-wheel/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/poetry/lock-2.1/direct-path-wheel/poetry.lock create mode 100644 spikes/poetry/lock-2.1/direct-path-wheel/pyproject.toml create mode 100644 spikes/poetry/lock-2.1/direct-registry/poetry.lock create mode 100644 spikes/poetry/lock-2.1/direct-registry/pyproject.toml create mode 100644 spikes/poetry/lock-2.1/transitive-path/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/poetry/lock-2.1/transitive-path/poetry.lock create mode 100644 spikes/poetry/lock-2.1/transitive-path/pyproject.toml create mode 100644 spikes/poetry/lock-2.1/transitive-registry/poetry.lock create mode 100644 spikes/poetry/lock-2.1/transitive-registry/pyproject.toml create mode 100644 spikes/poetry/wheels/original/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/poetry/wheels/patched/six-1.16.0-py2.py3-none-any.whl create mode 100644 spikes/yarn-berry-nm/README.md create mode 100644 spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/after/yarn.lock create mode 100644 spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/.yarnrc.yml create mode 100644 spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/package.json create mode 100644 spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/yarn.lock create mode 100644 spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/rebuilt-from-tgz.zip create mode 100644 spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/rebuilt-modeprobe.zip create mode 100644 spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-left-pad-file-8dfd6a0c16-10c0.zip create mode 100644 spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-modeprobe-file-10c0.zip create mode 100644 spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/.yarnrc.yml create mode 100644 spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/package.json create mode 100644 spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/yarn.lock create mode 100644 spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/.yarnrc.yml create mode 100644 spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/package.json create mode 100644 spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/yarn.lock create mode 100644 spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/.yarnrc.yml create mode 100644 spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/package.json create mode 100644 spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/yarn.lock create mode 100644 spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/.yarnrc.yml create mode 100644 spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/package.json create mode 100644 spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/yarn.lock create mode 100644 spikes/yarn-berry-nm/rebuild_zip.py create mode 100644 spikes/yarn-classic/README.md create mode 100644 spikes/yarn-classic/y1-file-dep-ground-truth/package.json create mode 100644 spikes/yarn-classic/y1-file-dep-ground-truth/yarn.lock create mode 100644 spikes/yarn-classic/y2-lock-rewrite/after/package.json create mode 100644 spikes/yarn-classic/y2-lock-rewrite/after/yarn.lock create mode 100644 spikes/yarn-classic/y2-lock-rewrite/before/package.json create mode 100644 spikes/yarn-classic/y2-lock-rewrite/before/yarn.lock create mode 100644 spikes/yarn-classic/y4-tamper/after/package.json create mode 100644 spikes/yarn-classic/y4-tamper/after/yarn.lock create mode 100644 spikes/yarn-classic/y5-merged-alias/after/dep-a/package.json create mode 100644 spikes/yarn-classic/y5-merged-alias/after/package.json create mode 100644 spikes/yarn-classic/y5-merged-alias/after/yarn.lock create mode 100644 spikes/yarn-classic/y5-merged-alias/before/dep-a/package.json create mode 100644 spikes/yarn-classic/y5-merged-alias/before/package.json create mode 100644 spikes/yarn-classic/y5-merged-alias/before/yarn.lock create mode 100644 spikes/yarn-classic/y8-berry-sniff/.yarnrc.yml create mode 100644 spikes/yarn-classic/y8-berry-sniff/package.json create mode 100644 spikes/yarn-classic/y8-berry-sniff/yarn.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0a136a..10a3922 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -326,10 +326,17 @@ jobs: - name: Run ${{ matrix.ecosystem }} Docker e2e test with coverage run: | + # Vendor build-proof capstones ride the same image as their + # ecosystem's main suite (extend the case as new vendor suites land). + EXTRA="" + case "${{ matrix.ecosystem }}" in + composer) EXTRA="--test docker_e2e_vendor_composer" ;; + gem) EXTRA="--test docker_e2e_vendor_gem" ;; + esac cargo llvm-cov \ --features docker-e2e,cargo,golang,maven,composer,nuget,deno \ --no-report \ - --test docker_e2e_${{ matrix.ecosystem }} + --test docker_e2e_${{ matrix.ecosystem }} $EXTRA - name: Generate per-ecosystem lcov run: | diff --git a/Cargo.lock b/Cargo.lock index 70ad835..e79eb41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2341,6 +2341,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2442,6 +2453,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha1", "sha2", "tar", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 06d4b13..6821912 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ clap = { version = "=4.5.60", features = ["derive", "env"] } serde = { version = "=1.0.228", features = ["derive"] } serde_json = { version = "=1.0.149", features = ["preserve_order"] } sha2 = "=0.10.9" +sha1 = "=0.10.6" hex = "=0.4.3" reqwest = { version = "=0.12.28", features = ["rustls-tls", "json"], default-features = false } tokio = { version = "=1.50.0", features = ["full"] } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index b141136..eb5cd6e 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -11,7 +11,7 @@ use clap::Args; use socket_patch_core::api::client::get_api_client_with_overrides; -use socket_patch_core::crawlers::{detect_npm_pkg_manager, CrawlerOptions, Ecosystem, NpmPkgManager}; +use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{verify_file_patch, PatchSources}; @@ -92,7 +92,9 @@ async fn dispatch_vendor_one( let eco = ecosystem_dir_for_purl(purl)?; Some(match eco { "npm" => { - socket_patch_core::patch::vendor::npm_lock::vendor_npm( + // The flavor router probes the project's lockfile (package-lock / + // yarn / pnpm / bun) and dispatches or refuses per flavor. + socket_patch_core::patch::vendor::npm_flavor::vendor_npm_any( purl, pkg_path, project_root, @@ -184,8 +186,12 @@ async fn dispatch_revert_one( ) -> RevertOutcome { match entry.ecosystem.as_str() { "npm" => { - socket_patch_core::patch::vendor::npm_lock::revert_npm(entry, project_root, dry_run) - .await + socket_patch_core::patch::vendor::npm_flavor::revert_npm_any( + entry, + project_root, + dry_run, + ) + .await } "pypi" => { socket_patch_core::patch::vendor::pypi::revert_pypi(entry, project_root, dry_run).await @@ -400,17 +406,6 @@ async fn run_vendor(args: &VendorArgs, manifest_path: &Path, env: &mut Envelope) return i32::from(has_errors); } - // npm layout gate: vendor rewrites package-lock.json semantics only. - // yarn/pnpm/bun each have a native first-class patch flow; refuse their - // npm purls per-purl so other ecosystems still vendor. - let pkg_manager = detect_npm_pkg_manager(&common.cwd); - let npm_manager_refusal: Option<&str> = match pkg_manager { - NpmPkgManager::YarnBerryPnP | NpmPkgManager::YarnClassic => Some("yarn patch "), - NpmPkgManager::Pnpm => Some("pnpm patch "), - NpmPkgManager::Bun => Some("bun patch "), - _ => None, - }; - let vendorable_partition: HashMap> = partitioned .into_iter() .map(|(eco, purls)| { @@ -490,22 +485,6 @@ async fn run_vendor(args: &VendorArgs, manifest_path: &Path, env: &mut Envelope) } matched.insert(candidate.clone()); - // npm layout refusal. - if ecosystem_dir_for_purl(candidate) == Some("npm") { - if let Some(native) = npm_manager_refusal { - has_errors = true; - env.record( - PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( - "vendor_pkg_manager_unsupported", - format!( - "this project uses {pkg_manager:?}; socket-patch vendor only rewrites package-lock.json — use `{native}` instead" - ), - ), - ); - continue; - } - } - let outcome = dispatch_vendor_one( candidate, pkg_path, diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs new file mode 100644 index 0000000..224e9cd --- /dev/null +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs @@ -0,0 +1,336 @@ +//! Docker build-proof capstone for `socket-patch vendor` — composer flavor. +//! +//! Proves the CLI_CONTRACT "Vendor command contract" composer row end to end +//! against the REAL composer 2 inside `socket-patch-test-composer:latest`, +//! with state carried across containers via a bind-mounted host tempdir +//! (see `docker_vendor_common/mod.rs`): +//! +//! stage 1 (networked): `composer update` resolves a real psr/log 3.0.x +//! from packagist → a marker patch is hand-staged in-container (manifest +//! + blob; git-blob sha256 computed from the ACTUAL installed bytes) → +//! `socket-patch vendor --json --offline` (the binary baked into the +//! image) → asserts: artifact dir + `socket-patch.vendor.json` + +//! `state.json`, and the composer.lock entry rewired to +//! `dist: {type: path, url: , reference: }` + +//! `transport-options: {symlink: false}` + `source` removed, with +//! composer.json untouched. +//! stage 2 (`--network none`, empty COMPOSER_HOME): ONLY the committable +//! files (composer.json + composer.lock + .socket/) are copied to a +//! fresh dir; `composer install` must succeed cold+offline, materialize +//! `vendor/psr/log` as a REAL directory (not a symlink) whose patched +//! file is byte-identical to the blob, and propagate the patch uuid +//! into `vendor/composer/installed.json` (`dist.reference`). +//! stage 3 (`--network none`): re-vendor is idempotent (already_vendored, +//! lock sha256-stable) → `vendor --revert` restores composer.lock +//! byte-identical to the pre-vendor snapshot and removes `.socket/vendor` +//! entirely → a re-vendor succeeds again. +//! +//! The host side re-asserts the lock wiring independently (serde_json over +//! the mounted composer.lock) so a broken in-container php oracle can't +//! green-light a wrong lock. + +#![cfg(feature = "docker-e2e")] + +#[path = "docker_vendor_common/mod.rs"] +mod docker_vendor_common; + +use docker_vendor_common::{ + assert_stage_markers, bash_prelude, json_assert_fns, run_in_image, run_in_image_network_none, + skip_if_no_image, stage_patch_fn, +}; + +const IMAGE: &str = "socket-patch-test-composer:latest"; +/// Canonical lowercase patch uuid — a dedicated path level under +/// `.socket/vendor/composer/`, and the value `dist.reference` must carry. +const UUID: &str = "21212121-2121-4121-8121-212121212121"; + +/// Glue the shared bash helpers onto a stage body and pin the uuid. +fn render(stage_body: &str) -> String { + format!( + "{}{}{}{}", + bash_prelude(), + stage_patch_fn(), + json_assert_fns(), + stage_body + ) + .replace("__UUID__", UUID) +} + +/// Stage 1: real fixture install (network OK) + staged marker patch + +/// `vendor --json --offline` + artifact/wiring asserts + fresh-checkout +/// staging of ONLY the committable files. +const STAGE1: &str = r#" +mkdir -p /workspace/proj && cd /workspace/proj +# Keep the in-container socket-patch fully offline (also gates telemetry, +# which keys off the env var rather than the --offline flag). +export SOCKET_OFFLINE=1 + +cat > composer.json <<'EOF' +{ + "name": "socket/vendor-capstone", + "description": "socket-patch vendor docker capstone fixture", + "require": { + "psr/log": "3.0.*" + } +} +EOF + +# 1. REAL fixture: composer update resolves + installs psr/log from packagist. +composer update --no-interaction > /tmp/install.log 2>&1 || { + cat /tmp/install.log >&2; fail "composer update (fixture install) failed"; } + +PSR_VER=$(php -r ' + $l = json_decode(file_get_contents("composer.lock"), true); + foreach ($l["packages"] as $p) { + if ($p["name"] === "psr/log") { echo ltrim($p["version"], "v"); exit(0); } + } + exit(1); +') || fail "psr/log not present in composer.lock after update" +echo "resolved psr/log version: $PSR_VER" >&2 +case "$PSR_VER" in 3.0.*) ;; *) fail "expected a psr/log 3.0.x, got $PSR_VER" ;; esac + +ORIG=vendor/psr/log/src/LoggerInterface.php +[ -f "$ORIG" ] || fail "$ORIG missing after composer update" + +# Pristine pre-check: without this the post-vendor marker asserts are circular. +grep -q 'SOCKET-PATCH-VENDOR-E2E-MARKER' "$ORIG" \ + && fail "marker already in $ORIG BEFORE patching — fixture not pristine" + +# 2. Marker patch = the ACTUAL installed bytes + a trailing marker comment +# (still valid php). before/after git-blob hashes computed in-container. +cp "$ORIG" /tmp/patched.php +printf '\n// SOCKET-PATCH-VENDOR-E2E-MARKER patch=__UUID__\n' >> /tmp/patched.php +PURL="pkg:composer/psr/log@$PSR_VER" +stage_patch "$PURL" "__UUID__" "src/LoggerInterface.php" "$ORIG" /tmp/patched.php + +# Pre-vendor snapshots: consumed by stage 2/3 byte-identity asserts. +mkdir -p /workspace/snap +cp composer.json /workspace/snap/composer.json.prevendor +cp composer.lock /workspace/snap/composer.lock.prevendor +sha256sum /tmp/patched.php | cut -d' ' -f1 > /workspace/snap/patched.sha +echo "$PSR_VER" > /workspace/snap/psr-ver + +# 3. Vendor (fully offline: the blob is staged locally). +socket-patch vendor --json --offline > /tmp/vendor.json 2>/tmp/vendor.err +RC=$?; cat /tmp/vendor.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/vendor.json >&2; fail "vendor exited $RC (expected 0)"; } +assert_json_field /tmp/vendor.json '"status": "success"' +assert_json_field /tmp/vendor.json '"action": "applied"' +assert_json_field /tmp/vendor.json "$PURL" +assert_summary /tmp/vendor.json applied 1 +assert_summary /tmp/vendor.json failed 0 +echo "===VENDOR RUN VERIFIED===" + +# 4. Artifact under the stable path convention, patched byte-for-byte, +# plus the informational marker and the committed ledger. +COPY_REL=".socket/vendor/composer/__UUID__/psr/log@$PSR_VER" +[ -d "$COPY_REL" ] || fail "vendored copy missing at $COPY_REL" +grep -q 'SOCKET-PATCH-VENDOR-E2E-MARKER' "$COPY_REL/src/LoggerInterface.php" \ + || fail "patched marker missing in the vendored copy" +ACTUAL_SHA=$(sha256sum "$COPY_REL/src/LoggerInterface.php" | cut -d' ' -f1) +[ "$ACTUAL_SHA" = "$(cat /workspace/snap/patched.sha)" ] \ + || fail "vendored LoggerInterface.php is not byte-identical to the patch blob" +[ -f ".socket/vendor/composer/__UUID__/socket-patch.vendor.json" ] \ + || fail "informational socket-patch.vendor.json marker missing" +[ -f ".socket/vendor/state.json" ] || fail "vendor ledger (.socket/vendor/state.json) missing" +echo "===ARTIFACT VERIFIED===" + +# 5. Lock wiring (the composer contract row): dist → {type: path, url, +# reference: }, transport-options.symlink === false (forces a +# real copy), source REMOVED; composer.json byte-untouched. +php -r ' + $l = json_decode(file_get_contents("composer.lock"), true); + [$uuid, $rel] = [$argv[1], $argv[2]]; + foreach ($l["packages"] as $p) { + if ($p["name"] !== "psr/log") continue; + if (($p["dist"]["type"] ?? "") !== "path") { fwrite(STDERR, "dist.type != path\n"); exit(1); } + if (($p["dist"]["url"] ?? "") !== $rel) { fwrite(STDERR, "dist.url=".json_encode($p["dist"]["url"] ?? null)." != $rel\n"); exit(1); } + if (($p["dist"]["reference"] ?? "") !== $uuid) { fwrite(STDERR, "dist.reference != patch uuid\n"); exit(1); } + if (array_key_exists("source", $p)) { fwrite(STDERR, "source not removed\n"); exit(1); } + if (($p["transport-options"]["symlink"] ?? null) !== false) { fwrite(STDERR, "transport-options.symlink !== false\n"); exit(1); } + exit(0); + } + fwrite(STDERR, "psr/log entry not found in composer.lock packages[]\n"); exit(1); +' "__UUID__" "$COPY_REL" || { cat composer.lock >&2; fail "composer.lock wiring wrong"; } +cmp -s composer.json /workspace/snap/composer.json.prevendor \ + || fail "vendor must NOT touch composer.json (lock-only wiring)" +echo "===LOCK WIRING VERIFIED===" + +# 6. Fresh-checkout staging: ONLY the committable files. +rm -rf /workspace/fresh && mkdir -p /workspace/fresh +cp composer.json composer.lock /workspace/fresh/ +cp -R .socket /workspace/fresh/.socket +echo "===STAGE1 VERIFIED===" +exit 0 +"#; + +/// Stage 2 (`--network none`): strictest consumption proof. Cold composer +/// home/cache, no registry — the vendored path dist is the only possible +/// source of psr/log. +const STAGE2: &str = r#" +cd /workspace/fresh + +# Cold caches: empty COMPOSER_HOME + cache dir, fresh container. +export COMPOSER_HOME=/tmp/cold-composer-home +export COMPOSER_CACHE_DIR=/tmp/cold-composer-cache +mkdir -p "$COMPOSER_HOME" "$COMPOSER_CACHE_DIR" + +# The committable set must not have leaked an installed tree. +[ ! -e vendor ] || fail "fresh checkout already has vendor/ (test bug: uncommittable file copied)" + +composer install --no-interaction > /tmp/install.log 2>&1 || { + cat /tmp/install.log >&2; fail "cold-cache offline composer install failed"; } +cat /tmp/install.log >&2 + +# Real COPY, not a symlink (transport-options symlink:false is load-bearing). +[ -d vendor/psr/log ] || fail "vendor/psr/log missing after install" +[ ! -L vendor/psr/log ] || fail "vendor/psr/log is a SYMLINK — symlink:false not honored" + +F=vendor/psr/log/src/LoggerInterface.php +grep -q 'SOCKET-PATCH-VENDOR-E2E-MARKER' "$F" || { head -5 "$F" >&2; fail "patched marker missing in the installed copy"; } +ACTUAL_SHA=$(sha256sum "$F" | cut -d' ' -f1) +[ "$ACTUAL_SHA" = "$(cat /workspace/snap/patched.sha)" ] \ + || fail "installed $F not byte-identical to the patched blob (got $ACTUAL_SHA)" +echo "===FRESH INSTALL VERIFIED===" + +# In-tree traceability: composer preserves dist.reference verbatim into +# vendor/composer/installed.json — the patch uuid must survive there. +php -r ' + $i = json_decode(file_get_contents("vendor/composer/installed.json"), true); + $pkgs = $i["packages"] ?? $i; + foreach ($pkgs as $p) { + if (($p["name"] ?? "") !== "psr/log") continue; + if (($p["dist"]["reference"] ?? "") !== $argv[1]) { + fwrite(STDERR, "installed.json dist.reference=".json_encode($p["dist"]["reference"] ?? null)." != patch uuid\n"); exit(1); + } + exit(0); + } + fwrite(STDERR, "psr/log not found in vendor/composer/installed.json\n"); exit(1); +' "__UUID__" || fail "installed.json must carry dist.reference == patch uuid" +echo "===INSTALLED JSON VERIFIED===" +exit 0 +"#; + +/// Stage 3 (`--network none`): idempotent re-vendor → revert (byte-identical +/// lock restore + full `.socket/vendor` removal) → re-vendor works again. +const STAGE3: &str = r#" +cd /workspace/proj +export SOCKET_OFFLINE=1 +PSR_VER=$(cat /workspace/snap/psr-ver) +COPY_REL=".socket/vendor/composer/__UUID__/psr/log@$PSR_VER" + +# 1. Idempotency: a re-run reports already_vendored and leaves the lock +# byte-stable (sha oracle). +LOCK_SHA_BEFORE=$(sha256sum composer.lock | cut -d' ' -f1) +socket-patch vendor --json --offline > /tmp/revendor.json 2>/tmp/revendor.err +RC=$?; cat /tmp/revendor.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revendor.json >&2; fail "re-vendor exited $RC"; } +assert_summary /tmp/revendor.json failed 0 +assert_json_field /tmp/revendor.json '"already_vendored"' +[ "$LOCK_SHA_BEFORE" = "$(sha256sum composer.lock | cut -d' ' -f1)" ] \ + || fail "re-vendor churned composer.lock" +echo "===IDEMPOTENT VERIFIED===" + +# 2. Revert: composer.lock byte-identical to the pre-vendor snapshot, +# .socket/vendor (artifacts + ledger) fully gone. +socket-patch vendor --revert --json --offline > /tmp/revert.json 2>/tmp/revert.err +RC=$?; cat /tmp/revert.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revert.json >&2; fail "revert exited $RC"; } +assert_json_field /tmp/revert.json '"status": "success"' +assert_summary /tmp/revert.json removed 1 +cmp -s composer.lock /workspace/snap/composer.lock.prevendor \ + || fail "revert did not restore composer.lock byte-identical to the pre-vendor snapshot" +[ ! -e .socket/vendor ] || fail ".socket/vendor must be fully removed after revert" +echo "===REVERT VERIFIED===" + +# 3. Re-vendor after revert succeeds and rewires again. +socket-patch vendor --json --offline > /tmp/revendor2.json 2>/tmp/revendor2.err +RC=$?; cat /tmp/revendor2.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revendor2.json >&2; fail "post-revert re-vendor exited $RC"; } +assert_summary /tmp/revendor2.json applied 1 +assert_summary /tmp/revendor2.json failed 0 +[ -d "$COPY_REL" ] || fail "re-vendor did not recreate $COPY_REL" +grep -qF '"type": "path"' composer.lock || fail "re-vendor did not rewire composer.lock" +echo "===REVENDOR VERIFIED===" +exit 0 +"#; + +/// Host-side independent oracle on the bind-mounted composer.lock: the +/// in-container php asserts and this serde_json check would both have to be +/// wrong in the same way for a mis-wired lock to pass. +fn assert_lock_wired_from_host(host_dir: &std::path::Path) { + let lock_path = host_dir.join("proj/composer.lock"); + let lock: serde_json::Value = + serde_json::from_slice(&std::fs::read(&lock_path).expect("read mounted composer.lock")) + .expect("mounted composer.lock parses"); + let psr_ver = std::fs::read_to_string(host_dir.join("snap/psr-ver")) + .expect("snap/psr-ver") + .trim() + .to_string(); + let entry = lock["packages"] + .as_array() + .expect("packages[]") + .iter() + .find(|p| p["name"] == "psr/log") + .expect("psr/log entry in mounted composer.lock"); + assert_eq!( + entry["dist"]["type"], "path", + "host oracle: dist.type\n{entry}" + ); + assert_eq!( + entry["dist"]["url"], + format!(".socket/vendor/composer/{UUID}/psr/log@{psr_ver}"), + "host oracle: dist.url\n{entry}" + ); + assert_eq!( + entry["dist"]["reference"], UUID, + "host oracle: dist.reference\n{entry}" + ); + assert_eq!( + entry["transport-options"]["symlink"], + serde_json::Value::Bool(false), + "host oracle: transport-options.symlink\n{entry}" + ); + assert!( + entry.get("source").is_none(), + "host oracle: source must be removed\n{entry}" + ); +} + +#[test] +fn composer_vendor_fresh_checkout_install_and_revert() { + if skip_if_no_image(IMAGE) { + return; + } + let tmp = tempfile::tempdir().expect("tempdir"); + // Canonicalize so the macOS `/var` → `/private/var` symlink doesn't + // confuse Docker Desktop's file-sharing allowlist. + let host_dir = tmp.path().canonicalize().expect("canonicalize tempdir"); + + // Stage 1 — networked fixture install + offline vendor + wiring asserts. + let out = run_in_image(IMAGE, &host_dir, &render(STAGE1)); + assert_stage_markers( + "composer stage 1 (install+vendor)", + &out, + &["VENDOR RUN", "ARTIFACT", "LOCK WIRING", "STAGE1"], + ); + assert_lock_wired_from_host(&host_dir); + + // Stage 2 — fresh checkout, cold caches, network cut. + let out = run_in_image_network_none(IMAGE, &host_dir, &render(STAGE2)); + assert_stage_markers( + "composer stage 2 (fresh checkout, --network none)", + &out, + &["FRESH INSTALL", "INSTALLED JSON"], + ); + + // Stage 3 — idempotency, revert, re-vendor (still no network). + let out = run_in_image_network_none(IMAGE, &host_dir, &render(STAGE3)); + assert_stage_markers( + "composer stage 3 (idempotent+revert+re-vendor)", + &out, + &["IDEMPOTENT", "REVERT", "REVENDOR"], + ); + // Suite leaves the project re-vendored; the host oracle must hold again. + assert_lock_wired_from_host(&host_dir); +} diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs new file mode 100644 index 0000000..1fffbd9 --- /dev/null +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs @@ -0,0 +1,334 @@ +//! Docker build-proof capstone for `socket-patch vendor` — gem flavor. +//! +//! Proves the CLI_CONTRACT "Vendor command contract" gem row end to end +//! against the REAL bundler (pinned `~> 2.7` in `tests/docker/Dockerfile.gem`) +//! inside `socket-patch-test-gem:latest`, with state carried across +//! containers via a bind-mounted host tempdir (see +//! `docker_vendor_common/mod.rs`): +//! +//! stage 1 (networked): Gemfile `gem "rack", "~> 3.1"` + `bundle config +//! set --local path vendor/bundle` + `bundle install` resolve a real +//! rack → a marker patch on `lib/rack.rb` is hand-staged in-container +//! (manifest + blob; git-blob sha256 from the ACTUAL installed bytes; +//! the marker reopens `module Rack` with a probe constant so the patch +//! is observable at `require` time) → `socket-patch vendor --json +//! --offline` → asserts: vendored gem dir + materialized `rack.gemspec` +//! + `socket-patch.vendor.json` + `state.json`; the Gemfile line gained +//! the exact pin + `path:`; the lock gained the canonical PATH section +//! (before GEM) and the `rack (= )!` DEPENDENCIES pin. +//! stage 2 (`--network none`, `BUNDLE_FROZEN=true`): ONLY the committable +//! files (Gemfile, Gemfile.lock, .socket/, .bundle/config) in a fresh +//! dir; `bundle install` exits 0 cold+offline with a byte-stable lock, +//! and `bundle exec ruby -e 'require "rack"'` resolves the probe +//! constant AND loads rack from the vendored path. +//! stage 3 (`--network none`): re-vendor idempotent (already_vendored, +//! Gemfile + lock byte-stable) → `vendor --revert` byte-restores BOTH +//! Gemfile and Gemfile.lock and removes `.socket/vendor` entirely → +//! re-vendor succeeds again. +//! +//! This suite deliberately runs against a lock WITHOUT a `CHECKSUMS` section +//! (bundler keeps `lockfile_checksums` opt-in, and CHECKSUMS-aware vendoring +//! is a parallel workstream) — stage 1 hard-asserts that precondition. +//! TODO(v2 gem CHECKSUMS): add the lockfile_checksums variant (fixture with +//! `bundle config set --local lockfile_checksums true` before the first +//! lock; expect the vendored entry rewritten to bundler's bare path-gem +//! CHECKSUMS form per spikes/gem-checksums/). + +#![cfg(feature = "docker-e2e")] + +#[path = "docker_vendor_common/mod.rs"] +mod docker_vendor_common; + +use docker_vendor_common::{ + assert_stage_markers, bash_prelude, json_assert_fns, run_in_image, run_in_image_network_none, + skip_if_no_image, stage_patch_fn, +}; + +const IMAGE: &str = "socket-patch-test-gem:latest"; +/// Canonical lowercase patch uuid — the dedicated path level under +/// `.socket/vendor/gem/` and the runtime probe constant's value. +const UUID: &str = "32323232-3232-4232-8232-323232323232"; + +/// Glue the shared bash helpers onto a stage body and pin the uuid. +fn render(stage_body: &str) -> String { + format!( + "{}{}{}{}", + bash_prelude(), + stage_patch_fn(), + json_assert_fns(), + stage_body + ) + .replace("__UUID__", UUID) +} + +/// Stage 1: real bundler fixture (network OK) + staged marker patch + +/// `vendor --json --offline` + pair-edit asserts + fresh-checkout staging. +const STAGE1: &str = r#" +mkdir -p /workspace/proj && cd /workspace/proj +# Keep the in-container socket-patch fully offline (also gates telemetry, +# which keys off the env var rather than the --offline flag). +export SOCKET_OFFLINE=1 +# The official ruby image points BUNDLE_APP_CONFIG at /usr/local/bundle, +# which would hijack `bundle config set --local`; pin it back to the +# project so .bundle/config is a real committable file. +export BUNDLE_APP_CONFIG="$PWD/.bundle" + +cat > Gemfile <<'EOF' +source "https://rubygems.org" + +gem "rack", "~> 3.1" +EOF + +bundle config set --local path vendor/bundle || fail "bundle config set --local path" +[ -f .bundle/config ] || fail ".bundle/config not created (BUNDLE_APP_CONFIG override failed?)" + +# 1. REAL fixture: bundle install resolves rack from rubygems.org into the +# project-local vendor/bundle. +bundle install > /tmp/install.log 2>&1 || { cat /tmp/install.log >&2; fail "bundle install (fixture) failed"; } + +RACK_VER=$(sed -n 's/^ rack (\([0-9][0-9.]*\))$/\1/p' Gemfile.lock | head -1) +[ -n "$RACK_VER" ] || { cat Gemfile.lock >&2; fail "could not read the resolved rack version from Gemfile.lock"; } +echo "resolved rack version: $RACK_VER" >&2 + +# Precondition this suite is scoped to: NO CHECKSUMS section (bundler >= 2.6 +# keeps lockfile_checksums opt-in; CHECKSUMS-aware vendoring is a parallel +# workstream — see the module doc TODO). +grep -q '^CHECKSUMS' Gemfile.lock && fail "Gemfile.lock unexpectedly has a CHECKSUMS section — this suite requires the default (no-CHECKSUMS) lock" + +RUBY_API=$(ruby -e 'puts Gem.ruby_api_version') || fail "ruby api version probe" +GEM_DIR="vendor/bundle/ruby/$RUBY_API/gems/rack-$RACK_VER" +ORIG="$GEM_DIR/lib/rack.rb" +[ -f "$ORIG" ] || { ls -R vendor/bundle/ruby >&2 || true; fail "$ORIG missing after bundle install"; } + +# Pristine pre-checks (file AND runtime): otherwise the post-vendor marker +# asserts are circular. +grep -q 'SOCKET_PATCH_VENDOR_E2E' "$ORIG" && fail "probe constant already in $ORIG — fixture not pristine" +bundle exec ruby -e 'require "rack"; exit(defined?(Rack::SOCKET_PATCH_VENDOR_E2E) ? 1 : 0)' \ + || fail "probe constant already defined at runtime — fixture not pristine" + +# 2. Marker patch = the ACTUAL installed bytes + a reopened `module Rack` +# defining a probe constant (observable via `require "rack"`). +cp "$ORIG" /tmp/patched.rb +cat >> /tmp/patched.rb <<'EOF' + +# SOCKET-PATCH-VENDOR-E2E-MARKER +module Rack + SOCKET_PATCH_VENDOR_E2E = "__UUID__" +end +EOF +PURL="pkg:gem/rack@$RACK_VER" +stage_patch "$PURL" "__UUID__" "lib/rack.rb" "$ORIG" /tmp/patched.rb + +# Pre-vendor snapshots: consumed by stage 2/3 byte-identity asserts. +mkdir -p /workspace/snap +cp Gemfile /workspace/snap/Gemfile.prevendor +cp Gemfile.lock /workspace/snap/Gemfile.lock.prevendor +sha256sum /tmp/patched.rb | cut -d' ' -f1 > /workspace/snap/patched.sha +echo "$RACK_VER" > /workspace/snap/rack-ver + +# 3. Vendor (fully offline: the blob is staged locally). +socket-patch vendor --json --offline > /tmp/vendor.json 2>/tmp/vendor.err +RC=$?; cat /tmp/vendor.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/vendor.json >&2; fail "vendor exited $RC (expected 0)"; } +assert_json_field /tmp/vendor.json '"status": "success"' +assert_json_field /tmp/vendor.json '"action": "applied"' +assert_json_field /tmp/vendor.json "$PURL" +assert_summary /tmp/vendor.json applied 1 +assert_summary /tmp/vendor.json failed 0 +echo "===VENDOR RUN VERIFIED===" + +# 4. Artifact: gem dir under the stable path convention, patched +# byte-for-byte, with the stub gemspec materialized next to it (a path +# source needs one), plus the informational marker and the ledger. +COPY_REL=".socket/vendor/gem/__UUID__/rack-$RACK_VER" +[ -d "$COPY_REL" ] || fail "vendored gem dir missing at $COPY_REL" +grep -q 'SOCKET-PATCH-VENDOR-E2E-MARKER' "$COPY_REL/lib/rack.rb" || fail "marker missing in the vendored copy" +ACTUAL_SHA=$(sha256sum "$COPY_REL/lib/rack.rb" | cut -d' ' -f1) +[ "$ACTUAL_SHA" = "$(cat /workspace/snap/patched.sha)" ] \ + || fail "vendored lib/rack.rb is not byte-identical to the patch blob" +[ -f "$COPY_REL/rack.gemspec" ] || { ls "$COPY_REL" >&2; fail "stub gemspec not materialized into the vendored dir"; } +[ -f ".socket/vendor/gem/__UUID__/socket-patch.vendor.json" ] || fail "informational socket-patch.vendor.json marker missing" +[ -f ".socket/vendor/state.json" ] || fail "vendor ledger (.socket/vendor/state.json) missing" +echo "===ARTIFACT VERIFIED===" + +# 5. The MANDATORY pair edit (a lock-only edit is a silent unpatch): +# Gemfile line gains the exact pin + path:, the lock gains a PATH section +# (before GEM, relative remote, spec moved over) and the DEPENDENCIES +# entry becomes the ` (= )!` pin. +grep -qF "gem \"rack\", \"$RACK_VER\", path: \"$COPY_REL\"" Gemfile \ + || { cat Gemfile >&2; fail "Gemfile line not rewritten to the exact-pin + path: form"; } +grep -q '^PATH$' Gemfile.lock || { cat Gemfile.lock >&2; fail "no PATH section in Gemfile.lock"; } +grep -qF " remote: $COPY_REL" Gemfile.lock || { cat Gemfile.lock >&2; fail "PATH remote is not the relative vendored path"; } +grep -qF " rack ($RACK_VER)" Gemfile.lock || { cat Gemfile.lock >&2; fail "rack spec block missing from PATH specs"; } +grep -qF " rack (= $RACK_VER)!" Gemfile.lock || { cat Gemfile.lock >&2; fail "DEPENDENCIES pin ' rack (= $RACK_VER)!' missing"; } +awk '/^PATH$/{p=NR} /^GEM$/{g=NR} END{exit !(p && g && p&2; fail "PATH section must precede GEM"; } +echo "===LOCK WIRING VERIFIED===" + +# 6. Fresh-checkout staging: ONLY the committable files. +rm -rf /workspace/fresh && mkdir -p /workspace/fresh +cp Gemfile Gemfile.lock /workspace/fresh/ +cp -R .socket /workspace/fresh/.socket +cp -R .bundle /workspace/fresh/.bundle +echo "===STAGE1 VERIFIED===" +exit 0 +"#; + +/// Stage 2 (`--network none` + `BUNDLE_FROZEN=true`): strictest consumption +/// proof — cold caches, frozen lock, no registry; the vendored path source +/// is the only possible provider of rack, and the patched constant must be +/// visible at `require` time. +const STAGE2: &str = r#" +cd /workspace/fresh +export BUNDLE_APP_CONFIG="$PWD/.bundle" +export BUNDLE_FROZEN=true +RACK_VER=$(cat /workspace/snap/rack-ver) + +# Cold-cache premise: the fresh container has no bundle cache, no project +# vendor/, and the image gem home must not already satisfy rack. +[ ! -e vendor ] || fail "fresh checkout already has vendor/ (test bug: uncommittable file copied)" +gem list -i '^rack$' > /dev/null && fail "rack pre-installed in the image gem home — cold-cache premise broken" + +LOCK_SHA_BEFORE=$(sha256sum Gemfile.lock | cut -d' ' -f1) +bundle install > /tmp/install.log 2>&1 || { cat /tmp/install.log >&2; fail "frozen cold-cache offline bundle install failed"; } +cat /tmp/install.log >&2 +[ "$LOCK_SHA_BEFORE" = "$(sha256sum Gemfile.lock | cut -d' ' -f1)" ] \ + || fail "bundle install churned the committed Gemfile.lock" +echo "===FRESH INSTALL VERIFIED===" + +# Runtime proof: rack must load FROM the vendored path and expose the +# patched probe constant carrying the patch uuid. +OUT=$(bundle exec ruby -e ' + require "rack" + abort "probe constant missing after require" unless defined?(Rack::SOCKET_PATCH_VENDOR_E2E) + puts Rack::SOCKET_PATCH_VENDOR_E2E + puts $LOADED_FEATURES.grep(%r{/rack\.rb\z}) +' 2>&1) || { echo "$OUT" >&2; fail "bundle exec runtime probe failed"; } +echo "$OUT" >&2 +echo "$OUT" | grep -qF "__UUID__" || fail "probe constant does not carry the patch uuid" +echo "$OUT" | grep -qF ".socket/vendor/gem/__UUID__/rack-$RACK_VER/lib/rack.rb" \ + || fail "rack was not loaded from the vendored path" +echo "===RUNTIME MARKER VERIFIED===" +exit 0 +"#; + +/// Stage 3 (`--network none`): idempotent re-vendor → revert byte-restores +/// the Gemfile + lock pair and removes `.socket/vendor` → re-vendor again. +const STAGE3: &str = r#" +cd /workspace/proj +export SOCKET_OFFLINE=1 +export BUNDLE_APP_CONFIG="$PWD/.bundle" +RACK_VER=$(cat /workspace/snap/rack-ver) +COPY_REL=".socket/vendor/gem/__UUID__/rack-$RACK_VER" + +# 1. Idempotency: re-run reports already_vendored, both files byte-stable. +GEMFILE_SHA=$(sha256sum Gemfile | cut -d' ' -f1) +LOCK_SHA=$(sha256sum Gemfile.lock | cut -d' ' -f1) +socket-patch vendor --json --offline > /tmp/revendor.json 2>/tmp/revendor.err +RC=$?; cat /tmp/revendor.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revendor.json >&2; fail "re-vendor exited $RC"; } +assert_summary /tmp/revendor.json failed 0 +assert_json_field /tmp/revendor.json '"already_vendored"' +[ "$LOCK_SHA" = "$(sha256sum Gemfile.lock | cut -d' ' -f1)" ] || fail "re-vendor churned Gemfile.lock" +[ "$GEMFILE_SHA" = "$(sha256sum Gemfile | cut -d' ' -f1)" ] || fail "re-vendor churned Gemfile" +echo "===IDEMPOTENT VERIFIED===" + +# 2. Revert: BOTH halves of the pair edit byte-restored, artifacts gone. +socket-patch vendor --revert --json --offline > /tmp/revert.json 2>/tmp/revert.err +RC=$?; cat /tmp/revert.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revert.json >&2; fail "revert exited $RC"; } +assert_json_field /tmp/revert.json '"status": "success"' +assert_summary /tmp/revert.json removed 1 +cmp -s Gemfile /workspace/snap/Gemfile.prevendor \ + || { diff /workspace/snap/Gemfile.prevendor Gemfile >&2 || true; fail "revert did not byte-restore the Gemfile"; } +cmp -s Gemfile.lock /workspace/snap/Gemfile.lock.prevendor \ + || { diff /workspace/snap/Gemfile.lock.prevendor Gemfile.lock >&2 || true; fail "revert did not byte-restore Gemfile.lock"; } +[ ! -e .socket/vendor ] || fail ".socket/vendor must be fully removed after revert" +echo "===REVERT VERIFIED===" + +# 3. Re-vendor after revert succeeds and re-wires the pair. +socket-patch vendor --json --offline > /tmp/revendor2.json 2>/tmp/revendor2.err +RC=$?; cat /tmp/revendor2.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revendor2.json >&2; fail "post-revert re-vendor exited $RC"; } +assert_summary /tmp/revendor2.json applied 1 +assert_summary /tmp/revendor2.json failed 0 +[ -d "$COPY_REL" ] || fail "re-vendor did not recreate $COPY_REL" +grep -qF "path: \"$COPY_REL\"" Gemfile || fail "re-vendor did not rewire the Gemfile" +grep -qF " rack (= $RACK_VER)!" Gemfile.lock || fail "re-vendor did not rewire Gemfile.lock" +echo "===REVENDOR VERIFIED===" +exit 0 +"#; + +/// Host-side independent oracle on the bind-mounted Gemfile + Gemfile.lock: +/// re-asserts the pair edit without trusting the in-container greps. +fn assert_pair_wired_from_host(host_dir: &std::path::Path) { + let rack_ver = std::fs::read_to_string(host_dir.join("snap/rack-ver")) + .expect("snap/rack-ver") + .trim() + .to_string(); + let copy_rel = format!(".socket/vendor/gem/{UUID}/rack-{rack_ver}"); + + let gemfile = + std::fs::read_to_string(host_dir.join("proj/Gemfile")).expect("read mounted Gemfile"); + assert!( + gemfile.contains(&format!( + "gem \"rack\", \"{rack_ver}\", path: \"{copy_rel}\"" + )), + "host oracle: Gemfile not in the exact-pin + path: form:\n{gemfile}" + ); + + let lock = std::fs::read_to_string(host_dir.join("proj/Gemfile.lock")) + .expect("read mounted Gemfile.lock"); + assert!( + lock.contains(&format!( + "PATH\n remote: {copy_rel}\n specs:\n rack ({rack_ver})" + )), + "host oracle: canonical PATH section missing:\n{lock}" + ); + assert!( + lock.contains(&format!("\n rack (= {rack_ver})!")), + "host oracle: DEPENDENCIES pin missing:\n{lock}" + ); + assert!( + !lock.contains("\nCHECKSUMS"), + "host oracle: this suite must run against a no-CHECKSUMS lock:\n{lock}" + ); +} + +#[test] +fn gem_vendor_fresh_checkout_bundle_install_and_revert() { + if skip_if_no_image(IMAGE) { + return; + } + let tmp = tempfile::tempdir().expect("tempdir"); + // Canonicalize so the macOS `/var` → `/private/var` symlink doesn't + // confuse Docker Desktop's file-sharing allowlist. + let host_dir = tmp.path().canonicalize().expect("canonicalize tempdir"); + + // Stage 1 — networked fixture install + offline vendor + pair-edit asserts. + let out = run_in_image(IMAGE, &host_dir, &render(STAGE1)); + assert_stage_markers( + "gem stage 1 (install+vendor)", + &out, + &["VENDOR RUN", "ARTIFACT", "LOCK WIRING", "STAGE1"], + ); + assert_pair_wired_from_host(&host_dir); + + // Stage 2 — fresh checkout, frozen + cold caches + network cut. + let out = run_in_image_network_none(IMAGE, &host_dir, &render(STAGE2)); + assert_stage_markers( + "gem stage 2 (fresh checkout, --network none, BUNDLE_FROZEN)", + &out, + &["FRESH INSTALL", "RUNTIME MARKER"], + ); + + // Stage 3 — idempotency, revert, re-vendor (still no network). + let out = run_in_image_network_none(IMAGE, &host_dir, &render(STAGE3)); + assert_stage_markers( + "gem stage 3 (idempotent+revert+re-vendor)", + &out, + &["IDEMPOTENT", "REVERT", "REVENDOR"], + ); + // Suite leaves the project re-vendored; the host oracle must hold again. + assert_pair_wired_from_host(&host_dir); +} diff --git a/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs b/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs new file mode 100644 index 0000000..16a9082 --- /dev/null +++ b/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs @@ -0,0 +1,197 @@ +//! Shared harness for the Docker build-proof `vendor` capstone suites +//! (`docker_e2e_vendor_.rs`). +//! +//! Unlike the `docker_e2e_.rs` scan→apply suites (one self-contained +//! container run against a host wiremock), the vendor capstones drive a +//! MULTI-STAGE lifecycle where state must survive between containers: +//! +//! stage 1 (networked): real package-manager fixture install + staged +//! marker patch + `socket-patch vendor` + wiring +//! asserts +//! stage 2 (--network none): fresh-checkout copy of ONLY the committable +//! files + strictest native install with cold +//! caches → patched bytes prove out +//! stage 3 (offline-safe): idempotent re-vendor / `--revert` / re-vendor +//! +//! So instead of a throwaway container filesystem, every stage runs with the +//! same host tempdir bind-mounted at `/workspace`. The socket-patch binary +//! itself is the one BAKED INTO the image at `/usr/local/bin/socket-patch` +//! by `tests/docker/Dockerfile.base` (optionally shadowed by the +//! coverage-instrumented binary via `cov_docker_args`, same hook as the +//! other docker suites). +//! +//! Each test file pulls this in with +//! `#[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common;`. +//! +//! `#![allow(dead_code)]`: each suite uses a different subset. + +#![allow(dead_code)] + +use std::path::Path; +use std::process::{Command, Output}; + +/// Coverage instrumentation hook — identical contract to +/// `docker_e2e_pypi.rs::cov_docker_args`. The CI coverage-docker job sets +/// `SOCKET_PATCH_COV_BIN` (host path to an llvm-cov-instrumented +/// socket-patch) + `SOCKET_PATCH_COV_PROFRAW_DIR` (host dir for in-container +/// *.profraw output); locally both are unset and this is empty. +pub fn cov_docker_args() -> Vec { + let Ok(bin) = std::env::var("SOCKET_PATCH_COV_BIN") else { + return Vec::new(); + }; + let Ok(dir) = std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR") else { + return Vec::new(); + }; + vec![ + "-v".into(), + format!("{bin}:/usr/local/bin/socket-patch:ro"), + "-v".into(), + format!("{dir}:/coverage"), + "-e".into(), + "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), + ] +} + +/// Returns `true` when the test should skip: `docker` missing from PATH or +/// the per-ecosystem image not built. Prints a skip notice — Rust +/// integration tests have no native "skipped" outcome, so the test then +/// reports `ok`. Build locally with +/// `docker build -f tests/docker/Dockerfile. -t .` +/// (after `Dockerfile.base` → `socket-patch-test-base:latest`). +#[must_use] +pub fn skip_if_no_image(image: &str) -> bool { + let Ok(out) = Command::new("docker") + .args(["image", "inspect", image]) + .output() + else { + eprintln!("skipping: `docker` not on PATH"); + return true; + }; + if !out.status.success() { + eprintln!("skipping: docker image `{image}` not present"); + return true; + } + false +} + +fn docker_run(image: &str, host_dir: &Path, script: &str, extra: &[&str]) -> Output { + let mut cmd = Command::new("docker"); + cmd.args(["run", "--rm", "-i"]); + cmd.args(extra); + cmd.args([ + "-v", + &format!("{}:/workspace", host_dir.display()), + "-w", + "/workspace", + ]); + cmd.args(cov_docker_args()); + cmd.args([image, "bash", "-c", script]); + cmd.output().expect("docker run") +} + +/// Run `script` (bash) inside `image` with `host_dir` bind-mounted at +/// `/workspace` (the working dir). Network is the docker default — use this +/// for fixture-install stages that need the real registry. +pub fn run_in_image(image: &str, host_dir: &Path, script: &str) -> Output { + docker_run(image, host_dir, script, &[]) +} + +/// `run_in_image` + `--network none`: the cold-cache fresh-checkout install +/// stage. Any code path that still wants the registry fails loudly in here. +pub fn run_in_image_network_none(image: &str, host_dir: &Path, script: &str) -> Output { + docker_run(image, host_dir, script, &["--network", "none"]) +} + +/// Anti-vacuity stage gate: the container run must have exited 0 AND echoed +/// every `=== VERIFIED===` marker to stdout. Each marker sits directly +/// behind that stage's in-container asserts, so a script that short-circuits +/// (early `exit 0`, skipped block, copy-pasted tail) cannot pass — markers +/// it never reached are missing. Panics with full stdout+stderr context. +pub fn assert_stage_markers(label: &str, out: &Output, markers: &[&str]) { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "{label}: container exited {:?}\nstdout=\n{stdout}\nstderr=\n{stderr}", + out.status.code() + ); + for m in markers { + let gate = format!("==={m} VERIFIED==="); + assert!( + stdout.contains(&gate), + "{label}: missing stage gate `{gate}`\nstdout=\n{stdout}\nstderr=\n{stderr}" + ); + } +} + +/// Bash prelude shared by every stage script: strict-ish mode (no `-e`; the +/// scripts check exit codes explicitly so failures carry diagnostics), a +/// `fail` helper, and `git_blob_sha ` — the Git-blob SHA-256 +/// (`sha256("blob \0" ++ bytes)`) socket-patch records in manifests, +/// computed entirely in-container so before/after hashes come from the REAL +/// installed bytes. +pub fn bash_prelude() -> &'static str { + r#"set -u +fail() { echo "FAIL: $*" >&2; exit 1; } +git_blob_sha() { + # git blob sha256: sha256("blob \0" + bytes) + local f="$1" + local len + len=$(wc -c < "$f" | tr -d '[:space:]') + { printf 'blob %s\0' "$len"; cat "$f"; } | sha256sum | cut -d' ' -f1 +} +"# +} + +/// Bash snippet defining `stage_patch +/// `: writes `.socket/manifest.json` + the after-hash blob into +/// `.socket/blobs/` (relative to the CURRENT directory — call from the +/// project root) so `socket-patch vendor --offline` runs with zero network. +/// Shape mirrors `e2e_vendor_npm_build.rs::stage_patch`. Requires +/// [`bash_prelude`] (uses `git_blob_sha`). +pub fn stage_patch_fn() -> &'static str { + r#"stage_patch() { + local purl="$1" uuid="$2" file_key="$3" before_file="$4" after_file="$5" + local before_hash after_hash + before_hash=$(git_blob_sha "$before_file") || fail "hashing $before_file" + after_hash=$(git_blob_sha "$after_file") || fail "hashing $after_file" + mkdir -p .socket/blobs || fail "mkdir .socket/blobs" + cp "$after_file" ".socket/blobs/$after_hash" || fail "staging blob" + cat > .socket/manifest.json < ` (grep -F) and +/// `assert_summary ` (word-boundary so `"applied": 1` +/// can't be satisfied by `"applied": 10`). Requires [`bash_prelude`]. +pub fn json_assert_fns() -> &'static str { + r#"assert_json_field() { + grep -qF "$2" "$1" || { echo "---- $1 ----" >&2; cat "$1" >&2; fail "$1 missing [$2]"; } +} +assert_summary() { + grep -qE "\"$2\": $3([^0-9]|\$)" "$1" || { + echo "---- $1 ----" >&2; cat "$1" >&2; fail "$1 does not report summary.$2 == $3"; } +} +"# +} diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index c76ac11..2134a6f 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -129,6 +129,10 @@ fn write_vendor_state(cwd: &Path, purl: &str, rel_path: &str) { took_over_go_patches: false, flavor: None, uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, }, ); let dir = cwd.join(".socket/vendor"); diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 4edc36f..09c4967 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -707,6 +707,10 @@ async fn vendored_golang_purl_skipped_by_apply() { took_over_go_patches: false, flavor: None, uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, }, ); socket_patch_core::patch::vendor::save_state(root, &state) diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml index ce98a61..795b88f 100644 --- a/crates/socket-patch-core/Cargo.toml +++ b/crates/socket-patch-core/Cargo.toml @@ -11,6 +11,7 @@ readme = "README.md" serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } +sha1 = { workspace = true } hex = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } diff --git a/crates/socket-patch-core/src/patch/vendor/cargo.rs b/crates/socket-patch-core/src/patch/vendor/cargo.rs index 839d2e3..42a2608 100644 --- a/crates/socket-patch-core/src/patch/vendor/cargo.rs +++ b/crates/socket-patch-core/src/patch/vendor/cargo.rs @@ -444,6 +444,10 @@ pub async fn vendor_cargo_crate( took_over_go_patches: false, flavor: None, uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, }; done(result, Some(entry), warnings) diff --git a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs index 32fc713..7db2bcf 100644 --- a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs @@ -297,6 +297,10 @@ pub async fn vendor_composer( took_over_go_patches: false, flavor: None, uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, }; VendorOutcome::Done { diff --git a/crates/socket-patch-core/src/patch/vendor/gem.rs b/crates/socket-patch-core/src/patch/vendor/gem.rs index 0eb9a61..d688e88 100644 --- a/crates/socket-patch-core/src/patch/vendor/gem.rs +++ b/crates/socket-patch-core/src/patch/vendor/gem.rs @@ -23,7 +23,15 @@ //! empty the empty `specs:` stanza is KEPT (that is what bundler writes); //! * the DEPENDENCIES entry becomes ` (= )!` — exact pin plus //! the `!` path-source marker; PLATFORMS / BUNDLED WITH / everything else is -//! byte-preserved. +//! byte-preserved; +//! * bundler ≥ 2.6 with `lockfile_checksums` adds a CHECKSUMS section whose +//! registry entries read ` () sha256=`; a path-sourced +//! gem keeps a BARE ` ()` entry (bundler 2.7.2 spike — +//! `spikes/PHASE0-V2-FINDINGS.txt` gemChecksums G2/G3). The registry token +//! MUST be stripped on vendor — bundler never repairs it itself (G4: a stale +//! token is silently preserved, i.e. permanent lock-vs-regen churn) — and +//! restored verbatim on revert: a bare entry on a registry-sourced gem +//! hard-fails `BUNDLE_FROZEN=true bundle install` (exit 16). //! //! The Gemfile gains `path:` on the gem's declaration (rewritten in place when //! it is a statically-parseable single top-level line, quote style preserved) @@ -63,7 +71,7 @@ use super::{RevertOutcome, VendorOutcome, VendorWarning}; const GEMFILE: &str = "Gemfile"; const GEMFILE_LOCK: &str = "Gemfile.lock"; -/// Wiring-record discriminators (`key` is the gem name for both). +/// Wiring-record discriminators (`key` is the gem name for all three). /// /// `gemfile_line`: `original`/`new` are verbatim line/block strings. /// @@ -73,8 +81,14 @@ const GEMFILE_LOCK: &str = "Gemfile.lock"; /// entry — its absence means the gem was transitive and revert deletes the /// added entry. In `new`, the last element is the DEPENDENCIES entry we wrote /// and the rest is the emitted PATH section. +/// +/// `gemfile_lock_checksum`: `original`/`new` are the verbatim CHECKSUMS line +/// strings (the registry ` () sha256=` form vs the bare +/// ` ()` path form). A SEPARATE record — never appended into +/// `gemfile_lock_spec`'s arrays, whose revert parses them positionally. const GEMFILE_WIRING_KIND: &str = "gemfile_line"; const LOCK_WIRING_KIND: &str = "gemfile_lock_spec"; +const LOCK_CHECKSUM_WIRING_KIND: &str = "gemfile_lock_checksum"; /// Managed-block fence for transitive (not-Gemfile-declared) gems. const MANAGED_OPEN: &str = "# >>> socket-patch vendor (managed) >>>"; @@ -227,17 +241,34 @@ pub async fn vendor_gem( // the first run's ledger entry holds the only copy of the pre-vendor // originals. let remote_line = format!(" remote: {copy_rel}"); - if copy_matches_after_hashes(©_dir, &record.files).await + let wired = copy_matches_after_hashes(©_dir, &record.files).await && tokio::fs::metadata(copy_dir.join(format!("{name}.gemspec"))).await.is_ok() && lock_text.split('\n').any(|l| l == remote_line) - && gemfile_text.contains(©_rel) - { - let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); - return VendorOutcome::Done { - result: synthesized_result(purl, ©_dir, verified, true, None), - entry: None, - warnings: Vec::new(), - }; + && gemfile_text.contains(©_rel); + if wired { + if lock_checksum_in_sync(&lock_text, name, version) { + let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + return VendorOutcome::Done { + result: synthesized_result(purl, ©_dir, verified, true, None), + entry: None, + warnings: Vec::new(), + }; + } + // Wired everywhere EXCEPT the lock's CHECKSUMS entry, which still + // carries the registry form — a lock wired by a pre-CHECKSUMS-aware + // socket-patch. Bundler never repairs this itself (spike G4: install, + // frozen install and `bundle lock` all silently preserve a stale + // token), and we cannot strip it here: this run records no ledger + // entry, so a revert would put back everything EXCEPT the token — + // leaving a bare CHECKSUMS entry on a registry-sourced gem, which + // hard-fails frozen installs (exit 16). Refuse with the repair path + // instead of the generic "already carries `path:`" Gemfile refusal. + return refused( + "vendor_stale_lock_checksum", + format!( + "Gemfile.lock already wires `{name}` to {copy_rel} but its CHECKSUMS entry is not bundler's bare path-gem form (an earlier socket-patch left the registry line in place); run `vendor --revert` for {purl} and re-vendor to repair it" + ), + ); } // ── dry run: verify-only against the installed dir, no writes ──────── @@ -398,6 +429,21 @@ pub async fn vendor_gem( original: Some(Value::Array(original_lines)), new: Some(Value::Array(new_lines)), }; + let mut wiring = vec![gemfile_record, lock_record]; + // The CHECKSUMS rewrite (when the lock had a registry entry for the gem) + // rides in its OWN record: revert must restore the registry `sha256=` + // line verbatim — it is not recomputable offline, and a bare entry on a + // registry-sourced gem hard-fails frozen installs (spike, exit 16). + if let Some((orig_line, new_line)) = &lock_edit.checksum_rewrite { + wiring.push(WiringRecord { + file: GEMFILE_LOCK.to_string(), + kind: LOCK_CHECKSUM_WIRING_KIND.to_string(), + action: WiringAction::Rewritten, + key: Some(name.to_string()), + original: Some(Value::String(orig_line.clone())), + new: Some(Value::String(new_line.clone())), + }); + } let entry = VendorEntry { ecosystem: "gem".to_string(), @@ -409,11 +455,15 @@ pub async fn vendor_gem( size: None, platform_locked: None, }, - wiring: vec![gemfile_record, lock_record], + wiring, lock: None, took_over_go_patches: false, flavor: None, uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, }; VendorOutcome::Done { @@ -424,8 +474,9 @@ pub async fn vendor_gem( } /// Revert a gem vendor entry: restore the Gemfile line / delete the managed -/// block, splice the lock's spec block back into GEM specs (sorted) and the -/// original DEPENDENCIES entry back in, then remove the validated uuid dir. +/// block, splice the lock's spec block back into GEM specs (sorted), the +/// original DEPENDENCIES entry back in and the registry CHECKSUMS line back +/// over the bare path form, then remove the validated uuid dir. /// Each fragment that no longer looks like what vendor wrote — a hand edit, a /// `bundle update`, a newer vendor run — is left alone with a /// `vendor_lock_entry_drifted` warning. @@ -447,6 +498,9 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) for w in entry.wiring.iter().rev() { let restored = match w.kind.as_str() { LOCK_WIRING_KIND => revert_lock_record(&project_root.join(GEMFILE_LOCK), w, dry_run).await, + LOCK_CHECKSUM_WIRING_KIND => { + revert_lock_checksum_record(&project_root.join(GEMFILE_LOCK), w, dry_run).await + } GEMFILE_WIRING_KIND => revert_gemfile_record(&project_root.join(GEMFILE), w, dry_run).await, _ => { warnings.push(VendorWarning::new( @@ -642,6 +696,12 @@ struct LockEdit { path_section: Vec, /// The DEPENDENCIES entry we wrote (` (= )!`). new_dep_line: String, + /// CHECKSUMS rewrite `(original line, bare replacement)`; `None` when the + /// lock has no CHECKSUMS section, no entry for the gem, or the entry was + /// already bare (idempotency: our own edit is never recorded as an + /// "original" — reverting it onto a registry-sourced lock would break + /// frozen installs). + checksum_rewrite: Option<(String, String)>, } /// Produce the pair-edited lock text (see the module doc for the canonical @@ -712,12 +772,69 @@ fn edit_lock(text: &str, name: &str, version: &str, rel: &str) -> Result ()` entry — bundler's own re-lock emits + // exactly that form (spike G2), so the registry `sha256=` token must be + // stripped here or the committed lock diverges from any regen forever + // (spike G4: bundler silently preserves a stale token, never repairs it). + // Absent section / absent entry are both tolerated by bundler — touched + // by nothing. Re-found via section_span because the PATH splice above + // shifted every index. + let mut checksum_rewrite: Option<(String, String)> = None; + if let Some((ck_start, ck_end)) = section_span(&lines, "CHECKSUMS") { + let bare = format!(" {name} ({version})"); + let platform_prefix = format!("{version}-"); + let mut plain_at: Option = None; + for (i, line) in lines.iter().enumerate().take(ck_end).skip(ck_start + 1) { + match checksum_entry(line) { + Some((n, v)) if n == name && v == version => { + if plain_at.is_some() { + // SECURITY/fail-closed: duplicate entries mean the + // grammar assumption is wrong for this lock — editing + // one of them would be a guess. + return Err(format!( + "Gemfile.lock CHECKSUMS has more than one entry for `{name} ({version})`" + )); + } + plain_at = Some(i); + } + Some((n, v)) if n == name && v.starts_with(&platform_prefix) => { + // SECURITY/fail-closed: platform-suffixed installs were + // refused (`platform_gem_unsupported`) before this point, + // so a platform sibling here means the lock disagrees + // with the installed tree — never guess which entries + // bundler would collapse for a PATH spec. + return Err(format!( + "Gemfile.lock CHECKSUMS has a platform-suffixed entry `{n} ({v})` but the installed gem is not platform-specific; the lock disagrees with the install (re-resolve it before vendoring)" + )); + } + Some(_) => {} + // SECURITY/fail-closed: a line that names the gem but does + // not fit the entry grammar would be left half-edited or + // skipped silently — both wrong. Err unwinds the Gemfile. + None if checksum_line_names_gem(line, name) => { + return Err(format!( + "Gemfile.lock CHECKSUMS entry for `{name}` is not parseable: {line:?}" + )); + } + None => {} + } + } + if let Some(i) = plain_at { + if lines[i] != bare { + checksum_rewrite = Some((lines[i].clone(), bare.clone())); + lines[i] = bare; + } + } + } + Ok(LockEdit { text: lines.join("\n"), removed_spec_block, old_dep_line, path_section, new_dep_line, + checksum_rewrite, }) } @@ -756,6 +873,65 @@ fn spec_entry_name(line: &str) -> Option<&str> { Some(rest.split(' ').next().unwrap_or(rest)) } +/// Parse a CHECKSUMS entry line: two-space indent, ` ()` or +/// ` (-)`, then optional space-separated tokens +/// (`sha256=` on registry entries, nothing on path entries). Returns +/// `(name, parenthesized token)` — the platform suffix stays inside the token +/// because matching must mirror the GEM specs grammar (spike G5: native gems +/// get one CHECKSUMS line per platform spec, `ffi (1.17.2-aarch64-linux-gnu)`). +fn checksum_entry(line: &str) -> Option<(&str, &str)> { + let rest = line.strip_prefix(" ")?; + if rest.is_empty() || rest.starts_with(' ') { + return None; + } + let open = rest.find(" (")?; + let after = &rest[open + 2..]; + let close = after.find(')')?; + let (name, ver, tail) = (&rest[..open], &after[..close], &after[close + 1..]); + if name.is_empty() || ver.is_empty() || !(tail.is_empty() || tail.starts_with(' ')) { + return None; + } + Some((name, ver)) +} + +/// True when a CHECKSUMS-section line's leading token is `name` — used to +/// fail closed on lines that mention the gem but do not fit the +/// [`checksum_entry`] grammar (editing around them would be a guess). +fn checksum_line_names_gem(line: &str, name: &str) -> bool { + line.strip_prefix(" ") + .filter(|r| !r.starts_with(' ')) + .and_then(|r| r.split([' ', '(']).next()) + == Some(name) +} + +/// True when the lock's CHECKSUMS section is coherent with a path-sourced +/// gem: no section, no entry for the gem, or exactly the bare +/// ` ()` form. A leftover registry `sha256=` token (a lock +/// wired by a pre-CHECKSUMS-aware socket-patch) is NOT in sync — bundler +/// silently preserves it forever (spike G4), so the hot path must not declare +/// such a lock done; only revert + re-vendor can repair it. +fn lock_checksum_in_sync(lock_text: &str, name: &str, version: &str) -> bool { + let lines: Vec = lock_text.split('\n').map(str::to_string).collect(); + let Some((ck_start, ck_end)) = section_span(&lines, "CHECKSUMS") else { + return true; + }; + let bare = format!(" {name} ({version})"); + let platform_prefix = format!("{version}-"); + for line in &lines[ck_start + 1..ck_end] { + match checksum_entry(line) { + Some((n, v)) if n == name && (v == version || v.starts_with(&platform_prefix)) => { + if line.as_str() != bare { + return false; + } + } + Some(_) => {} + None if checksum_line_names_gem(line, name) => return false, + None => {} + } + } + true +} + // ── revert helpers ─────────────────────────────────────────────────────────── /// Restore one `gemfile_line` record. `Ok(true)` = restored (or would be, on @@ -840,6 +1016,48 @@ fn wiring_string_array(v: Option<&Value>) -> Option> { .collect() } +/// Restore one `gemfile_lock_checksum` record: the registry CHECKSUMS line +/// (`sha256=` token and all) goes back over the bare path-form line vendor +/// wrote. Restoring is not optional polish — a bare entry left on a +/// registry-sourced gem hard-fails `BUNDLE_FROZEN=true bundle install` +/// (exit 16) and plain installs rewrite the lock to refill the token (churn); +/// the token is not recomputable offline (spike `bare-checksum-registry-gem` +/// pair). The search is confined to the CHECKSUMS section so a coincidental +/// identical line elsewhere (e.g. a DEPENDENCIES entry) is never clobbered. +/// `Ok(true)` = restored (or would be, on dry run); `Ok(false)` = the line is +/// gone (drift), left alone. +async fn revert_lock_checksum_record( + lock_path: &Path, + w: &WiringRecord, + dry_run: bool, +) -> Result { + let Some(original) = w.original.as_ref().and_then(Value::as_str) else { + return Ok(false); + }; + let Some(written) = w.new.as_ref().and_then(Value::as_str) else { + return Ok(false); + }; + let text = match tokio::fs::read_to_string(lock_path).await { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(format!("unreadable Gemfile.lock: {e}")), + }; + let mut lines: Vec = text.split('\n').map(str::to_string).collect(); + let Some((ck_start, ck_end)) = section_span(&lines, "CHECKSUMS") else { + return Ok(false); + }; + let Some(i) = (ck_start + 1..ck_end).find(|&i| lines[i] == written) else { + return Ok(false); + }; + lines[i] = original.to_string(); + if !dry_run { + atomic_write_bytes(lock_path, lines.join("\n").as_bytes()) + .await + .map_err(|e| format!("failed to write Gemfile.lock: {e}"))?; + } + Ok(true) +} + /// Pure splice reversing [`edit_lock`]: drop the PATH section vendor emitted, /// move the spec block back into GEM/specs at its sorted position, and /// restore (or delete) the DEPENDENCIES entry. All preconditions are checked @@ -1581,4 +1799,444 @@ mod tests { "uuid dir still removed" ); } + + // ── bundler ≥ 2.6 CHECKSUMS (spike: gemChecksums, bundler 2.7.2) ───────── + + const PURL_318: &str = "pkg:gem/rack@3.1.8"; + const PRISTINE_318: &[u8] = b"module Rack\n VERSION = \"3.1.8\"\nend\n"; + const PATCHED_318: &[u8] = b"module Rack\n SOCKET_PATCHED = true\n VERSION = \"3.1.8\"\nend\n"; + const GEMSPEC_318: &str = "Gem::Specification.new do |s|\n s.name = \"rack\"\n s.version = \"3.1.8\"\n s.require_paths = [\"lib\"]\nend\n"; + + // Embedded VERBATIM from the spike pair + // `spikes/gem-checksums/path-with-checksums/{before,after}/` (bundler + // 2.7.2, ruby 3.3.11, aarch64-linux; the `after` lock was written by + // bundler itself via `bundle lock`, never by hand). G3 pinned exactly this + // pair byte-stable under `bundle install`, `BUNDLE_FROZEN=true bundle + // install` and a from-scratch `bundle lock`. + const SPIKE_GEMFILE_CHECKSUMS: &str = + "source \"https://rubygems.org\"\n\ngem \"rack\", \"3.1.8\"\n"; + const SPIKE_RACK_SHA_LINE: &str = + " rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1"; + const SPIKE_LOCK_CHECKSUMS_BEFORE: &str = "GEM\n remote: https://rubygems.org/\n specs:\n rack (3.1.8)\n\nPLATFORMS\n aarch64-linux\n ruby\n\nDEPENDENCIES\n rack (= 3.1.8)\n\nCHECKSUMS\n rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1\n\nBUNDLED WITH\n 2.7.2\n"; + const SPIKE_LOCK_CHECKSUMS_AFTER: &str = "PATH\n remote: vendored/rack-3.1.8\n specs:\n rack (3.1.8)\n\nGEM\n remote: https://rubygems.org/\n specs:\n\nPLATFORMS\n aarch64-linux\n ruby\n\nDEPENDENCIES\n rack (= 3.1.8)!\n\nCHECKSUMS\n rack (3.1.8)\n\nBUNDLED WITH\n 2.7.2\n"; + + fn copy_rel_318() -> String { + format!(".socket/vendor/gem/{UUID}/rack-3.1.8") + } + + /// The spike `after` lock byte-for-byte, except the PATH remote points + /// into `.socket/vendor/` instead of the spike's hand-placed `vendored/` + /// dir — the only divergence; everything else (including the bare + /// CHECKSUMS entry) must match bundler's own output exactly for the lock + /// to stay byte-stable under re-lock. + fn expected_lock_checksums() -> String { + SPIKE_LOCK_CHECKSUMS_AFTER.replace( + " remote: vendored/rack-3.1.8\n", + &format!(" remote: {}\n", copy_rel_318()), + ) + } + + /// rack-3.1.8 twin of [`fixture`] (the CHECKSUMS spike pinned that exact + /// version, so the oracles can embed the spike locks verbatim). + async fn fixture_318( + gemfile: &str, + lock: &str, + ) -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf, PatchRecord) { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let installed = base.join("gem_home/gems/rack-3.1.8"); + tokio::fs::create_dir_all(installed.join("lib")).await.unwrap(); + tokio::fs::write(installed.join("lib/rack.rb"), PRISTINE_318).await.unwrap(); + let specs = base.join("gem_home/specifications"); + tokio::fs::create_dir_all(&specs).await.unwrap(); + tokio::fs::write(specs.join("rack-3.1.8.gemspec"), GEMSPEC_318).await.unwrap(); + + let root = base.join("project"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(root.join(GEMFILE), gemfile).await.unwrap(); + tokio::fs::write(root.join(GEMFILE_LOCK), lock).await.unwrap(); + + let before = compute_git_sha256_from_bytes(PRISTINE_318); + let after = compute_git_sha256_from_bytes(PATCHED_318); + let blobs = base.join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + tokio::fs::write(blobs.join(&after), PATCHED_318).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "lib/rack.rb".to_string(), + PatchFileInfo { + before_hash: before, + after_hash: after, + }, + ); + let record = PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-06-09T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }; + (dir, root, installed, blobs, record) + } + + async fn run_vendor_318( + root: &Path, + blobs: &Path, + installed: &Path, + record: &PatchRecord, + dry_run: bool, + ) -> VendorOutcome { + let sources = PatchSources::blobs_only(blobs); + vendor_gem( + PURL_318, + installed, + root, + record, + &sources, + "2026-06-09T00:00:00Z", + dry_run, + false, + ) + .await + } + + #[tokio::test] + async fn test_checksums_direct_vendor_matches_spike_pair() { + let (_tmp, root, installed, blobs, record) = + fixture_318(SPIKE_GEMFILE_CHECKSUMS, SPIKE_LOCK_CHECKSUMS_BEFORE).await; + + let (result, entry, _w) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + + // Lock: bundler's own path-gem output (spike G3 pair) byte-for-byte, + // modulo the PATH remote value. + let lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + assert_eq!(lock, expected_lock_checksums()); + + // Ledger: the checksum rewrite is its own third record with the + // verbatim registry line as original and the bare form as new. + let entry = entry.expect("success must carry a ledger entry"); + assert_eq!(entry.wiring.len(), 3); + let ck = &entry.wiring[2]; + assert_eq!(ck.file, GEMFILE_LOCK); + assert_eq!(ck.kind, LOCK_CHECKSUM_WIRING_KIND); + assert_eq!(ck.action, WiringAction::Rewritten); + assert_eq!(ck.key.as_deref(), Some("rack")); + assert_eq!( + ck.original.as_ref().unwrap(), + &Value::String(SPIKE_RACK_SHA_LINE.to_string()) + ); + assert_eq!( + ck.new.as_ref().unwrap(), + &Value::String(" rack (3.1.8)".to_string()) + ); + // The positional gemfile_lock_spec record must NOT have absorbed the + // checksum line (its revert parses original/new by position). + let spec = &entry.wiring[1]; + assert!( + !spec + .original + .as_ref() + .unwrap() + .as_array() + .unwrap() + .iter() + .any(|l| l.as_str().unwrap().contains("sha256=")), + "checksum line must not leak into gemfile_lock_spec: {:?}", + spec.original + ); + } + + #[tokio::test] + async fn test_checksums_transitive_vendor_strips_only_our_token() { + let gemfile = "source \"https://rubygems.org\"\n\ngem \"puma\"\n"; + let puma_sha_line = + " puma (6.4.2) sha256=9c4f1f9d8f7c3a1b5e2d6c8a0b4f7e1d3c5a9b8e7f6d4c2a1b3e5d7c9f8a6b4c"; + let lock = format!( + "GEM\n remote: https://rubygems.org/\n specs:\n puma (6.4.2)\n nio4r (~> 2.0)\n rack (3.1.8)\n\nPLATFORMS\n aarch64-linux\n ruby\n\nDEPENDENCIES\n puma\n\nCHECKSUMS\n{puma_sha_line}\n{SPIKE_RACK_SHA_LINE}\n\nBUNDLED WITH\n 2.7.2\n" + ); + let (_tmp, root, installed, blobs, record) = fixture_318(gemfile, &lock).await; + + let (result, entry, _w) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + + // Full oracle: rack moved to PATH + sorted `!` dep + bare CHECKSUMS + // entry; puma's checksum line is byte-untouched. + let new_lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + assert_eq!( + new_lock, + format!( + "PATH\n remote: {rel}\n specs:\n rack (3.1.8)\n\nGEM\n remote: https://rubygems.org/\n specs:\n puma (6.4.2)\n nio4r (~> 2.0)\n\nPLATFORMS\n aarch64-linux\n ruby\n\nDEPENDENCIES\n puma\n rack (= 3.1.8)!\n\nCHECKSUMS\n{puma_sha_line}\n rack (3.1.8)\n\nBUNDLED WITH\n 2.7.2\n", + rel = copy_rel_318() + ) + ); + + // Revert restores both files byte-exactly (added dep deleted, managed + // block removed, registry checksum line back). + let entry = entry.unwrap(); + assert_eq!(entry.wiring.len(), 3); + let outcome = revert_gem(&entry, &root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + !outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + "clean revert must not report drift: {:?}", + outcome.warnings + ); + assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), gemfile); + assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), lock); + } + + #[tokio::test] + async fn test_checksums_revert_round_trip() { + let (_tmp, root, installed, blobs, record) = + fixture_318(SPIKE_GEMFILE_CHECKSUMS, SPIKE_LOCK_CHECKSUMS_BEFORE).await; + + let (result, entry, _w) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(result.success); + let entry = entry.unwrap(); + + let outcome = revert_gem(&entry, &root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + !outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + "clean revert must not report drift: {:?}", + outcome.warnings + ); + // Byte-exact restore — the registry sha256 token is back (a bare + // CHECKSUMS entry on a registry gem fails frozen installs, exit 16). + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + SPIKE_GEMFILE_CHECKSUMS + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + SPIKE_LOCK_CHECKSUMS_BEFORE + ); + assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists()); + } + + #[tokio::test] + async fn test_checksums_idempotent_rerun_in_sync() { + let (_tmp, root, installed, blobs, record) = + fixture_318(SPIKE_GEMFILE_CHECKSUMS, SPIKE_LOCK_CHECKSUMS_BEFORE).await; + + let (r1, e1, _) = unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(r1.success); + assert!(e1.is_some()); + let gemfile1 = tokio::fs::read(root.join(GEMFILE)).await.unwrap(); + let lock1 = tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(); + + // The bare CHECKSUMS entry counts as in-sync: the rerun takes the hot + // path and records nothing. + let (r2, e2, _) = unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(r2.success); + assert!(e2.is_none(), "hot path must not re-record"); + assert_eq!(tokio::fs::read(root.join(GEMFILE)).await.unwrap(), gemfile1); + assert_eq!(tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(), lock1); + } + + #[tokio::test] + async fn test_checksums_already_bare_records_nothing() { + // Spike `bare-checksum-registry-gem/before`: a registry-sourced lock + // whose CHECKSUMS entry is already the bare form. Vendor must not + // record our own target form as an "original" — reverting it later + // would NOT be a restore (and per the spike a bare entry is exactly + // what the path form needs anyway). + let lock = SPIKE_LOCK_CHECKSUMS_BEFORE.replace(SPIKE_RACK_SHA_LINE, " rack (3.1.8)"); + let (_tmp, root, installed, blobs, record) = + fixture_318(SPIKE_GEMFILE_CHECKSUMS, &lock).await; + + let (result, entry, _w) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + assert_eq!( + entry.wiring.len(), + 2, + "already-bare entry must not produce a checksum record: {:?}", + entry.wiring + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + expected_lock_checksums(), + "the bare line is kept verbatim" + ); + } + + #[tokio::test] + async fn test_checksums_absent_entry_untouched() { + // CHECKSUMS section present but no entry for our gem: bundler + // tolerates absent entries, so vendor touches nothing there. + let other_line = + " puma (6.4.2) sha256=9c4f1f9d8f7c3a1b5e2d6c8a0b4f7e1d3c5a9b8e7f6d4c2a1b3e5d7c9f8a6b4c"; + let lock = SPIKE_LOCK_CHECKSUMS_BEFORE.replace(SPIKE_RACK_SHA_LINE, other_line); + let (_tmp, root, installed, blobs, record) = + fixture_318(SPIKE_GEMFILE_CHECKSUMS, &lock).await; + + let (result, entry, _w) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + assert_eq!(entry.unwrap().wiring.len(), 2, "no checksum record for an absent entry"); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + expected_lock_checksums().replace(" rack (3.1.8)\n\nBUNDLED", &format!("{other_line}\n\nBUNDLED")), + "the foreign entry is byte-untouched" + ); + } + + #[tokio::test] + async fn test_checksums_unparseable_entry_unwinds() { + // A CHECKSUMS line that names our gem but breaks the entry grammar + // (lost closing paren) fails closed AFTER the Gemfile was rewritten: + // the pair-edit unwind must restore the Gemfile bytes. + let lock = SPIKE_LOCK_CHECKSUMS_BEFORE.replace(SPIKE_RACK_SHA_LINE, " rack (3.1.8 sha256=deadbeef"); + let (_tmp, root, installed, blobs, record) = + fixture_318(SPIKE_GEMFILE_CHECKSUMS, &lock).await; + + let (result, entry, _w) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(!result.success); + let err = result.error.as_deref().unwrap_or(""); + assert!(err.contains("CHECKSUMS") && err.contains("not parseable"), "{err}"); + assert!(entry.is_none()); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + SPIKE_GEMFILE_CHECKSUMS, + "Gemfile unwound to its original bytes" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + lock, + "lock untouched" + ); + assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists()); + } + + #[tokio::test] + async fn test_checksums_platform_sibling_fails_closed() { + // vendor_gem refuses platform-suffixed INSTALL dirs before the lock + // edit, so a platform-suffixed CHECKSUMS sibling means the lock + // disagrees with the installed tree — never guess which entries + // bundler would collapse; fail closed and unwind. + let lock = SPIKE_LOCK_CHECKSUMS_BEFORE.replace( + SPIKE_RACK_SHA_LINE, + &format!("{SPIKE_RACK_SHA_LINE}\n rack (3.1.8-aarch64-linux) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1"), + ); + let (_tmp, root, installed, blobs, record) = + fixture_318(SPIKE_GEMFILE_CHECKSUMS, &lock).await; + + let (result, entry, _w) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(!result.success); + assert!( + result.error.as_deref().unwrap_or("").contains("platform-suffixed"), + "{:?}", + result.error + ); + assert!(entry.is_none()); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + SPIKE_GEMFILE_CHECKSUMS, + "Gemfile unwound" + ); + assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), lock); + assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists()); + } + + #[test] + fn test_checksums_duplicate_entries_fail_closed() { + let lock = SPIKE_LOCK_CHECKSUMS_BEFORE.replace( + SPIKE_RACK_SHA_LINE, + &format!("{SPIKE_RACK_SHA_LINE}\n{SPIKE_RACK_SHA_LINE}"), + ); + let err = match edit_lock(&lock, "rack", "3.1.8", ©_rel_318()) { + Err(e) => e, + Ok(_) => panic!("duplicate CHECKSUMS entries must fail closed"), + }; + assert!(err.contains("more than one entry"), "{err}"); + } + + #[test] + fn test_no_checksums_lock_records_no_checksum_wiring() { + // Regression: a lock WITHOUT a CHECKSUMS section must keep producing + // the exact pre-CHECKSUMS output and no checksum record. + let edit = edit_lock(LOCK_DIRECT, "rack", "3.2.6", ©_rel()).unwrap(); + assert!(edit.checksum_rewrite.is_none()); + assert_eq!(edit.text, expected_lock_direct()); + } + + #[tokio::test] + async fn test_checksums_revert_drift_warning() { + let (_tmp, root, installed, blobs, record) = + fixture_318(SPIKE_GEMFILE_CHECKSUMS, SPIKE_LOCK_CHECKSUMS_BEFORE).await; + + let (result, entry, _w) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(result.success); + let entry = entry.unwrap(); + + // Third-party drift on ONLY the checksum line (someone hand-restored + // a token): revert must leave that line alone with a warning, never + // clobber it, while the other records still restore cleanly. + let drifted_line = " rack (3.1.8) sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let wired = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + let edited = wired.replace("\nCHECKSUMS\n rack (3.1.8)\n", &format!("\nCHECKSUMS\n{drifted_line}\n")); + assert_ne!(edited, wired, "fixture edit must hit the bare line"); + tokio::fs::write(root.join(GEMFILE_LOCK), &edited).await.unwrap(); + + let outcome = revert_gem(&entry, &root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + let drift_count = outcome + .warnings + .iter() + .filter(|w| w.code == "vendor_lock_entry_drifted") + .count(); + assert_eq!(drift_count, 1, "exactly the checksum record drifts: {:?}", outcome.warnings); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + SPIKE_LOCK_CHECKSUMS_BEFORE.replace(SPIKE_RACK_SHA_LINE, drifted_line), + "everything else restored; the drifted checksum line preserved verbatim" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + SPIKE_GEMFILE_CHECKSUMS + ); + } + + #[tokio::test] + async fn test_stale_checksum_rerun_refused_with_guidance() { + // A lock wired by a pre-CHECKSUMS-aware socket-patch: PATH wiring in + // place but the registry sha256 token still on the CHECKSUMS line + // (the spike's stale-checksum-v1-bug shape — bundler itself never + // repairs it). The rerun must NOT report in-sync, and must refuse + // with the revert+re-vendor repair path rather than silently editing + // a lock it has no ledger entry for. + let (_tmp, root, installed, blobs, record) = + fixture_318(SPIKE_GEMFILE_CHECKSUMS, SPIKE_LOCK_CHECKSUMS_BEFORE).await; + let (r1, _e1, _) = unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(r1.success); + let wired = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + let v1 = wired.replace( + "\nCHECKSUMS\n rack (3.1.8)\n", + &format!("\nCHECKSUMS\n{SPIKE_RACK_SHA_LINE}\n"), + ); + assert_ne!(v1, wired, "fixture edit must hit the bare line"); + tokio::fs::write(root.join(GEMFILE_LOCK), &v1).await.unwrap(); + let gemfile = tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(); + + let (code, detail) = + unwrap_refused(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert_eq!(code, "vendor_stale_lock_checksum"); + assert!(detail.contains("vendor --revert"), "{detail}"); + // The refusal mutates nothing. + assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), gemfile); + assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), v1); + } } diff --git a/crates/socket-patch-core/src/patch/vendor/golang.rs b/crates/socket-patch-core/src/patch/vendor/golang.rs index d7d8bdd..6279a0c 100644 --- a/crates/socket-patch-core/src/patch/vendor/golang.rs +++ b/crates/socket-patch-core/src/patch/vendor/golang.rs @@ -233,6 +233,10 @@ pub async fn vendor_go_module( took_over_go_patches: takeover, flavor: None, uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, }; VendorOutcome::Done { diff --git a/crates/socket-patch-core/src/patch/vendor/mod.rs b/crates/socket-patch-core/src/patch/vendor/mod.rs index db1f988..256253c 100644 --- a/crates/socket-patch-core/src/patch/vendor/mod.rs +++ b/crates/socket-patch-core/src/patch/vendor/mod.rs @@ -20,6 +20,11 @@ //! | gem | gem dir (+gemspec) | Gemfile `path:` + Gemfile.lock PATH pair | //! | pypi | rebuilt wheel | uv: pyproject+uv.lock pair; pip: requirements | //! +//! npm requests route through [`npm_flavor`], which content-sniffs the +//! project's lockfile (package-lock / yarn / pnpm / bun) and dispatches to +//! the matching backend — today only the package-lock backend exists and +//! the other flavors refuse with stable reason codes. +//! //! ## Ownership & reversal //! //! `.socket/vendor/state.json` (committed) records the verbatim original @@ -44,12 +49,15 @@ pub mod composer_lock; pub mod gem; #[cfg(feature = "golang")] pub mod golang; +mod npm_common; +pub mod npm_flavor; pub mod npm_lock; pub mod npm_pack; pub mod pypi; pub mod pypi_requirements; pub mod pypi_uv; pub mod pypi_wheel; +mod toml_surgery; pub mod verify; pub use path::{ecosystem_dir_for_purl, parse_vendor_path, VendorPathParts, VENDOR_DIR}; diff --git a/crates/socket-patch-core/src/patch/vendor/npm_common.rs b/crates/socket-patch-core/src/patch/vendor/npm_common.rs new file mode 100644 index 0000000..098bd93 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/npm_common.rs @@ -0,0 +1,419 @@ +//! Flavor-agnostic npm vendoring pipeline: coordinate guards plus the shared +//! stage→patch→pack steps. +//! +//! Every npm lockfile flavor (package-lock today; yarn-classic/pnpm/bun +//! backends to come) vendors the same way up to the wiring: validate the +//! coordinates fail-closed, stage a private copy of the installed package in +//! a tempdir OUTSIDE the project, prune nested `node_modules`, refuse +//! bundled-deps packages, run the hardened apply pipeline against the stage, +//! and pack the result into a deterministic tarball under +//! `.socket/vendor/npm//`. Only the lockfile wiring differs per flavor, +//! and it always runs LAST — so a refusal or failure in this pipeline leaves +//! the project byte-untouched (a dry run stops after verification and +//! creates nothing on disk). + +use std::collections::HashMap; +use std::path::Path; + +use serde_json::Value; + +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{apply_package_patch, normalize_file_path, ApplyResult, PatchSources}; +use crate::patch::copy_tree::{fresh_copy, remove_tree}; +use crate::patch::path_safety; +use crate::utils::purl::strip_purl_qualifiers; + +use super::npm_pack::{pack_deterministic, PackedTarball}; +use super::path::vendor_uuid_dir_rel; +use super::VendorOutcome; + +/// Validated npm vendoring coordinates (the output of +/// [`guard_coordinates`]). `name`/`version` borrow from the purl. +#[derive(Debug)] +pub(super) struct NpmCoords<'a> { + pub name: &'a str, + pub version: &'a str, + /// `.socket/vendor/npm/` (validated, forward slashes). + pub uuid_dir_rel: String, + /// Qualifier-free base PURL. + pub base_purl: String, +} + +/// Parse + validate the coordinates every npm flavor keys its artifact path +/// (and lockfile strings) on. +/// +/// SECURITY: name/version/uuid come from a committed, tamper-able manifest +/// and key the artifact path under `.socket/vendor/npm/` plus the spec +/// string written into the lockfile. A `..` segment, separator, or +/// non-canonical uuid would escape the vendor dir (arbitrary write on +/// vendor, arbitrary delete on revert) — reject fail-closed before any disk +/// access. `Err` carries a ready [`VendorOutcome::Refused`] to bubble +/// verbatim. +pub(super) fn guard_coordinates<'a>( + purl: &'a str, + record: &PatchRecord, +) -> Result, Box> { + let Some((name, version)) = parse_npm_purl(purl) else { + return Err(Box::new(refused( + "unsafe_coordinates", + format!("cannot parse an npm name@version out of `{purl}`"), + ))); + }; + if !is_safe_npm_name(name) || !path_safety::is_safe_single_segment(version) { + return Err(Box::new(refused( + "unsafe_coordinates", + format!( + "refusing to vendor `{name}@{version}`: a `..` segment, absolute path, or \ + separator would escape .socket/vendor/npm/" + ), + ))); + } + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &record.uuid) else { + return Err(Box::new(refused( + "unsafe_coordinates", + format!( + "refusing to vendor with non-canonical patch uuid `{}`", + record.uuid + ), + ))); + }; + Ok(NpmCoords { + name, + version, + uuid_dir_rel, + base_purl: strip_purl_qualifiers(purl).to_string(), + }) +} + +/// The shared pipeline's product: a verified, deterministically packed +/// tarball plus the facts the flavor wiring needs. +pub(super) struct NpmStagedPack { + pub name: String, + pub version: String, + /// `.socket/vendor/npm//` (forward slashes). + pub rel_tgz: String, + pub packed: PackedTarball, + /// `Some` iff the patch rewrote the package's own `package.json` (the + /// lockfile's dependency-mirror fields are then stale and the flavor + /// wiring must recompute them from this parsed manifest). + pub staged_pkg_json: Option, +} + +/// Stage → patch → pack one installed npm package. +/// +/// Runs [`guard_coordinates`] first (pure and cheap — callers that already +/// guarded simply re-validate), stages a fresh copy of `installed_dir` in a +/// tempdir outside the project, prunes nested `node_modules`, refuses +/// bundled-deps packages, applies the patch via the hardened apply pipeline, +/// and packs the deterministic tarball into the uuid dir. +/// +/// Result shape (mirrors how `npm_lock::vendor_npm` splits its phases): +/// +/// * `Err(outcome)` — a refusal (`Refused`) or a hard pipeline failure +/// (`Done` with a failed synthesized [`ApplyResult`]); bubble verbatim. +/// Nothing inside the project was written. +/// * `Ok((None, result))` — the patch step finished without packing: either +/// `!result.success` (verify/patch failure; the caller wraps it with its +/// accumulated warnings) or a successful dry run (stops after +/// verification — no pack, no dirs created). +/// * `Ok((Some(staged), result))` — full success: the tarball is on disk at +/// `staged.rel_tgz` and the caller proceeds to its lockfile wiring. +pub(super) async fn stage_patch_pack( + purl: &str, + installed_dir: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + dry_run: bool, + force: bool, +) -> Result<(Option, ApplyResult), Box> { + let coords = guard_coordinates(purl, record)?; + + // ── Stage + patch a private copy ──────────────────────────────────── + // The stage lives in a tempdir OUTSIDE the project: nothing inside the + // project is written until the patched tarball verifies. + let stage_tmp = match tempfile::tempdir() { + Ok(t) => t, + Err(e) => { + return Err(Box::new(done_failure( + purl, + format!("cannot create staging tempdir: {e}"), + ))) + } + }; + let stage = stage_tmp.path().join("stage"); + if let Err(e) = fresh_copy(installed_dir, &stage, None).await { + return Err(Box::new(done_failure( + purl, + format!("cannot stage a copy of the installed package: {e}"), + ))); + } + // The tarball must carry ONLY the package's own files: a nested + // node_modules (hoisting leftovers, file:-dep installs) would balloon + // the artifact and shadow the lock's own resolution. + if let Err(e) = remove_tree(&stage.join("node_modules")).await { + return Err(Box::new(done_failure( + purl, + format!("cannot prune staged node_modules: {e}"), + ))); + } + // Bundled dependencies ship INSIDE the package tarball; since we just + // dropped nested node_modules, repacking would produce a tarball npm + // cannot satisfy those deps from. Refuse before patching. + if let Ok(bytes) = tokio::fs::read(stage.join("package.json")).await { + if let Ok(pkg) = serde_json::from_slice::(&bytes) { + if declares_bundled_deps(&pkg) { + return Err(Box::new(refused( + "vendor_bundled_deps_unsupported", + format!( + "{}@{} declares bundleDependencies; vendoring would repack \ + the tarball without its bundled node_modules and break installs", + coords.name, coords.version + ), + ))); + } + } + } + + // Delegate to the hardened apply pipeline, pointed at the stage (which + // plays the role of the installed package dir — manifest npm keys carry + // the `package/` prefix and `apply` strips it via `normalize_file_path`, + // exactly as it does for an in-place npm apply). + let result = apply_package_patch( + purl, + &stage, + &record.files, + sources, + Some(&record.uuid), + dry_run, + force, + ) + .await; + // A failed patch never packs (wiring is last — the caller returns with + // the project byte-untouched); a dry run stops after the verify. + if !result.success || dry_run { + return Ok((None, result)); + } + + // ── Pack the deterministic tarball ────────────────────────────────── + let rel_tgz = format!( + "{}/{}", + coords.uuid_dir_rel, + tgz_rel_leaf(coords.name, coords.version) + ); + let dest = project_root.join(&rel_tgz); + if let Some(parent) = dest.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + return Err(Box::new(done_failure( + purl, + format!("cannot create {}: {e}", parent.display()), + ))); + } + } + let packed = match pack_deterministic(&stage, &dest).await { + Ok(p) => p, + Err(e) => { + return Err(Box::new(done_failure( + purl, + format!("cannot pack the vendored tarball: {e}"), + ))) + } + }; + + // ── Patched package.json ⇒ the lock's dependency mirror is stale ──── + let staged_pkg_json = if record + .files + .keys() + .any(|k| normalize_file_path(k) == "package.json") + { + match read_staged_package_json(&stage).await { + Ok(pkg) => Some(pkg), + Err(e) => return Err(Box::new(done_failure(purl, e))), + } + } else { + None + }; + + Ok(( + Some(NpmStagedPack { + name: coords.name.to_string(), + version: coords.version.to_string(), + rel_tgz, + packed, + staged_pkg_json, + }), + result, + )) +} + +// ───────────────────────────── small helpers ───────────────────────────── + +/// `pkg:npm/[@scope/]name@version` → `(name, version)`; scoped names keep +/// the `@scope/` prefix. The LAST `@` separates the version (a leading +/// scope-`@` is at index 0 and never the last `@` of a versioned purl). +pub(super) fn parse_npm_purl(purl: &str) -> Option<(&str, &str)> { + let base = strip_purl_qualifiers(purl); + let rest = base.strip_prefix("pkg:npm/")?; + let at = rest.rfind('@').filter(|&i| i > 0)?; + let (name, version) = (&rest[..at], &rest[at + 1..]); + if name.is_empty() || version.is_empty() { + return None; + } + Some((name, version)) +} + +/// npm-name shape on top of the generic traversal guard: at most one `/`, +/// and only with an `@scope` first segment (so a smuggled `a/b/c` can't +/// create surprise directory levels under the uuid dir). +pub(super) fn is_safe_npm_name(name: &str) -> bool { + if !path_safety::is_safe_multi_segment(name) { + return false; + } + match name.split_once('/') { + None => !name.starts_with('@'), + Some((scope, bare)) => scope.starts_with('@') && !bare.contains('/'), + } +} + +/// The artifact path under the uuid dir: `[@scope/]-.tgz`, +/// with the scope kept as a real subdirectory. +pub(super) fn tgz_rel_leaf(name: &str, version: &str) -> String { + match name.split_once('/') { + Some((scope, bare)) => format!("{scope}/{bare}-{version}.tgz"), + None => format!("{name}-{version}.tgz"), + } +} + +/// `bundleDependencies` (npm) / `bundledDependencies` (legacy alias): +/// `true` means "all deps", an array names them; either makes the package +/// unvendorable (see the refusal site). +fn declares_bundled_deps(pkg: &Value) -> bool { + ["bundleDependencies", "bundledDependencies"].iter().any(|k| match pkg.get(*k) { + Some(Value::Bool(b)) => *b, + Some(Value::Array(a)) => !a.is_empty(), + _ => false, + }) +} + +async fn read_staged_package_json(stage: &Path) -> Result { + let bytes = tokio::fs::read(stage.join("package.json")) + .await + .map_err(|e| format!("patched package.json unreadable in the stage: {e}"))?; + serde_json::from_slice(&bytes) + .map_err(|e| format!("patched package.json is not parseable JSON: {e}")) +} + +pub(super) fn refused(code: &'static str, detail: String) -> VendorOutcome { + VendorOutcome::Refused { code, detail } +} + +/// A backend failure after the refusal phase: `Done` with a failed +/// synthesized [`ApplyResult`], mirroring `go_redirect`'s synthesized +/// results. +pub(super) fn done_failure(purl: &str, error: String) -> VendorOutcome { + VendorOutcome::Done { + result: ApplyResult { + package_key: purl.to_string(), + package_path: String::new(), + success: false, + files_verified: Vec::new(), + files_patched: Vec::new(), + applied_via: HashMap::new(), + error: Some(error), + sidecar: None, + }, + entry: None, + warnings: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::schema::PatchFileInfo; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + fn record_with_uuid(uuid: &str) -> PatchRecord { + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: "a".repeat(64), + after_hash: "b".repeat(64), + }, + ); + PatchRecord { + uuid: uuid.to_string(), + exported_at: String::new(), + files, + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + } + } + + fn expect_refusal(err: Box, want_code: &str) { + match *err { + VendorOutcome::Refused { code, detail } => { + assert_eq!(code, want_code, "{detail}"); + } + other => panic!("expected Refused {want_code}, got {other:?}"), + } + } + + #[test] + fn guard_coordinates_accepts_plain_and_scoped_names() { + let record = record_with_uuid(UUID); + let coords = guard_coordinates("pkg:npm/left-pad@1.3.0", &record).unwrap(); + assert_eq!((coords.name, coords.version), ("left-pad", "1.3.0")); + assert_eq!(coords.uuid_dir_rel, format!(".socket/vendor/npm/{UUID}")); + assert_eq!(coords.base_purl, "pkg:npm/left-pad@1.3.0"); + + let coords = + guard_coordinates("pkg:npm/@scope/pkg@1.0.0?artifact_id=x", &record).unwrap(); + assert_eq!((coords.name, coords.version), ("@scope/pkg", "1.0.0")); + assert_eq!(coords.base_purl, "pkg:npm/@scope/pkg@1.0.0", "qualifiers stripped"); + } + + #[test] + fn guard_coordinates_refuses_fail_closed() { + let record = record_with_uuid(UUID); + // Unparseable purl. + expect_refusal( + guard_coordinates("pkg:pypi/six@1.16.0", &record).unwrap_err(), + "unsafe_coordinates", + ); + // Traversal name. + expect_refusal( + guard_coordinates("pkg:npm/../escape@1.0.0", &record).unwrap_err(), + "unsafe_coordinates", + ); + // Traversal version. + expect_refusal( + guard_coordinates("pkg:npm/x@../1.0.0", &record).unwrap_err(), + "unsafe_coordinates", + ); + // Tampered uuid. + let record = record_with_uuid("../../x"); + expect_refusal( + guard_coordinates("pkg:npm/left-pad@1.3.0", &record).unwrap_err(), + "unsafe_coordinates", + ); + } + + #[tokio::test] + async fn done_failure_shape_matches_contract() { + let outcome = done_failure("pkg:npm/x@1.0.0", "boom".to_string()); + let VendorOutcome::Done { result, entry, warnings } = outcome else { + panic!("done_failure must be Done"); + }; + assert!(!result.success); + assert_eq!(result.package_key, "pkg:npm/x@1.0.0"); + assert_eq!(result.error.as_deref(), Some("boom")); + assert!(result.files_verified.is_empty() && result.files_patched.is_empty()); + assert!(entry.is_none()); + assert!(warnings.is_empty()); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs b/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs new file mode 100644 index 0000000..a497cde --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs @@ -0,0 +1,657 @@ +//! Vendor-side npm lockfile flavor probe + router. +//! +//! `vendor` rewires whichever lockfile actually drives the project's +//! installs, so the probe sniffs lockfile CONTENT (not just file presence): +//! a `pnpm-lock.yaml` only routes to the pnpm backend when its +//! `lockfileVersion` is one we have fixtures for, and a `yarn.lock` is only +//! "yarn classic" when it carries the v1 header (a berry lock — top-level +//! `__metadata:` — checksums installs against its cache zips even under the +//! node-modules linker, so vendoring is structurally impossible there). +//! +//! The router fans `vendor`/`revert` out per detected flavor. Today only the +//! package-lock backend ([`super::npm_lock`]) exists; the yarn-classic / +//! pnpm / bun arms refuse with the same stable code the CLI's old layout +//! gate used (`vendor_pkg_manager_unsupported`) and will be replaced by real +//! backends. Reverts fail CLOSED on a flavor this build has no backend for — +//! never guess at another flavor's wiring records. + +use std::path::Path; + +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::PatchSources; + +use super::npm_lock; +use super::state::VendorEntry; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +/// Which lockfile flavor drives this project's npm installs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NpmLockFlavor { + /// `package-lock.json` / `npm-shrinkwrap.json` (npm). + PackageLock, + /// `yarn.lock` with the `# yarn lockfile v1` header (yarn classic). + YarnClassic, + /// `pnpm-lock.yaml`, lockfileVersion 9.0 (pnpm >= 9). + Pnpm, + /// `bun.lock` (bun's text lockfile). + Bun, +} + +impl NpmLockFlavor { + /// The stable string recorded as [`VendorEntry::flavor`]. + pub fn as_str(self) -> &'static str { + match self { + NpmLockFlavor::PackageLock => "package-lock", + NpmLockFlavor::YarnClassic => "yarn-classic", + NpmLockFlavor::Pnpm => "pnpm", + NpmLockFlavor::Bun => "bun", + } + } +} + +/// Yarn berry Plug'n'Play loaders: packages live inside `.yarn/cache/` zips, +/// so there is nothing on disk to stage and no lockfile entry to rewire. +const PNP_MARKERS: [&str; 3] = [".pnp.cjs", ".pnp.js", ".pnp.loader.mjs"]; + +/// The pnpm lockfile version the (future) pnpm backend is built against. +const PNPM_SUPPORTED_LOCK_VERSION: &str = "9.0"; + +/// How many head lines the content sniffs read. The markers sit at the very +/// top of their files (pnpm's `lockfileVersion` is line 1; yarn's v1 header +/// is in the leading comment block; berry's `__metadata:` is the first +/// top-level key after it). +const SNIFF_HEAD_LINES: usize = 5; +const YARN_SNIFF_HEAD_LINES: usize = 30; + +/// Every lockfile name the probe knows, grouped into wiring families: the +/// flavor that owns a family wires (or supersedes) every file in it, so only +/// files OUTSIDE the detected family get the multiple-lockfiles warning. +const LOCKFILE_FAMILIES: [(NpmLockFlavor, &[&str]); 4] = [ + // npm itself ignores package-lock.json when npm-shrinkwrap.json exists, + // so the npm family never warns about its own sibling. + (NpmLockFlavor::PackageLock, &["npm-shrinkwrap.json", "package-lock.json"]), + (NpmLockFlavor::YarnClassic, &["yarn.lock"]), + (NpmLockFlavor::Pnpm, &["pnpm-lock.yaml"]), + // bun reads bun.lock when both exist (lockb is the migrated-away binary). + (NpmLockFlavor::Bun, &["bun.lock", "bun.lockb"]), +]; + +/// Probe the project root for the lockfile flavor that drives npm installs. +/// +/// Decision table, first match wins: +/// 1. a PnP loader file → Err `vendor_yarn_berry_unsupported`; +/// 2. `bun.lock` → Bun; else `bun.lockb` → Err `vendor_bun_lockb_unsupported`; +/// 3. `pnpm-lock.yaml` → head-sniff `lockfileVersion` (only `'9.0'`) → Pnpm, +/// else Err `vendor_lockfile_version_unsupported`; +/// 4. `yarn.lock` → head-sniff: column-0 `__metadata:` → Err +/// `vendor_yarn_berry_unsupported`; `# yarn lockfile v1` → YarnClassic; +/// neither → Err `vendor_lockfile_version_unsupported`; +/// 5. `npm-shrinkwrap.json` | `package-lock.json` → PackageLock; +/// 6. nothing → Err `vendor_lockfile_missing`. +/// +/// `Ok` carries one `vendor_multiple_lockfiles` warning per OTHER known +/// lockfile present (outside the detected flavor's family): installs driven +/// by an unwired lockfile would still install the unpatched registry bytes. +pub async fn detect_npm_lock_flavor( + project_root: &Path, +) -> Result<(NpmLockFlavor, Vec), (&'static str, String)> { + let exists = |name: &str| { + let p = project_root.join(name); + async move { tokio::fs::metadata(&p).await.is_ok() } + }; + + // 1. Yarn berry PnP — checked first because it means packages are not on + // disk at all, whatever lockfiles are also lying around. + for marker in PNP_MARKERS { + if exists(marker).await { + return Err(( + "vendor_yarn_berry_unsupported", + format!( + "found `{marker}`: this is a yarn berry Plug'n'Play project — packages \ + live inside .yarn/cache/ zips, not node_modules/, so there is nothing \ + vendor could stage or rewire; use `yarn patch ` instead" + ), + )); + } + } + + let detected = 'flavor: { + // 2. bun: the text lockfile is wirable; the legacy binary one is not. + if exists("bun.lock").await { + break 'flavor NpmLockFlavor::Bun; + } + if exists("bun.lockb").await { + return Err(( + "vendor_bun_lockb_unsupported", + "bun.lockb is bun's legacy binary lockfile, which vendor cannot rewrite; \ + run `bun install --save-text-lockfile`, commit the resulting bun.lock, \ + and re-run vendor" + .to_string(), + )); + } + + // 3. pnpm: only lockfileVersion 9.0 has a wiring backend. + if exists("pnpm-lock.yaml").await { + sniff_pnpm_lock(project_root).await?; + break 'flavor NpmLockFlavor::Pnpm; + } + + // 4. yarn: classic v1 vs berry, decided by content. + if exists("yarn.lock").await { + sniff_yarn_lock(project_root).await?; + break 'flavor NpmLockFlavor::YarnClassic; + } + + // 5. npm (npm_lock itself prefers the shrinkwrap when both exist). + if exists("npm-shrinkwrap.json").await || exists("package-lock.json").await { + break 'flavor NpmLockFlavor::PackageLock; + } + + // 6. nothing recognizable. + return Err(( + "vendor_lockfile_missing", + format!( + "no package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, or \ + bun.lock at {} — vendoring rewires the lockfile, so one must exist (run \ + your package manager's install first)", + project_root.display() + ), + )); + }; + + // Multiple lockfiles: warn about every present file the detected + // flavor's wiring does not cover. + let mut warnings = Vec::new(); + for (flavor, family) in LOCKFILE_FAMILIES { + if flavor == detected { + continue; + } + for file in family { + if exists(file).await { + warnings.push(VendorWarning::new( + "vendor_multiple_lockfiles", + format!( + "multiple lockfiles present: `{file}` is not wired by the {} vendor \ + backend — installs driven by `{file}` will still install the \ + UNPATCHED registry bytes", + detected.as_str() + ), + )); + } + } + } + Ok((detected, warnings)) +} + +/// `pnpm-lock.yaml` head sniff: the first lines carry +/// `lockfileVersion: '9.0'` (pnpm quotes it; accept double-quoted and bare +/// spellings too). Anything else has no wiring backend. +async fn sniff_pnpm_lock(project_root: &Path) -> Result<(), (&'static str, String)> { + let text = tokio::fs::read_to_string(project_root.join("pnpm-lock.yaml")) + .await + .map_err(|e| { + ( + "vendor_lockfile_missing", + format!("cannot read pnpm-lock.yaml: {e}"), + ) + })?; + let version = text + .lines() + .take(SNIFF_HEAD_LINES) + .find_map(|line| line.strip_prefix("lockfileVersion:")) + .map(|rest| rest.trim().trim_matches(['\'', '"']).to_string()); + match version { + Some(v) if v == PNPM_SUPPORTED_LOCK_VERSION => Ok(()), + Some(v) => Err(( + "vendor_lockfile_version_unsupported", + format!( + "pnpm-lock.yaml has lockfileVersion {v}; only {PNPM_SUPPORTED_LOCK_VERSION} \ + is supported — re-lock with pnpm >= 9" + ), + )), + None => Err(( + "vendor_lockfile_version_unsupported", + format!( + "pnpm-lock.yaml has no lockfileVersion in its first {SNIFF_HEAD_LINES} \ + lines; only {PNPM_SUPPORTED_LOCK_VERSION} is supported — re-lock with \ + pnpm >= 9" + ), + )), + } +} + +/// `yarn.lock` head sniff: berry locks carry a top-level (column-0) +/// `__metadata:` key; classic v1 locks carry the `# yarn lockfile v1` +/// comment header. Berry wins the check — a berry lock must never be +/// mistaken for classic. +async fn sniff_yarn_lock(project_root: &Path) -> Result<(), (&'static str, String)> { + let text = tokio::fs::read_to_string(project_root.join("yarn.lock")) + .await + .map_err(|e| { + ( + "vendor_lockfile_missing", + format!("cannot read yarn.lock: {e}"), + ) + })?; + let head: Vec<&str> = text.lines().take(YARN_SNIFF_HEAD_LINES).collect(); + if head.iter().any(|l| l.starts_with("__metadata:")) { + return Err(( + "vendor_yarn_berry_unsupported", + "yarn.lock is a yarn berry (v2+) lockfile (top-level `__metadata:` key); even \ + with the node-modules linker, berry verifies installs against its cache zips' \ + checksums, so a rewired tarball would fail validation — use `yarn patch ` \ + instead" + .to_string(), + )); + } + if head.iter().any(|l| l.trim() == "# yarn lockfile v1") { + return Ok(()); + } + Err(( + "vendor_lockfile_version_unsupported", + "yarn.lock carries neither the `# yarn lockfile v1` header nor a berry \ + `__metadata:` key; cannot identify the lockfile version" + .to_string(), + )) +} + +/// Vendor one npm package through whichever flavor backend serves this +/// project. Probe refusals surface verbatim; flavors without a backend yet +/// refuse with the manager's native patch flow (behavior-equivalent to the +/// CLI's former layout gate). +#[allow(clippy::too_many_arguments)] +pub async fn vendor_npm_any( + purl: &str, + installed_dir: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, +) -> VendorOutcome { + let (flavor, probe_warnings) = match detect_npm_lock_flavor(project_root).await { + Ok(found) => found, + Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + }; + match flavor { + NpmLockFlavor::PackageLock => { + let mut outcome = npm_lock::vendor_npm( + purl, + installed_dir, + project_root, + record, + sources, + vendored_at, + dry_run, + force, + ) + .await; + if let VendorOutcome::Done { entry, warnings, .. } = &mut outcome { + // Probe warnings precede the backend's own (the probe ran + // first); the ledger records which flavor wired the entry so + // revert can route — and fail closed on builds without the + // matching backend. + let mut merged = probe_warnings; + merged.append(warnings); + *warnings = merged; + if let Some(entry) = entry { + entry.flavor = Some(flavor.as_str().to_string()); + } + } + outcome + } + NpmLockFlavor::YarnClassic | NpmLockFlavor::Pnpm | NpmLockFlavor::Bun => { + // No wiring backend yet: refuse pointing at the manager's native + // patch flow. These arms are the seams the yarn-classic / pnpm / + // bun backends will replace. + let native = match flavor { + NpmLockFlavor::YarnClassic => "yarn patch ", + NpmLockFlavor::Pnpm => "pnpm patch ", + NpmLockFlavor::Bun => "bun patch ", + NpmLockFlavor::PackageLock => unreachable!("handled above"), + }; + VendorOutcome::Refused { + code: "vendor_pkg_manager_unsupported", + detail: format!( + "this project's installs are driven by a {} lockfile; socket-patch \ + vendor only rewrites package-lock.json — use `{native}` instead", + flavor.as_str() + ), + } + } + } +} + +/// Revert one recorded npm vendor entry through the flavor that wired it. +/// Entries from before the flavor field existed (`None`) are package-lock +/// wirings; an unknown flavor fails CLOSED (an older binary must not guess +/// at a newer backend's wiring records). +pub async fn revert_npm_any( + entry: &VendorEntry, + project_root: &Path, + dry_run: bool, +) -> RevertOutcome { + match entry.flavor.as_deref() { + None | Some("package-lock") => npm_lock::revert_npm(entry, project_root, dry_run).await, + Some(other) => RevertOutcome::failed(format!( + "this socket-patch build cannot revert npm vendor flavor `{other}` — upgrade \ + socket-patch and re-run" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; + use crate::patch::vendor::state::VendorArtifact; + use std::collections::HashMap; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + async fn touch(root: &Path, name: &str, content: &str) { + tokio::fs::write(root.join(name), content).await.unwrap(); + } + + async fn detect(root: &Path) -> Result<(NpmLockFlavor, Vec), (&'static str, String)> { + detect_npm_lock_flavor(root).await + } + + const YARN_V1: &str = "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ + # yarn lockfile v1\n\n\nleft-pad@^1.3.0:\n version \"1.3.0\"\n"; + const YARN_BERRY: &str = "# This file is generated by running \"yarn install\" inside your project.\n\ + # Manifest files (package.json) are also used.\n\n\ + __metadata:\n version: 8\n cacheKey: 10\n"; + const PNPM_9: &str = "lockfileVersion: '9.0'\n\nsettings:\n autoInstallPeers: true\n"; + + #[test] + fn flavor_strings_are_stable() { + assert_eq!(NpmLockFlavor::PackageLock.as_str(), "package-lock"); + assert_eq!(NpmLockFlavor::YarnClassic.as_str(), "yarn-classic"); + assert_eq!(NpmLockFlavor::Pnpm.as_str(), "pnpm"); + assert_eq!(NpmLockFlavor::Bun.as_str(), "bun"); + } + + #[tokio::test] + async fn pnp_loaders_refuse_before_any_lockfile() { + for marker in PNP_MARKERS { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), marker, "/* pnp */").await; + // Even with a perfectly good package-lock present. + touch(tmp.path(), "package-lock.json", "{}").await; + let (code, detail) = detect(tmp.path()).await.unwrap_err(); + assert_eq!(code, "vendor_yarn_berry_unsupported", "{marker}"); + assert!(detail.contains(marker), "{detail}"); + assert!(detail.contains("yarn patch"), "{detail}"); + } + } + + #[tokio::test] + async fn bun_lock_routes_and_lockb_refuses() { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "bun.lock", "{\n \"lockfileVersion\": 1\n}\n").await; + let (flavor, warnings) = detect(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::Bun); + assert!(warnings.is_empty()); + + // bun.lock wins over a stray bun.lockb (no warning for the sibling). + touch(tmp.path(), "bun.lockb", "binary").await; + let (flavor, warnings) = detect(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::Bun); + assert!(warnings.is_empty(), "{warnings:?}"); + + // lockb alone: actionable migration pointer. + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "bun.lockb", "binary").await; + let (code, detail) = detect(tmp.path()).await.unwrap_err(); + assert_eq!(code, "vendor_bun_lockb_unsupported"); + assert!(detail.contains("bun install --save-text-lockfile"), "{detail}"); + } + + #[tokio::test] + async fn pnpm_version_sniff() { + // Quoted (pnpm's own spelling), double-quoted, and bare all accept. + for head in ["lockfileVersion: '9.0'", "lockfileVersion: \"9.0\"", "lockfileVersion: 9.0"] { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "pnpm-lock.yaml", &format!("{head}\n\nsettings: {{}}\n")).await; + let (flavor, _) = detect(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm, "{head}"); + } + + // Older version: named in the error. + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '6.0'\n").await; + let (code, detail) = detect(tmp.path()).await.unwrap_err(); + assert_eq!(code, "vendor_lockfile_version_unsupported"); + assert!(detail.contains("6.0"), "{detail}"); + assert!(detail.contains("pnpm >= 9"), "{detail}"); + + // No version line in the head at all. + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "pnpm-lock.yaml", "settings:\n autoInstallPeers: true\n").await; + let (code, _) = detect(tmp.path()).await.unwrap_err(); + assert_eq!(code, "vendor_lockfile_version_unsupported"); + } + + #[tokio::test] + async fn yarn_sniff_separates_classic_berry_and_unknown() { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "yarn.lock", YARN_V1).await; + let (flavor, _) = detect(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnClassic); + + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "yarn.lock", YARN_BERRY).await; + let (code, detail) = detect(tmp.path()).await.unwrap_err(); + assert_eq!(code, "vendor_yarn_berry_unsupported"); + assert!(detail.contains("yarn patch"), "{detail}"); + assert!(detail.contains("checksum"), "must explain the cache-zip checksum problem: {detail}"); + + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "yarn.lock", "garbage: true\n").await; + let (code, _) = detect(tmp.path()).await.unwrap_err(); + assert_eq!(code, "vendor_lockfile_version_unsupported"); + } + + #[tokio::test] + async fn npm_locks_route_to_package_lock_and_nothing_is_missing() { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "package-lock.json", "{}").await; + assert_eq!(detect(tmp.path()).await.unwrap().0, NpmLockFlavor::PackageLock); + + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "npm-shrinkwrap.json", "{}").await; + let (flavor, warnings) = detect(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::PackageLock); + assert!(warnings.is_empty()); + + // Shrinkwrap + package-lock are the same family: no self-warning. + touch(tmp.path(), "package-lock.json", "{}").await; + let (_, warnings) = detect(tmp.path()).await.unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + + let tmp = tempfile::tempdir().unwrap(); + let (code, _) = detect(tmp.path()).await.unwrap_err(); + assert_eq!(code, "vendor_lockfile_missing"); + } + + #[tokio::test] + async fn precedence_and_multiple_lockfile_warnings() { + // bun.lock beats pnpm beats yarn beats package-lock; every unwired + // lockfile gets its own loud warning. + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "bun.lock", "{}").await; + touch(tmp.path(), "pnpm-lock.yaml", PNPM_9).await; + touch(tmp.path(), "yarn.lock", YARN_V1).await; + touch(tmp.path(), "package-lock.json", "{}").await; + let (flavor, warnings) = detect(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::Bun); + let named: Vec<&str> = warnings.iter().map(|w| w.detail.as_str()).collect(); + assert_eq!(warnings.len(), 3, "{named:?}"); + assert!(warnings.iter().all(|w| w.code == "vendor_multiple_lockfiles")); + for file in ["pnpm-lock.yaml", "yarn.lock", "package-lock.json"] { + assert!( + warnings.iter().any(|w| w.detail.contains(file) && w.detail.contains("UNPATCHED")), + "missing loud warning for {file}: {named:?}" + ); + } + + // yarn.lock outranks package-lock.json (yarn classic projects often + // carry an npm-generated stray): yarn classic wins, npm lock warned. + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "package-lock.json", "{}").await; + touch(tmp.path(), "yarn.lock", YARN_V1).await; + let (flavor, warnings) = detect(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnClassic); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].detail.contains("package-lock.json"), "{warnings:?}"); + } + + /// Build a vendorable npm project (installed package, v3 package-lock, + /// patched blob + record) and return `(tempdir, record)`. + async fn npm_project() -> (tempfile::TempDir, crate::manifest::schema::PatchRecord) { + const ORIG: &[u8] = b"module.exports = () => 'orig';\n"; + const PATCHED: &[u8] = b"module.exports = () => 'patched';\n"; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pkg = root.join("node_modules/left-pad"); + tokio::fs::create_dir_all(&pkg).await.unwrap(); + touch(&pkg, "package.json", r#"{"name":"left-pad","version":"1.3.0"}"#).await; + tokio::fs::write(pkg.join("index.js"), ORIG).await.unwrap(); + touch( + root, + "package-lock.json", + &serde_json::to_string_pretty(&serde_json::json!({ + "name": "fixture", "version": "1.0.0", "lockfileVersion": 3, + "packages": { + "": { "name": "fixture", "version": "1.0.0" }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-orig==" + } + } + })) + .unwrap(), + ) + .await; + let blobs = root.join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + let after_hash = compute_git_sha256_from_bytes(PATCHED); + tokio::fs::write(blobs.join(&after_hash), PATCHED).await.unwrap(); + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(ORIG), + after_hash, + }, + ); + let record = crate::manifest::schema::PatchRecord { + uuid: UUID.to_string(), + exported_at: String::new(), + files, + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }; + (tmp, record) + } + + async fn vendor_any(root: &Path, record: &crate::manifest::schema::PatchRecord) -> VendorOutcome { + let blobs = root.join(".socket/blobs"); + let sources = crate::patch::apply::PatchSources::blobs_only(&blobs); + vendor_npm_any( + "pkg:npm/left-pad@1.3.0", + &root.join("node_modules/left-pad"), + root, + record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + ) + .await + } + + /// The PackageLock arm: the router runs the npm_lock backend and stamps + /// the ledger entry's flavor. (Every OTHER known lockfile outranks + /// package-lock in the decision table, so the PackageLock arm can never + /// carry probe warnings today — the merge matters once the yarn/pnpm/bun + /// arms become real backends.) + #[tokio::test] + async fn package_lock_arm_stamps_flavor_on_the_ledger_entry() { + let (tmp, record) = npm_project().await; + + let outcome = vendor_any(tmp.path(), &record).await; + let VendorOutcome::Done { result, entry, warnings } = outcome else { + panic!("expected Done, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + assert!(warnings.is_empty(), "{warnings:?}"); + let entry = entry.expect("success carries a ledger entry"); + assert_eq!(entry.flavor.as_deref(), Some("package-lock")); + // The lock really was wired (the backend ran). + let lock = tokio::fs::read_to_string(tmp.path().join("package-lock.json")).await.unwrap(); + assert!(lock.contains(&format!("file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"))); + } + + /// The unwired-flavor arms refuse with the old CLI gate's stable code, + /// naming the manager's native patch flow, and write nothing. + #[tokio::test] + async fn unwired_flavor_arm_refuses_with_native_pointer() { + let (tmp, record) = npm_project().await; + tokio::fs::remove_file(tmp.path().join("package-lock.json")).await.unwrap(); + touch(tmp.path(), "yarn.lock", YARN_V1).await; + + let outcome = vendor_any(tmp.path(), &record).await; + let VendorOutcome::Refused { code, detail } = outcome else { + panic!("expected Refused, got {outcome:?}"); + }; + assert_eq!(code, "vendor_pkg_manager_unsupported"); + assert!(detail.contains("yarn-classic"), "{detail}"); + assert!(detail.contains("yarn patch "), "{detail}"); + assert!(!tmp.path().join(".socket/vendor").exists(), "refusal writes nothing"); + } + + #[tokio::test] + async fn revert_routes_by_flavor_and_fails_closed_on_unknown() { + let tmp = tempfile::tempdir().unwrap(); + let mut entry = VendorEntry { + ecosystem: "npm".into(), + base_purl: "pkg:npm/left-pad@1.3.0".into(), + uuid: UUID.into(), + artifact: VendorArtifact { + path: format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"), + sha256: String::new(), + size: None, + platform_locked: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + flavor: Some("yarn-classic".into()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + }; + + // Unknown-to-this-build flavor: fail closed, name the flavor. + let outcome = revert_npm_any(&entry, tmp.path(), false).await; + assert!(!outcome.success); + assert!(outcome.error.as_deref().unwrap().contains("yarn-classic")); + + // None and "package-lock" both route to npm_lock::revert_npm (which + // succeeds trivially here: no wiring records, nothing on disk). + for flavor in [None, Some("package-lock".to_string())] { + entry.flavor = flavor; + let outcome = revert_npm_any(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + } + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/npm_lock.rs b/crates/socket-patch-core/src/patch/vendor/npm_lock.rs index 0198fee..0c1dcc9 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_lock.rs @@ -20,22 +20,22 @@ use std::path::Path; use serde_json::Value; use crate::manifest::schema::PatchRecord; -use crate::patch::apply::{ - apply_package_patch, normalize_file_path, ApplyResult, PatchSources, VerifyResult, - VerifyStatus, -}; -use crate::patch::copy_tree::{fresh_copy, remove_tree}; -use crate::patch::path_safety; +use crate::patch::apply::{ApplyResult, PatchSources, VerifyResult, VerifyStatus}; +use crate::patch::copy_tree::remove_tree; use crate::utils::fs::atomic_write_bytes; -use crate::utils::purl::strip_purl_qualifiers; -use super::npm_pack::pack_deterministic; +use super::npm_common::{done_failure, guard_coordinates, refused, stage_patch_pack}; use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::{RevertOutcome, VendorOutcome, VendorWarning}; +// Test-only re-imports: the helpers moved to `npm_common` but the existing +// suite exercises them through `use super::*` and stays unmodified. +#[cfg(test)] +use super::npm_common::{is_safe_npm_name, parse_npm_purl, tgz_rel_leaf}; + /// `npm-shrinkwrap.json` wins over `package-lock.json` when both exist — /// npm itself ignores the package-lock in that case, so editing it would be /// a silent no-op. @@ -85,38 +85,15 @@ pub async fn vendor_npm( ) -> VendorOutcome { let mut warnings: Vec = Vec::new(); - // ── 1. Coordinates ────────────────────────────────────────────────── - // SECURITY: name/version/uuid come from a committed, tamper-able - // manifest and key the artifact path under `.socket/vendor/npm/` plus - // the `file:` string written into the lock. A `..` segment, separator, - // or non-canonical uuid would escape the vendor dir (arbitrary write on - // vendor, arbitrary delete on revert) — reject fail-closed before any - // disk access. - let Some((name, version)) = parse_npm_purl(purl) else { - return refused( - "unsafe_coordinates", - format!("cannot parse an npm name@version out of `{purl}`"), - ); - }; - if !is_safe_npm_name(name) || !path_safety::is_safe_single_segment(version) { - return refused( - "unsafe_coordinates", - format!( - "refusing to vendor `{name}@{version}`: a `..` segment, absolute path, or \ - separator would escape .socket/vendor/npm/" - ), - ); - } - let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &record.uuid) else { - return refused( - "unsafe_coordinates", - format!( - "refusing to vendor with non-canonical patch uuid `{}`", - record.uuid - ), - ); + // ── 1. Coordinates (shared guard: fail-closed before any disk access, + // see `npm_common::guard_coordinates` for the security note) ──── + let coords = match guard_coordinates(purl, record) { + Ok(coords) => coords, + Err(outcome) => return *outcome, }; - let base_purl = strip_purl_qualifiers(purl).to_string(); + let (name, version) = (coords.name, coords.version); + let uuid_dir_rel = coords.uuid_dir_rel; + let base_purl = coords.base_purl; // ── 2. Lockfile selection ─────────────────────────────────────────── let (lock_name, lock_bytes) = match select_lockfile(project_root).await { @@ -187,96 +164,40 @@ pub async fn vendor_npm( ); } - // ── 4. Stage + patch a private copy ───────────────────────────────── - // The stage lives in a tempdir OUTSIDE the project: nothing inside the - // project is written until the patched tarball verifies. - let stage_tmp = match tempfile::tempdir() { - Ok(t) => t, - Err(e) => return done_failure(purl, format!("cannot create staging tempdir: {e}")), - }; - let stage = stage_tmp.path().join("stage"); - if let Err(e) = fresh_copy(installed_dir, &stage, None).await { - return done_failure(purl, format!("cannot stage a copy of the installed package: {e}")); - } - // The tarball must carry ONLY the package's own files: a nested - // node_modules (hoisting leftovers, file:-dep installs) would balloon - // the artifact and shadow the lock's own resolution. - if let Err(e) = remove_tree(&stage.join("node_modules")).await { - return done_failure(purl, format!("cannot prune staged node_modules: {e}")); - } - // Bundled dependencies ship INSIDE the package tarball; since we just - // dropped nested node_modules, repacking would produce a tarball npm - // cannot satisfy those deps from. Refuse before patching. - if let Ok(bytes) = tokio::fs::read(stage.join("package.json")).await { - if let Ok(pkg) = serde_json::from_slice::(&bytes) { - if declares_bundled_deps(&pkg) { - return refused( - "vendor_bundled_deps_unsupported", - format!( - "{name}@{version} declares bundleDependencies; vendoring would repack \ - the tarball without its bundled node_modules and break installs" - ), - ); - } - } - } - - // Delegate to the hardened apply pipeline, pointed at the stage (which - // plays the role of the installed package dir — manifest npm keys carry - // the `package/` prefix and `apply` strips it via `normalize_file_path`, - // exactly as it does for an in-place npm apply). - let result = apply_package_patch( + // ── 4–7. Stage → patch → pack (shared flavor-agnostic pipeline: + // tempdir stage outside the project, nested node_modules prune, + // bundled-deps refusal, hardened apply, deterministic pack) ──── + let (staged, result) = match stage_patch_pack( purl, - &stage, - &record.files, + installed_dir, + project_root, + record, sources, - Some(&record.uuid), dry_run, force, ) - .await; - if !result.success { - // No lock writes — wiring is last, so a failed patch leaves the - // project byte-untouched. - return VendorOutcome::Done { result, entry: None, warnings }; - } - - // ── 5. Dry run stops after the verify ─────────────────────────────── - if dry_run { + .await + { + Ok(pair) => pair, + Err(outcome) => return *outcome, + }; + let Some(staged) = staged else { + // Failed patch (no lock writes — wiring is last, so the project is + // byte-untouched) or a dry run (stops after the verify). return VendorOutcome::Done { result, entry: None, warnings }; - } - - // ── 6. Pack the deterministic tarball ─────────────────────────────── - let rel_tgz = format!("{uuid_dir_rel}/{}", tgz_rel_leaf(name, version)); - let dest = project_root.join(&rel_tgz); - if let Some(parent) = dest.parent() { - if let Err(e) = tokio::fs::create_dir_all(parent).await { - return done_failure(purl, format!("cannot create {}: {e}", parent.display())); - } - } - let packed = match pack_deterministic(&stage, &dest).await { - Ok(p) => p, - Err(e) => return done_failure(purl, format!("cannot pack the vendored tarball: {e}")), }; + // `staged.name`/`staged.version` echo the validated coords (the wiring + // below keeps using the borrowed `name`/`version`). + debug_assert_eq!((staged.name.as_str(), staged.version.as_str()), (name, version)); + let rel_tgz = staged.rel_tgz; + let packed = staged.packed; + let staged_pkg_json = staged.staged_pkg_json; + let dest = project_root.join(&rel_tgz); // Forward slashes by construction (uuid_dir_rel + leaf are built with // `/`), relative to the project dir — the spelling npm resolves // `file:` specs against. let resolved = format!("file:{rel_tgz}"); - // ── 7. Patched package.json ⇒ the lock's dependency mirror is stale ─ - let staged_pkg_json = if record - .files - .keys() - .any(|k| normalize_file_path(k) == "package.json") - { - match read_staged_package_json(&stage).await { - Ok(pkg) => Some(pkg), - Err(e) => return done_failure(purl, e), - } - } else { - None - }; - // ── 8. Lock rewrite (in-place Value mutation: untouched keys stay // byte-stable thanks to serde_json's preserve_order) ──────────── let mut wiring: Vec = Vec::new(); @@ -402,6 +323,10 @@ pub async fn vendor_npm( took_over_go_patches: false, flavor: None, uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, }; VendorOutcome::Done { result, entry: Some(entry), warnings } } @@ -759,42 +684,7 @@ fn revert_one_record( } // ───────────────────────────── small helpers ───────────────────────────── - -/// `pkg:npm/[@scope/]name@version` → `(name, version)`; scoped names keep -/// the `@scope/` prefix. The LAST `@` separates the version (a leading -/// scope-`@` is at index 0 and never the last `@` of a versioned purl). -fn parse_npm_purl(purl: &str) -> Option<(&str, &str)> { - let base = strip_purl_qualifiers(purl); - let rest = base.strip_prefix("pkg:npm/")?; - let at = rest.rfind('@').filter(|&i| i > 0)?; - let (name, version) = (&rest[..at], &rest[at + 1..]); - if name.is_empty() || version.is_empty() { - return None; - } - Some((name, version)) -} - -/// npm-name shape on top of the generic traversal guard: at most one `/`, -/// and only with an `@scope` first segment (so a smuggled `a/b/c` can't -/// create surprise directory levels under the uuid dir). -fn is_safe_npm_name(name: &str) -> bool { - if !path_safety::is_safe_multi_segment(name) { - return false; - } - match name.split_once('/') { - None => !name.starts_with('@'), - Some((scope, bare)) => scope.starts_with('@') && !bare.contains('/'), - } -} - -/// The artifact path under the uuid dir: `[@scope/]-.tgz`, -/// with the scope kept as a real subdirectory. -fn tgz_rel_leaf(name: &str, version: &str) -> String { - match name.split_once('/') { - Some((scope, bare)) => format!("{scope}/{bare}-{version}.tgz"), - None => format!("{name}-{version}.tgz"), - } -} +// (the flavor-agnostic coordinate/staging helpers live in `npm_common`) async fn select_lockfile(project_root: &Path) -> std::io::Result)>> { for lock_name in [SHRINKWRAP, PACKAGE_LOCK] { @@ -807,25 +697,6 @@ async fn select_lockfile(project_root: &Path) -> std::io::Result bool { - ["bundleDependencies", "bundledDependencies"].iter().any(|k| match pkg.get(*k) { - Some(Value::Bool(b)) => *b, - Some(Value::Array(a)) => !a.is_empty(), - _ => false, - }) -} - -async fn read_staged_package_json(stage: &Path) -> Result { - let bytes = tokio::fs::read(stage.join("package.json")) - .await - .map_err(|e| format!("patched package.json unreadable in the stage: {e}"))?; - serde_json::from_slice(&bytes) - .map_err(|e| format!("patched package.json is not parseable JSON: {e}")) -} - /// The lock's indent unit: the leading whitespace of the first indented /// line (npm emits 2 spaces; respect whatever formatter the project uses /// so untouched lines stay byte-identical in diffs). Defaults to 2 spaces. @@ -852,20 +723,6 @@ fn serialize_lock(lock: &Value, indent: &str) -> std::io::Result> { Ok(out) } -fn refused(code: &'static str, detail: String) -> VendorOutcome { - VendorOutcome::Refused { code, detail } -} - -/// A backend failure after the refusal phase: `Done` with a failed -/// [`ApplyResult`], mirroring `go_redirect`'s synthesized results. -fn done_failure(purl: &str, error: String) -> VendorOutcome { - VendorOutcome::Done { - result: synthesized_result(purl, Path::new(""), Vec::new(), false, Some(error)), - entry: None, - warnings: Vec::new(), - } -} - fn synthesized_result( package_key: &str, path: &Path, @@ -1605,6 +1462,10 @@ mod tests { took_over_go_patches: false, flavor: None, uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, }; let outcome = revert_npm(&entry, fx.root(), false).await; assert!(!outcome.success, "tampered uuid must fail closed"); diff --git a/crates/socket-patch-core/src/patch/vendor/npm_pack.rs b/crates/socket-patch-core/src/patch/vendor/npm_pack.rs index 746945e..5a3c322 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_pack.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_pack.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use base64::Engine as _; +use sha1::Sha1; use sha2::{Digest, Sha256, Sha512}; use crate::utils::fs::atomic_write_bytes; @@ -30,6 +31,9 @@ pub struct PackedTarball { pub integrity: String, /// Plain sha256 hex of the tgz bytes (the vendor ledger's artifact hash). pub sha256_hex: String, + /// Plain sha1 hex of the tgz bytes (the checksum field yarn-classic and + /// other legacy lockfile flavors record for tarballs). + pub sha1_hex: String, /// Byte size of the tgz. pub size: u64, } @@ -58,6 +62,7 @@ pub async fn pack_deterministic(staged_dir: &Path, dest: &Path) -> std::io::Resu Ok(PackedTarball { integrity, sha256_hex: hex::encode(Sha256::digest(&bytes)), + sha1_hex: hex::encode(Sha1::digest(&bytes)), size: bytes.len() as u64, }) } @@ -221,11 +226,19 @@ mod tests { let bytes2 = tokio::fs::read(&dest2).await.unwrap(); assert_eq!(bytes1, bytes2, "two packs of the same tree must be byte-identical"); assert_eq!(packed1.sha256_hex, packed2.sha256_hex); + assert_eq!(packed1.sha1_hex, packed2.sha1_hex, "sha1 stable across packs"); assert_eq!(packed1.integrity, packed2.integrity); // The reported facts describe the final on-disk bytes. assert_eq!(packed1.size, bytes1.len() as u64); assert_eq!(packed1.sha256_hex, hex::encode(Sha256::digest(&bytes1))); + assert_eq!(packed1.sha1_hex, hex::encode(Sha1::digest(&bytes1))); + assert_eq!(packed1.sha1_hex.len(), 40, "sha1 hex is 40 chars"); + assert!( + packed1.sha1_hex.bytes().all(|b| b.is_ascii_hexdigit()), + "sha1 hex must be hex digits only: {}", + packed1.sha1_hex + ); let expected_integrity = format!( "sha512-{}", base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&bytes1)) diff --git a/crates/socket-patch-core/src/patch/vendor/pypi.rs b/crates/socket-patch-core/src/patch/vendor/pypi.rs index 5f511d2..c7275c7 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi.rs @@ -28,6 +28,12 @@ use super::{RevertOutcome, VendorOutcome, VendorWarning}; pub enum PypiFlavor { /// `uv.lock`-managed project → paired pyproject + lock surgery. UvProject, + /// `poetry.lock`-managed project → lock-only `[[package]]` splice. + Poetry, + /// `pdm.lock`-managed project → lock-only `[[package]]` splice. + Pdm, + /// `Pipfile.lock`-managed project → lock-only JSON entry rewrite. + Pipenv, /// Plain `requirements.txt` (pip / `uv pip`) → line rewriting. Requirements, } @@ -36,6 +42,9 @@ impl PypiFlavor { fn as_str(self) -> &'static str { match self { PypiFlavor::UvProject => "uv", + PypiFlavor::Poetry => "poetry", + PypiFlavor::Pdm => "pdm", + PypiFlavor::Pipenv => "pipenv", PypiFlavor::Requirements => "requirements", } } @@ -45,24 +54,69 @@ const SETUP_ALTERNATIVE: &str = "use the `socket-patch setup` .pth install hook instead, which patches installed \ site-packages without lockfile edits"; -/// Route the project to a wiring flavor, first match wins: -/// 1. `uv.lock` at the root → uv; -/// 2. `[tool.uv]` without a lock (and no requirements.txt fallback) → -/// refuse, asking for `uv lock`; -/// 3. Pipenv / Poetry / PDM markers → refuse (no spike-verified wiring); -/// 4. `requirements.txt` → requirements; -/// 5. a lone pyproject → refuse (no lock, nothing to wire); -/// 6. nothing → refuse. +/// Route the project to a wiring flavor, first match wins. Lockfiles are the +/// authoritative "this tool manages installs" signal, so locks are compared +/// with locks (precedence follows migration direction / ecosystem currency: +/// uv > poetry > pdm > pipenv), and a lock-less tool MARKER refuses with a +/// "run ` lock`" pointer — falling through to `requirements.txt` when +/// one exists (a marker alone must not block the requirements wiring): +/// 1. `uv.lock` → uv; 2. `poetry.lock` → poetry; 3. `pdm.lock` → pdm; +/// 4. `Pipfile.lock` → pipenv; +/// 5. lock-less `[tool.uv]`/`[tool.poetry]`/`[tool.pdm]`/`Pipfile` → +/// `_no_lockfile` refusal unless requirements.txt exists; +/// 6. `requirements.txt` → requirements; +/// 7. a lone pyproject → refuse; 8. nothing → refuse. +/// +/// When more than one tool lockfile coexists, the winner is wired and a LOUD +/// `pypi_multiple_lockfiles` warning names the ignored locks — they go +/// stale-but-valid, which is otherwise invisible. pub async fn detect_pypi_flavor( project_root: &Path, -) -> Result { +) -> Result<(PypiFlavor, Vec), (&'static str, String)> { let exists = |name: &str| { let p = project_root.join(name); async move { tokio::fs::metadata(&p).await.is_ok() } }; - if exists("uv.lock").await { - return Ok(PypiFlavor::UvProject); + let has_uv_lock = exists("uv.lock").await; + let has_poetry_lock = exists("poetry.lock").await; + let has_pdm_lock = exists("pdm.lock").await; + let has_pipfile_lock = exists("Pipfile.lock").await; + let has_pipfile = exists("Pipfile").await; + + // Coexisting tool locks: wire the precedence winner, warn about the rest. + let locks: Vec<(&str, bool)> = vec![ + ("uv.lock", has_uv_lock), + ("poetry.lock", has_poetry_lock), + ("pdm.lock", has_pdm_lock), + ("Pipfile.lock", has_pipfile_lock), + ]; + let present: Vec<&str> = locks.iter().filter(|(_, p)| *p).map(|(n, _)| *n).collect(); + let mut warnings = Vec::new(); + if present.len() > 1 { + let winner = present[0]; + let losers = present[1..].join(", "); + warnings.push(VendorWarning::new( + "pypi_multiple_lockfiles", + format!( + "multiple python lockfiles found; wiring `{winner}` — installs driven by \ + {losers} will still install the UNPATCHED registry bytes" + ), + )); + } + + if has_uv_lock { + return Ok((PypiFlavor::UvProject, warnings)); + } + if has_poetry_lock { + return Ok((PypiFlavor::Poetry, warnings)); + } + if has_pdm_lock { + return Ok((PypiFlavor::Pdm, warnings)); + } + if has_pipfile_lock { + return Ok((PypiFlavor::Pipenv, warnings)); } + let pyproject_text = tokio::fs::read_to_string(project_root.join("pyproject.toml")) .await .ok(); @@ -73,35 +127,49 @@ pub async fn detect_pypi_flavor( .map(|t| has_table(t, prefix)) .unwrap_or(false) }; - if has_pyproject_table("tool.uv") && !has_requirements { - return Err(( - "pypi_uv_no_lockfile", - format!( - "pyproject.toml declares [tool.uv] but there is no uv.lock; run `uv lock` and \ - re-run vendor, or {SETUP_ALTERNATIVE}" - ), - )); - } - if exists("Pipfile").await || exists("Pipfile.lock").await { - return Err(( - "pypi_pipenv_unsupported", - format!("Pipenv projects are not supported by vendor; {SETUP_ALTERNATIVE}"), - )); - } - if exists("poetry.lock").await || has_pyproject_table("tool.poetry") { - return Err(( - "pypi_poetry_unsupported", - format!("Poetry projects are not supported by vendor; {SETUP_ALTERNATIVE}"), - )); - } - if exists("pdm.lock").await || has_pyproject_table("tool.pdm") { - return Err(( - "pypi_pdm_unsupported", - format!("PDM projects are not supported by vendor; {SETUP_ALTERNATIVE}"), - )); + // Lock-less tool markers: a `requirements.txt` fallback wins (the marker + // alone must not block wiring the file pip/uv-pip actually install from); + // without one, refuse with the tool-specific "generate your lock" pointer. + if !has_requirements { + if has_pyproject_table("tool.uv") { + return Err(( + "pypi_uv_no_lockfile", + format!( + "pyproject.toml declares [tool.uv] but there is no uv.lock; run `uv lock` and \ + re-run vendor, or {SETUP_ALTERNATIVE}" + ), + )); + } + if has_pyproject_table("tool.poetry") { + return Err(( + "pypi_poetry_no_lockfile", + format!( + "pyproject.toml declares [tool.poetry] but there is no poetry.lock; run \ + `poetry lock` and re-run vendor, or {SETUP_ALTERNATIVE}" + ), + )); + } + if has_pyproject_table("tool.pdm") { + return Err(( + "pypi_pdm_no_lockfile", + format!( + "pyproject.toml declares [tool.pdm] but there is no pdm.lock; run `pdm lock` \ + and re-run vendor, or {SETUP_ALTERNATIVE}" + ), + )); + } + if has_pipfile { + return Err(( + "pypi_pipenv_no_lockfile", + format!( + "a Pipfile exists but there is no Pipfile.lock; run `pipenv lock` and re-run \ + vendor, or {SETUP_ALTERNATIVE}" + ), + )); + } } if has_requirements { - return Ok(PypiFlavor::Requirements); + return Ok((PypiFlavor::Requirements, warnings)); } if pyproject_text.is_some() { return Err(( @@ -185,14 +253,14 @@ pub async fn vendor_pypi( }; }; - let flavor = match detect_pypi_flavor(project_root).await { + let (flavor, flavor_warnings) = match detect_pypi_flavor(project_root).await { Ok(f) => f, Err((code, detail)) => return VendorOutcome::Refused { code, detail }, }; // Pre-flight the wiring guards BEFORE building anything, so refusals // leave the tree byte-untouched. - let mut warnings: Vec = Vec::new(); + let mut warnings: Vec = flavor_warnings; let plan = match flavor { PypiFlavor::UvProject => { let project = match load_uv_project(project_root).await { @@ -214,6 +282,32 @@ pub async fn vendor_pypi( } WiringPlan::Requirements } + // Detected but not yet wired: the backends land behind these arms + // (spike-verified GO — see spikes/PHASE0-V2-FINDINGS.txt). + PypiFlavor::Poetry => { + return VendorOutcome::Refused { + code: "pypi_poetry_unsupported", + detail: format!( + "Poetry projects are not supported by this build yet; {SETUP_ALTERNATIVE}" + ), + } + } + PypiFlavor::Pdm => { + return VendorOutcome::Refused { + code: "pypi_pdm_unsupported", + detail: format!( + "PDM projects are not supported by this build yet; {SETUP_ALTERNATIVE}" + ), + } + } + PypiFlavor::Pipenv => { + return VendorOutcome::Refused { + code: "pypi_pipenv_unsupported", + detail: format!( + "Pipenv projects are not supported by this build yet; {SETUP_ALTERNATIVE}" + ), + } + } }; let dist = match locate_installed_dist(site_packages, raw_name, version).await { @@ -269,6 +363,11 @@ pub async fn vendor_pypi( PypiFlavor::UvProject => { "uv.lock now resolves it from this single-platform wheel only" } + PypiFlavor::Poetry => "poetry.lock now resolves it from this single-platform wheel only", + PypiFlavor::Pdm => "pdm.lock now resolves it from this single-platform wheel only", + PypiFlavor::Pipenv => { + "Pipfile.lock now resolves it from this single-platform wheel only" + } PypiFlavor::Requirements => { "the requirements.txt path line installs on this platform only" } @@ -363,6 +462,10 @@ pub async fn vendor_pypi( took_over_go_patches: false, flavor: Some(flavor.as_str().to_string()), uv: uv_meta, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, }; VendorOutcome::Done { result, @@ -441,59 +544,105 @@ mod tests { tokio::fs::write(root.join(name), content).await.unwrap(); } + /// One assert per row of the v2 routing table (locks > lock-less markers + /// with requirements fallthrough > requirements > pyproject > nothing). #[tokio::test] - async fn flavor_routing_table_all_six_rules() { - // 1. uv.lock wins outright. + async fn flavor_routing_table_v2_precedence() { + let flavor = |tmp: &Path| { + let tmp = tmp.to_path_buf(); + async move { detect_pypi_flavor(&tmp).await.map(|(f, _)| f) } + }; + + // 1. uv.lock wins outright (even over requirements + other markers). let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "uv.lock", "version = 1\n").await; touch(tmp.path(), "requirements.txt", "six==1.16.0\n").await; - assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap(), PypiFlavor::UvProject); + assert_eq!(flavor(tmp.path()).await.unwrap(), PypiFlavor::UvProject); - // 2. [tool.uv] without a lock (and no requirements fallback). + // 2-4. Tool locks route to their flavors. let tmp = tempfile::tempdir().unwrap(); - touch(tmp.path(), "pyproject.toml", "[project]\nname = \"x\"\n\n[tool.uv]\ndev = true\n").await; - let err = detect_pypi_flavor(tmp.path()).await.unwrap_err(); - assert_eq!(err.0, "pypi_uv_no_lockfile"); - assert!(err.1.contains("uv lock")); - assert!(err.1.contains("socket-patch setup")); + touch(tmp.path(), "poetry.lock", "").await; + assert_eq!(flavor(tmp.path()).await.unwrap(), PypiFlavor::Poetry); - // ...but WITH requirements.txt present the pip flavor still serves. - touch(tmp.path(), "requirements.txt", "six==1.16.0\n").await; - assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap(), PypiFlavor::Requirements); + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "pdm.lock", "").await; + assert_eq!(flavor(tmp.path()).await.unwrap(), PypiFlavor::Pdm); - // 3. Pipenv / Poetry / PDM markers refuse (file and table forms). let tmp = tempfile::tempdir().unwrap(); - touch(tmp.path(), "Pipfile", "").await; - touch(tmp.path(), "requirements.txt", "six==1.16.0\n").await; - assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap_err().0, "pypi_pipenv_unsupported"); + touch(tmp.path(), "Pipfile.lock", "{}").await; + assert_eq!(flavor(tmp.path()).await.unwrap(), PypiFlavor::Pipenv); + // Lock precedence among coexisting locks + the LOUD warning. let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "poetry.lock", "").await; - assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap_err().0, "pypi_poetry_unsupported"); + touch(tmp.path(), "Pipfile.lock", "{}").await; + let (f, warnings) = detect_pypi_flavor(tmp.path()).await.unwrap(); + assert_eq!(f, PypiFlavor::Poetry); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].code, "pypi_multiple_lockfiles"); + assert!(warnings[0].detail.contains("Pipfile.lock"), "{}", warnings[0].detail); + + // 5. Lock-less tool markers refuse with the per-tool pointer... + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "pyproject.toml", "[project]\nname = \"x\"\n\n[tool.uv]\ndev = true\n").await; + let err = detect_pypi_flavor(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_uv_no_lockfile"); + assert!(err.1.contains("uv lock")); + assert!(err.1.contains("socket-patch setup")); let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "pyproject.toml", "[tool.poetry]\nname = \"x\"\n").await; - assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap_err().0, "pypi_poetry_unsupported"); + let err = detect_pypi_flavor(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_poetry_no_lockfile"); + assert!(err.1.contains("poetry lock")); let tmp = tempfile::tempdir().unwrap(); - touch(tmp.path(), "pdm.lock", "").await; - assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap_err().0, "pypi_pdm_unsupported"); + touch(tmp.path(), "pyproject.toml", "[tool.pdm]\n").await; + assert_eq!( + detect_pypi_flavor(tmp.path()).await.unwrap_err().0, + "pypi_pdm_no_lockfile" + ); let tmp = tempfile::tempdir().unwrap(); - touch(tmp.path(), "pyproject.toml", "[tool.pdm]\n").await; - assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap_err().0, "pypi_pdm_unsupported"); + touch(tmp.path(), "Pipfile", "").await; + assert_eq!( + detect_pypi_flavor(tmp.path()).await.unwrap_err().0, + "pypi_pipenv_no_lockfile" + ); - // 4. requirements.txt at the root. + // ...but every lock-less marker falls through to requirements.txt when + // one exists (the marker alone must not block the pip wiring) — this + // expands v1, where a bare Pipfile + requirements.txt refused. + for marker in [ + ("pyproject.toml", "[tool.uv]\n"), + ("pyproject.toml", "[tool.poetry]\n"), + ("pyproject.toml", "[tool.pdm]\n"), + ("Pipfile", ""), + ] { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), marker.0, marker.1).await; + touch(tmp.path(), "requirements.txt", "six==1.16.0\n").await; + assert_eq!( + flavor(tmp.path()).await.unwrap(), + PypiFlavor::Requirements, + "marker {marker:?} must fall through to requirements" + ); + } + + // 6. requirements.txt at the root. let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "requirements.txt", "six==1.16.0\n").await; - assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap(), PypiFlavor::Requirements); + assert_eq!(flavor(tmp.path()).await.unwrap(), PypiFlavor::Requirements); - // 5. a lone pyproject. + // 7. a lone pyproject. let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "pyproject.toml", "[project]\nname = \"x\"\n").await; - assert_eq!(detect_pypi_flavor(tmp.path()).await.unwrap_err().0, "pypi_pyproject_only"); + assert_eq!( + detect_pypi_flavor(tmp.path()).await.unwrap_err().0, + "pypi_pyproject_only" + ); - // 6. nothing at all. + // 8. nothing at all. let tmp = tempfile::tempdir().unwrap(); let err = detect_pypi_flavor(tmp.path()).await.unwrap_err(); assert_eq!(err.0, "pypi_no_requirements"); @@ -817,6 +966,10 @@ mod tests { took_over_go_patches: false, flavor: Some("mystery".into()), uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, }; let outcome = revert_pypi(&entry, &fx.root, false).await; assert!(!outcome.success); diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs index bc02e3a..a556fb8 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs @@ -698,6 +698,10 @@ mod tests { took_over_go_patches: false, flavor: Some("requirements".into()), uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, } } diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs b/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs index 731851c..ec8be62 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs @@ -22,6 +22,10 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::fs::atomic_write_bytes; use super::state::{UvMeta, VendorEntry, WiringAction, WiringRecord}; +use super::toml_surgery::{ + balanced_span, find_unit_span, line_index, remove_exact_line, remove_substring, + remove_table_if_empty, split_top_level_commas, top_level_brace_groups, +}; use super::{RevertOutcome, VendorWarning}; /// Highest uv.lock `revision` the spike fixtures were generated with. A newer @@ -748,47 +752,6 @@ fn find_root_package_name(lock: &DocumentMut) -> Option { None } -/// `(byte_offset, line_without_newline)` for every line (locks are LF). -fn line_index(text: &str) -> Vec<(usize, &str)> { - let mut out = Vec::new(); - let mut offset = 0; - for seg in text.split_inclusive('\n') { - let line = seg.strip_suffix('\n').unwrap_or(seg); - out.push((offset, line)); - offset += seg.len(); - } - out -} - -/// Byte span of the `[[package]]` unit (header through last non-blank line, -/// including `[package.*]` sub-tables) matching `predicate`. -fn find_unit_span(text: &str, predicate: F) -> Option> -where - F: Fn(&[&str]) -> bool, -{ - let index = line_index(text); - let starts: Vec = index - .iter() - .enumerate() - .filter(|(_, (_, l))| l.trim_end() == "[[package]]") - .map(|(i, _)| i) - .collect(); - for (k, &s) in starts.iter().enumerate() { - let hard_end = starts.get(k + 1).copied().unwrap_or(index.len()); - let mut e = hard_end; - while e > s && index[e - 1].1.trim().is_empty() { - e -= 1; - } - let lines: Vec<&str> = index[s..e].iter().map(|(_, l)| *l).collect(); - if predicate(&lines) { - let start = index[s].0; - let end = index[e - 1].0 + index[e - 1].1.len(); - return Some(start..end); - } - } - None -} - fn unit_has_name(lines: &[&str], canon: &str) -> bool { lines .iter() @@ -1069,168 +1032,6 @@ fn add_manifest_override( )) } -/// Exclusive end index of the bracket opened at `open_idx` (quote-aware; -/// TOML basic strings with backslash escapes). -fn balanced_span(text: &str, open_idx: usize, open: char, close: char) -> Option { - let mut depth = 0i32; - let mut in_str = false; - let mut escaped = false; - for (i, c) in text[open_idx..].char_indices() { - if in_str { - if escaped { - escaped = false; - } else if c == '\\' { - escaped = true; - } else if c == '"' { - in_str = false; - } - continue; - } - if c == '"' { - in_str = true; - } else if c == open { - depth += 1; - } else if c == close { - depth -= 1; - if depth == 0 { - return Some(open_idx + i + c.len_utf8()); - } - } - } - None -} - -/// `(start, end)` of each top-level `{...}` group (quote-aware). -fn top_level_brace_groups(text: &str) -> Vec<(usize, usize)> { - let mut out = Vec::new(); - let mut depth = 0i32; - let mut in_str = false; - let mut escaped = false; - let mut start = None; - for (i, c) in text.char_indices() { - if in_str { - if escaped { - escaped = false; - } else if c == '\\' { - escaped = true; - } else if c == '"' { - in_str = false; - } - continue; - } - match c { - '"' => in_str = true, - '{' => { - if depth == 0 { - start = Some(i); - } - depth += 1; - } - '}' => { - depth -= 1; - if depth == 0 { - if let Some(s) = start.take() { - out.push((s, i + 1)); - } - } - } - _ => {} - } - } - out -} - -/// Split inline-table body on commas outside quotes/brackets/braces. -fn split_top_level_commas(text: &str) -> Vec<&str> { - let mut out = Vec::new(); - let mut depth = 0i32; - let mut in_str = false; - let mut escaped = false; - let mut start = 0; - for (i, c) in text.char_indices() { - if in_str { - if escaped { - escaped = false; - } else if c == '\\' { - escaped = true; - } else if c == '"' { - in_str = false; - } - continue; - } - match c { - '"' => in_str = true, - '{' | '[' => depth += 1, - '}' | ']' => depth -= 1, - ',' if depth == 0 => { - out.push(&text[start..i]); - start = i + 1; - } - _ => {} - } - } - out.push(&text[start..]); - out -} - -/// Remove the first exact occurrence of `needle`; `None` when absent. -fn remove_substring(text: &str, needle: &str) -> Option { - let idx = text.find(needle)?; - let mut out = String::with_capacity(text.len() - needle.len()); - out.push_str(&text[..idx]); - out.push_str(&text[idx + needle.len()..]); - Some(out) -} - -/// Remove the first line that equals `line` exactly; `None` when absent. -fn remove_exact_line(text: &str, line: &str) -> Option { - let mut out: Vec<&str> = Vec::new(); - let mut removed = false; - for l in text.lines() { - if !removed && l == line { - removed = true; - continue; - } - out.push(l); - } - if !removed { - return None; - } - let mut joined = out.join("\n"); - if text.ends_with('\n') && !joined.is_empty() { - joined.push('\n'); - } - Some(joined) -} - -/// Drop a `[header]` whose section holds only blank lines, plus its -/// preceding blank separator. A non-empty section is left untouched. -fn remove_table_if_empty(text: &str, header: &str) -> String { - let lines: Vec<&str> = text.lines().collect(); - let Some(h) = lines.iter().position(|l| l.trim_end() == header) else { - return text.to_string(); - }; - let mut end = h + 1; - while end < lines.len() && !lines[end].starts_with('[') { - if !lines[end].trim().is_empty() { - return text.to_string(); - } - end += 1; - } - let mut start = h; - if start > 0 && lines[start - 1].trim().is_empty() { - start -= 1; - } - let mut out: Vec<&str> = Vec::with_capacity(lines.len()); - out.extend(&lines[..start]); - out.extend(&lines[end..]); - let mut joined = out.join("\n"); - if text.ends_with('\n') && !joined.is_empty() { - joined.push('\n'); - } - joined -} - #[cfg(test)] mod tests { use super::*; @@ -1441,6 +1242,10 @@ wheels = [ took_over_go_patches: false, flavor: Some("uv".into()), uv: Some(meta), + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, } } diff --git a/crates/socket-patch-core/src/patch/vendor/state.rs b/crates/socket-patch-core/src/patch/vendor/state.rs index 8f7cbb0..0a3737c 100644 --- a/crates/socket-patch-core/src/patch/vendor/state.rs +++ b/crates/socket-patch-core/src/patch/vendor/state.rs @@ -14,6 +14,14 @@ //! through `path_safety` / `vendor::path` first; the artifact contents are //! always re-verified against the manifest's afterHashes, never against this //! file alone. +//! +//! Forward compatibility: the schema evolves by ADDING optional fields and +//! new [`WiringRecord::kind`] STRINGS — never new [`WiringAction`] variants +//! (an older binary must still deserialize a newer ledger). A revert routine +//! that meets an unknown `kind` degrades to a `vendor_lock_entry_drifted` +//! warning and leaves the fragment alone; flavor routers fail closed on +//! flavor strings they have no backend for. Both keep an old binary safe +//! against a newer project checkout. use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; @@ -133,6 +141,52 @@ pub struct UvMeta { pub lock_revision: Option, } +/// npm/pnpm bookkeeping: which `pnpm-workspace.yaml`/`package.json` tables +/// the wiring had to create (revert then removes the emptied tables too). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PnpmMeta { + /// Vendor created the `overrides` table itself. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub created_overrides_table: bool, + /// Vendor created the enclosing `pnpm` table itself. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub created_pnpm_table: bool, +} + +/// pypi/poetry bookkeeping. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PoetryMeta { + /// How the target is declared (`direct` | `transitive`). + pub dep_class: String, + /// poetry.lock `lock-version` observed at vendor time. + pub lock_version: String, +} + +/// pypi/pdm bookkeeping. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PdmMeta { + /// How the target is declared (`direct` | `transitive`). + pub dep_class: String, + /// pdm.lock `lock_version` observed at vendor time. + pub lock_version: String, + /// pdm.lock `strategy` list observed at vendor time. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub strategy: Vec, +} + +/// pypi/pipenv bookkeeping. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PipenvMeta { + /// The Pipfile/Pipfile.lock sections the wiring touched (`default`, + /// `develop`, …). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sections: Vec, +} + /// One vendored package. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -156,12 +210,28 @@ pub struct VendorEntry { /// golang: vendor took over an existing `.socket/go-patches/` redirect. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub took_over_go_patches: bool, - /// pypi: which wiring flavor was used (`uv` | `requirements`). + /// Which wiring flavor was used, for the multi-flavor ecosystems — + /// npm: `package-lock` | `yarn-classic` | `pnpm` | `bun` (absent on + /// pre-flavor entries ⇒ `package-lock`); pypi: `uv` | `requirements` | + /// `poetry` | `pdm` | `pipenv`. Reverts route on this and fail closed + /// on flavors this build has no backend for. #[serde(default, skip_serializing_if = "Option::is_none")] pub flavor: Option, /// pypi/uv extras. #[serde(default, skip_serializing_if = "Option::is_none")] pub uv: Option, + /// npm/pnpm extras. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pnpm: Option, + /// pypi/poetry extras. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub poetry: Option, + /// pypi/pdm extras. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pdm: Option, + /// pypi/pipenv extras. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pipenv: Option, } /// The ledger. @@ -306,6 +376,10 @@ mod tests { took_over_go_patches: false, flavor: None, uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, } } @@ -332,9 +406,86 @@ mod tests { let text = String::from_utf8(bytes1).unwrap(); assert!(!text.contains("tookOverGoPatches")); assert!(!text.contains("\"flavor\"")); + for absent in ["\"uv\"", "\"pnpm\"", "\"poetry\"", "\"pdm\"", "\"pipenv\""] { + assert!(!text.contains(absent), "{absent} must not serialize when None"); + } assert!(text.contains("\"basePurl\""), "camelCase keys: {text}"); } + #[tokio::test] + async fn v2_meta_structs_round_trip_with_camel_case() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut entry = sample_entry(); + entry.flavor = Some("pnpm".into()); + entry.pnpm = Some(PnpmMeta { + created_overrides_table: true, + created_pnpm_table: false, + }); + entry.poetry = Some(PoetryMeta { + dep_class: "direct".into(), + lock_version: "2.1".into(), + }); + entry.pdm = Some(PdmMeta { + dep_class: "transitive".into(), + lock_version: "4.5.0".into(), + strategy: vec!["inherit_metadata".into(), "static_urls".into()], + }); + entry.pipenv = Some(PipenvMeta { + sections: vec!["default".into(), "develop".into()], + }); + let mut state = VendorState::new(); + state.entries.insert("pkg:npm/lodash@4.17.21".into(), entry); + + save_state(root, &state).await.unwrap(); + let loaded = load_state(root).await.unwrap(); + assert_eq!(loaded, state, "every meta survives the round trip"); + + let text = tokio::fs::read_to_string(root.join(VENDOR_STATE_REL)).await.unwrap(); + // camelCase keys on the wire. + for key in [ + "\"createdOverridesTable\"", + "\"depClass\"", + "\"lockVersion\"", + "\"strategy\"", + "\"sections\"", + ] { + assert!(text.contains(key), "{key} missing: {text}"); + } + // Skip-empty inner fields: the false bool and any empty vec vanish. + assert!(!text.contains("createdPnpmTable"), "false bool omitted: {text}"); + } + + #[test] + fn v2_meta_empty_inner_fields_do_not_serialize() { + let pnpm = serde_json::to_string(&PnpmMeta { + created_overrides_table: false, + created_pnpm_table: false, + }) + .unwrap(); + assert_eq!(pnpm, "{}", "all-default PnpmMeta serializes empty"); + + let pipenv = serde_json::to_string(&PipenvMeta { sections: Vec::new() }).unwrap(); + assert_eq!(pipenv, "{}", "empty sections omitted"); + + let pdm = serde_json::to_string(&PdmMeta { + dep_class: "direct".into(), + lock_version: "4.5.0".into(), + strategy: Vec::new(), + }) + .unwrap(); + assert!(!pdm.contains("strategy"), "empty strategy omitted: {pdm}"); + + // And the omitted spellings deserialize back to the defaults. + let back: PnpmMeta = serde_json::from_str("{}").unwrap(); + assert_eq!( + back, + PnpmMeta { created_overrides_table: false, created_pnpm_table: false } + ); + let back: PipenvMeta = serde_json::from_str("{}").unwrap(); + assert!(back.sections.is_empty()); + } + #[tokio::test] async fn missing_file_is_empty_corrupt_file_is_error() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs b/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs new file mode 100644 index 0000000..e18da78 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs @@ -0,0 +1,305 @@ +//! Pure text-surgery helpers for lockfile-shaped TOML. +//! +//! The pypi/uv backend (and the upcoming poetry/pdm/pipenv ones) edit locks +//! by TARGETED text surgery rather than a TOML re-serialize: the spike +//! proved a surgical edit reproduces the lock generator's own serializer +//! output byte-identically, which keeps `--check`-style validations green +//! and the committed diff minimal. These helpers are the shared, purely +//! textual building blocks: line/byte-span indexing over `[[package]]` +//! units, quote-aware bracket/brace balancing and comma splitting, and +//! exact-match line/section removal for reverts. None of them touch the +//! filesystem and none of them interpret TOML semantics beyond the spans +//! they cut. + +use std::ops::Range; + +/// `(byte_offset, line_without_newline)` for every line (locks are LF). +pub(super) fn line_index(text: &str) -> Vec<(usize, &str)> { + let mut out = Vec::new(); + let mut offset = 0; + for seg in text.split_inclusive('\n') { + let line = seg.strip_suffix('\n').unwrap_or(seg); + out.push((offset, line)); + offset += seg.len(); + } + out +} + +/// Byte span of the `[[package]]` unit (header through last non-blank line, +/// including `[package.*]` sub-tables) matching `predicate`. +pub(super) fn find_unit_span(text: &str, predicate: F) -> Option> +where + F: Fn(&[&str]) -> bool, +{ + let index = line_index(text); + let starts: Vec = index + .iter() + .enumerate() + .filter(|(_, (_, l))| l.trim_end() == "[[package]]") + .map(|(i, _)| i) + .collect(); + for (k, &s) in starts.iter().enumerate() { + let hard_end = starts.get(k + 1).copied().unwrap_or(index.len()); + let mut e = hard_end; + while e > s && index[e - 1].1.trim().is_empty() { + e -= 1; + } + let lines: Vec<&str> = index[s..e].iter().map(|(_, l)| *l).collect(); + if predicate(&lines) { + let start = index[s].0; + let end = index[e - 1].0 + index[e - 1].1.len(); + return Some(start..end); + } + } + None +} + +/// Exclusive end index of the bracket opened at `open_idx` (quote-aware; +/// TOML basic strings with backslash escapes). +pub(super) fn balanced_span(text: &str, open_idx: usize, open: char, close: char) -> Option { + let mut depth = 0i32; + let mut in_str = false; + let mut escaped = false; + for (i, c) in text[open_idx..].char_indices() { + if in_str { + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_str = false; + } + continue; + } + if c == '"' { + in_str = true; + } else if c == open { + depth += 1; + } else if c == close { + depth -= 1; + if depth == 0 { + return Some(open_idx + i + c.len_utf8()); + } + } + } + None +} + +/// `(start, end)` of each top-level `{...}` group (quote-aware). +pub(super) fn top_level_brace_groups(text: &str) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + let mut depth = 0i32; + let mut in_str = false; + let mut escaped = false; + let mut start = None; + for (i, c) in text.char_indices() { + if in_str { + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_str = false; + } + continue; + } + match c { + '"' => in_str = true, + '{' => { + if depth == 0 { + start = Some(i); + } + depth += 1; + } + '}' => { + depth -= 1; + if depth == 0 { + if let Some(s) = start.take() { + out.push((s, i + 1)); + } + } + } + _ => {} + } + } + out +} + +/// Split inline-table body on commas outside quotes/brackets/braces. +pub(super) fn split_top_level_commas(text: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut depth = 0i32; + let mut in_str = false; + let mut escaped = false; + let mut start = 0; + for (i, c) in text.char_indices() { + if in_str { + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_str = false; + } + continue; + } + match c { + '"' => in_str = true, + '{' | '[' => depth += 1, + '}' | ']' => depth -= 1, + ',' if depth == 0 => { + out.push(&text[start..i]); + start = i + 1; + } + _ => {} + } + } + out.push(&text[start..]); + out +} + +/// Remove the first exact occurrence of `needle`; `None` when absent. +pub(super) fn remove_substring(text: &str, needle: &str) -> Option { + let idx = text.find(needle)?; + let mut out = String::with_capacity(text.len() - needle.len()); + out.push_str(&text[..idx]); + out.push_str(&text[idx + needle.len()..]); + Some(out) +} + +/// Remove the first line that equals `line` exactly; `None` when absent. +pub(super) fn remove_exact_line(text: &str, line: &str) -> Option { + let mut out: Vec<&str> = Vec::new(); + let mut removed = false; + for l in text.lines() { + if !removed && l == line { + removed = true; + continue; + } + out.push(l); + } + if !removed { + return None; + } + let mut joined = out.join("\n"); + if text.ends_with('\n') && !joined.is_empty() { + joined.push('\n'); + } + Some(joined) +} + +/// Drop a `[header]` whose section holds only blank lines, plus its +/// preceding blank separator. A non-empty section is left untouched. +pub(super) fn remove_table_if_empty(text: &str, header: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + let Some(h) = lines.iter().position(|l| l.trim_end() == header) else { + return text.to_string(); + }; + let mut end = h + 1; + while end < lines.len() && !lines[end].starts_with('[') { + if !lines[end].trim().is_empty() { + return text.to_string(); + } + end += 1; + } + let mut start = h; + if start > 0 && lines[start - 1].trim().is_empty() { + start -= 1; + } + let mut out: Vec<&str> = Vec::with_capacity(lines.len()); + out.extend(&lines[..start]); + out.extend(&lines[end..]); + let mut joined = out.join("\n"); + if text.ends_with('\n') && !joined.is_empty() { + joined.push('\n'); + } + joined +} + +#[cfg(test)] +mod tests { + use super::*; + + const LOCK: &str = "version = 1\n\n[[package]]\nname = \"proj\"\nsource = { virtual = \".\" }\n\n[package.metadata]\nrequires-dist = [{ name = \"six\" }]\n\n[[package]]\nname = \"six\"\nversion = \"1.16.0\"\n"; + + #[test] + fn line_index_reports_byte_offsets() { + let idx = line_index("a\nbb\n\nccc"); + assert_eq!(idx, vec![(0, "a"), (2, "bb"), (5, ""), (6, "ccc")]); + // Offsets must index back into the original text. + let text = "a\nbb\n\nccc"; + for (off, line) in line_index(text) { + assert_eq!(&text[off..off + line.len()], line); + } + } + + #[test] + fn find_unit_span_selects_the_matching_package_unit() { + // The first unit includes its [package.*] sub-table but not the + // trailing blank separator. + let span = find_unit_span(LOCK, |lines| { + lines.iter().any(|l| *l == "name = \"proj\"") + }) + .unwrap(); + let unit = &LOCK[span]; + assert!(unit.starts_with("[[package]]")); + assert!(unit.contains("[package.metadata]"), "sub-table included"); + assert!(unit.ends_with("requires-dist = [{ name = \"six\" }]"), "no trailing blank: {unit:?}"); + + // The second (last) unit ends at the last non-blank line. + let span = find_unit_span(LOCK, |lines| { + lines.iter().any(|l| *l == "name = \"six\"") + }) + .unwrap(); + assert_eq!(&LOCK[span], "[[package]]\nname = \"six\"\nversion = \"1.16.0\""); + + // No match → None. + assert!(find_unit_span(LOCK, |lines| lines.iter().any(|l| *l == "name = \"absent\"")).is_none()); + } + + #[test] + fn balanced_span_is_quote_aware() { + let text = "x = [\"a]b\", [1, 2], \"c\\\"]d\"] tail"; + let open = text.find('[').unwrap(); + let end = balanced_span(text, open, '[', ']').unwrap(); + assert_eq!(&text[open..end], "[\"a]b\", [1, 2], \"c\\\"]d\"]"); + // Unbalanced → None. + assert!(balanced_span("[1, 2", 0, '[', ']').is_none()); + } + + #[test] + fn brace_groups_and_comma_splits_ignore_nested_and_quoted() { + let text = "{ a = \"}\" }, { b = [1, 2] }"; + let groups = top_level_brace_groups(text); + assert_eq!(groups.len(), 2); + assert_eq!(&text[groups[0].0..groups[0].1], "{ a = \"}\" }"); + assert_eq!(&text[groups[1].0..groups[1].1], "{ b = [1, 2] }"); + + let parts = split_top_level_commas("a = 1, b = [1, 2], c = \"x,y\""); + assert_eq!(parts, vec!["a = 1", " b = [1, 2]", " c = \"x,y\""]); + } + + #[test] + fn removal_helpers_round_trip() { + assert_eq!(remove_substring("abcdef", "cd").as_deref(), Some("abef")); + assert_eq!(remove_substring("abcdef", "xy"), None); + + assert_eq!( + remove_exact_line("a\nb\na\n", "a").as_deref(), + Some("b\na\n"), + "only the FIRST exact match is removed; trailing newline kept" + ); + assert_eq!(remove_exact_line("a\nb\n", "ab"), None, "no partial-line matches"); + + // Empty section: header + preceding blank dropped. + assert_eq!( + remove_table_if_empty("x = 1\n\n[tool.uv]\n", "[tool.uv]"), + "x = 1\n" + ); + // Non-empty section untouched. + let keep = "x = 1\n\n[tool.uv]\ndev = true\n"; + assert_eq!(remove_table_if_empty(keep, "[tool.uv]"), keep); + // Absent header untouched. + assert_eq!(remove_table_if_empty("x = 1\n", "[tool.uv]"), "x = 1\n"); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/verify.rs b/crates/socket-patch-core/src/patch/vendor/verify.rs index c210b0f..b207dc5 100644 --- a/crates/socket-patch-core/src/patch/vendor/verify.rs +++ b/crates/socket-patch-core/src/patch/vendor/verify.rs @@ -229,6 +229,10 @@ mod tests { took_over_go_patches: false, flavor: None, uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, } } diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index 8564146..a1d789c 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -924,6 +924,10 @@ mod tests { took_over_go_patches: false, flavor: None, uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, } } diff --git a/spikes/PHASE0-V2-FINDINGS.txt b/spikes/PHASE0-V2-FINDINGS.txt new file mode 100644 index 0000000..0c39ff4 --- /dev/null +++ b/spikes/PHASE0-V2-FINDINGS.txt @@ -0,0 +1,198 @@ +=== yarnClassic (yarn classic 1.22.22 (via corepack 0.34.5, node v24.12.0; yarn 4.12.0 for the berry sniff)) === + [CONFIRMED] Y1 ground truth: yarn.lock entry shape for a file: tarball dep + yarn install on {"lp": "file:./lp.tgz"} produced verbatim: '"lp@file:./lp.tgz":\n version "1.3.0"\n resolved "file:./lp.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6"'. Key is quoted "@file:./"; resolved keeps the file: prefix and relative path; #fragment is the SHA1 of the tgz bytes (matches shasum -a 1); NO integrity line is emitted for native file: deps. Fixture: y1-file-dep-ground-truth/. + [CONFIRMED] Y2 lock-only edit: rewrite registry left-pad block to vendored tarball, frozen install passes + Rewrote resolved to 'file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6' and integrity to sha512-AhUdVqx1...RmPw== of our tgz. 'corepack yarn install --frozen-lockfile' exit 0, marker present in node_modules/left-pad/index.js, yarn.lock byte-unchanged (sha256-verified). Spellings: 'file:./#sha1' WORKS; bare './#sha1' WORKS; path with no ./ prefix FAILS — treated as registry-relative: 'error Error: https://registry.yarnpkg.com/.socket/vendor/npm/.../left-pad-1.3.0.tgz: Request failed "404 Not Found"'. Bonus oracle: forcing yarn to re-serialize the lock (yarn add isarray@2.0.5 + yarn remove isarray) reproduced the rewritten block byte-for-byte, so the committed after/yarn.lock is yarn-emitted, not hand-written. + [CONFIRMED] Y3 warm-cache poisoning: registry-primed cache does not shadow the vendored tarball + Primed YARN_CACHE_FOLDER via a registry-lock install (cache dir v6/npm-left-pad-1.3.0-5b8a3a7765dfe001261dde915589e782f8c94d1e-integrity), then ran the vendored-lock frozen install against the same cache: exit 0, patched marker present. Cache keys embed the sha1 (npm----integrity), so the vendored tgz got its own slot (…-fa4cc6e3…) alongside the registry one. No poisoning in either direction. + [CONFIRMED] Y4 tamper: modified tgz must fail frozen install + Two variants, both exit 1. (a) raw byte-flip at offset 100: 'error "invalid distance too far back". Mirror tarball appears to be corrupt. You can resolve this by running:\n\n rm -rf undefined\n yarn install' (gzip-level failure, note the cosmetic 'rm -rf undefined' bug). (b) the sharper test — substituting the valid-but-unpatched registry tarball (hash mismatch only): 'error Integrity check failed for "left-pad" (computed integrity doesn't match our records, got "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== sha1-W4o6d2Xf4AEmHd6RVYnngvjJTR4=")'. Same failure occurs with the integrity line removed, proving the #sha1 fragment alone is also enforced. Fixture: y4-tamper/ (replayed from the committed fixture). + [PARTIAL] Y5 merged keys + alias: merged block and npm: alias both install patched + Confirmed for the real-world shape, with one substitution: left-pad@^1.3.2 is unsatisfiable (1.3.0 is the last left-pad ever published), so the merged block was generated by yarn itself as 'left-pad@^1.3.0, left-pad@~1.3.0:' (root dep ^1.3.0 + folder dep dep-a requiring ~1.3.0), plus a separate '"alias@npm:left-pad@^1.3.0":' block from dep 'alias: npm:left-pad@^1.3.0'. After rewriting both blocks' resolved+integrity to the vendored tarball: frozen install exit 0, marker present in BOTH node_modules/left-pad/index.js and node_modules/alias/index.js (alias dir contains left-pad@1.3.0), lock byte-unchanged, and yarn's serializer round-tripped the lock byte-identically. Takeaway: one rewrite of the merged block covers all requesters; alias blocks are separate keys and need their own rewrite. Fixture: y5-merged-alias/. + [CONFIRMED] Y6 offline fresh checkout: committed files only + cold cache installs patched + Fresh dir with ONLY package.json + yarn.lock + .socket/, empty YARN_CACHE_FOLDER: 'corepack yarn install --offline --frozen-lockfile' exit 0, marker present — --offline works fine for the file: dep (no offline-mirror config needed). Re-verified with HTTP_PROXY/HTTPS_PROXY pointed at a dead port (127.0.0.1:1): still exit 0 and patched, proving zero registry traffic. + [CONFIRMED] Y7 resolution base: relative resolved resolves against the lockfile/project dir, not process cwd + Ran 'corepack yarn --cwd install --frozen-lockfile' from an unrelated directory that contained a DECOY unpatched tarball at the same relative .socket/... path. The decoy was ignored: install succeeded with the patched marker (a process-cwd resolution would have hit the decoy and failed integrity with the registry sha1). No node_modules created in the invoking dir. Also ran from a nested subdir of the project without --cwd (yarn walks up to package.json): same correct result. + [CONFIRMED] Y8 berry sniff: yarn@4 lock has __metadata: and no '# yarn lockfile v1' header + yarn 4.12.0 with nodeLinker: node-modules generated a yarn.lock starting with '# This file is generated by running "yarn install" inside your project.' + '__metadata:\n version: 8\n cacheKey: 10c0'; grep counts: __metadata:=1, '# yarn lockfile v1'=0. Entries use 'resolution:'/'checksum: 10c0/...' instead of resolved/integrity. Reliable sniff: '__metadata:' => berry; '# yarn lockfile v1' => classic. Fixture: y8-berry-sniff/. + SURPRISES: + - A resolved path WITHOUT a ./ or file: prefix is interpreted as registry-relative — yarn requested https://registry.yarnpkg.com/.socket/... and 404'd. The rewrite must emit 'file:./...' (or './...'); never a bare path. + - Native file: deps get NO integrity line from yarn (Y1), but if the rewrite adds one, yarn verifies it; and even without it the #sha1 fragment alone is enforced (substitution fails 'Integrity check failed'). So the vendored entry is double-verified when we write both. + - yarn classic's lock serializer round-trips the hand-rewritten vendored entry byte-for-byte (verified via forced re-save with yarn add/remove), meaning later legitimate yarn operations won't churn or revert the vendored block — and the fixtures' after-locks are genuinely tool-emitted. + - left-pad@^1.3.2 (from the claim text) is unsatisfiable — left-pad's last release is 1.3.0 — so the merged-key fixture uses ^1.3.0 + ~1.3.0, generated by yarn itself via a folder dep. + - The byte-flip tamper failure surfaces as a gzip error with a buggy remediation message ('rm -rf undefined') rather than an integrity error; only a valid-but-wrong tarball exercises the actual hash check — fixture y4 uses the latter as the regression oracle. + - Cache entries are keyed npm----integrity, so registry and vendored artifacts of the same name@version coexist in one cache; warm-cache poisoning is structurally impossible in v6 cache layout. + REC: Vendor v2 is fully viable for yarn classic with a lock-only rewrite and no package.json changes. Recipe: for every lock block whose resolved points at the target name@version (including merged multi-key blocks and separate \"alias@npm:...\" blocks), set resolved to \"file:./.socket/vendor/npm//-.tgz#\" and integrity to the sha512 SRI of the tgz; always use the file:./ spelling (bare ./ also works, no-prefix breaks). This is checksum-verified on every install (sha1 fragment + sha512 integrity, both enforced even under --frozen-lockfile and warm caches), survives yarn's own re-serialization byte-for-byte, installs offline from a fresh checkout with cold caches, and resolves relative to the project dir regardless of invocation cwd. Gate the rewriter on the '# yarn lockfile v1' header and bail to a different strategy when '__metadata:' (berry) is present. One residual to verify in the next spike: workspaces-root locks (single lock at the workspace root) should behave identically since resolution is lockfile-dir-based, but it was not explicitly tested here. + FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/yarn-classic + +=== yarnBerry (yarn berry 4.x (corepack; 4.12.0 + 4.6.0), nodeLinker: node-modules, node v24.12.0, Python 3.14.3 rebuild script, macOS Darwin 25.5.0) === + [REFUTED] B1: lock entry with checksum field removed makes 'yarn install --immutable' / '--immutable --check-cache' fail (YN0028/YN0018) + Both pass with EXIT=0 on a cold cache (yarn 4.12.0). Installed bytes ARE the patched ones (marker line present in node_modules/left-pad/index.js). Surprise: the --immutable run silently REWROTE yarn.lock, re-adding the checksum line (resulting lock byte-identical to the original tool-generated one) — missing checksum is TOFU+self-heal, not an immutability violation. Tamper guards still hold: tampered tgz + intact lock -> resolution recomputes hash=b4fd84 (vs 39ea9b) -> 'YN0028: The lockfile would have been modified by this install, which is explicitly forbidden.' EXIT=1; present-but-wrong checksum -> 'YN0018: ...The remote archive doesn't match the expected checksum' EXIT=1. + [CONFIRMED] B2 (DECISIVE): berry's lock checksum is reproducible offline from our tgz + Lock 'checksum: 10c0/' hex == sha512 of the cache zip file, and rebuild_zip.py (python stdlib, offline, no yarn) rebuilds that zip BYTE-IDENTICAL (cmp clean) from the tgz. Verified on yarn 4.12.0 and 4.6.0 (identical checksum 7785879d...8d7ca3), under TZ=Asia/Kathmandu (identical -> DOS times written as UTC), and on an odd-modes probe tarball. Full recipe pinned in README: package/ stripped -> node_modules// prefix; tar order with mkdirp-on-demand dir entries; all entries stored (method 0, the 'c0'); every timestamp dosdate=0x08D6 dostime=0xAE40 (SAFE_TIME 456789000 = 1984-06-22 21:50:00 UTC); modes NORMALIZED by yarn (files 0o100644, 0o100755 iff any tar exec bit; dirs always 0o40755); flags 0x0000, version-needed 10 files/20 dirs, version-made-by 0x033F; NO extra fields, NO data descriptors, NO comments, NO zip64; external_attr=mode<<16, internal_attr=0; EOCD single-disk. + [CONFIRMED] B3: resolutions-driven file: lock entry ground truth + fresh-clone --immutable passes + package.json change: add resolutions {"left-pad": "file:./.socket/vendor/npm//left-pad-1.3.0.tgz"} (dependency stays registry range 1.3.0). Verbatim entry: key 'left-pad@file:./.socket/.../left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.'; resolution '...tgz#...tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A.'; version: 1.3.0; checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3; languageName: node; linkType: hard. hash= is the first 6 hex chars of sha512(tgz bytes) (verified + tamper-flips). Lock key/resolution embed the root workspace NAME and the relative tgz path. Fresh clone of committed files with empty caches: --immutable passes (see B5). + [CONFIRMED] B4: which .yarnrc.yml knobs change the checksum + compressionLevel changes BOTH the cacheKey and the checksum: 'mixed' -> cacheKey: 10 (drops c0), files become deflate(8), checksum 10/fdd30d4a...f18fdb5d. cacheVersion is NOT settable: yarn config get cacheVersion -> 'Usage Error: Couldn't find a configuration settings named "cacheVersion"' (the 10 is internal CACHE_VERSION). cacheFolder/enableGlobalCache/cacheMigrationMode don't change fresh-install checksums. Design rule: only emit checksums for cacheKey 10c0 (compressionLevel 0 = yarn 4 default); reproducing 'mixed' would require bit-identical zlib output. + [CONFIRMED] B5: strictest fresh-checkout install of committed files installs the patched artifact, checksum-verified + Copied exactly package.json + yarn.lock + .yarnrc.yml + .socket/ to a new mktemp dir; YARN_GLOBAL_FOLDER=, YARN_ENABLE_GLOBAL_CACHE=false, and (stricter than asked) YARN_ENABLE_NETWORK=false; 'yarn install --immutable --check-cache' -> EXIT=0, marker present in node_modules/left-pad/index.js, project-local .yarn/cache/left-pad-file-8dfd6a0c16-7785879d9a.zip sha512 == lock checksum. Fully offline: file: protocol never contacts the registry when the lock is complete. + SURPRISES: + - B1 inverted: --immutable with a missing checksum PASSES and self-heals — yarn rewrites yarn.lock under --immutable to add the computed checksum back (resulting lock byte-identical to the original); only a present-but-wrong checksum (YN0018) or changed tgz bytes (YN0028 via hash= param) fail. + - The file: resolution's hash=39ea9b is simply the first 6 hex chars of sha512(tgz bytes) — an independent, lock-committed tamper guard on the tarball itself, separate from the zip checksum. + - Yarn NORMALIZES file modes during tgz->zip conversion instead of copying tar modes: files become 0644 (0755 iff any exec bit: 0600/0664/0444 all -> 0644), dirs always 0755 — confirmed with a probe tarball and still byte-identical after encoding the rule. + - Checksum is timezone-insensitive even though zip DOS timestamps are nominally local-time: yarn's wasm libzip renders SAFE_TIME as UTC (fresh install under TZ=Asia/Kathmandu produced the identical checksum). + - The zip is maximally plain — no extra fields, no data descriptors, no UTF-8 flags, no zip64 — so the first-attempt raw-struct rebuild was already byte-identical; the only versioning hazard is the internal, non-settable CACHE_VERSION ('10') which historically bumps on yarn majors. + - With enableGlobalCache: false the project-local cache file name embeds the checksum head (left-pad-file-8dfd6a0c16-7785879d9a.zip) instead of the cacheKey suffix used in the global cache (-10c0.zip). + REC: Adopt the resolutions+file: design for yarn berry 4.x — it meets the committable guarantee with full checksum verification. Implementation: (1) write the vendored tgz under .socket/vendor/npm//; (2) add the resolutions entry; (3) write the lock entry ourselves using the pinned recipe: key/resolution embed the root workspace name + relative path, hash= = first 6 hex of sha512(tgz), checksum = 10c0/ + sha512 of the deterministic zip rebuilt per rebuild_zip.py (port to Rust; trivially small: stored entries, SAFE_TIME 0x08D6/0xAE40, normalized modes, tar-order + mkdirp dirs, no extra fields) — or, acceptable fallback, omit the checksum field and let --immutable self-heal it (B1), though emitting it is strictly better and cheap. Gate checksum emission on the user's lock cacheKey == 10c0 (compressionLevel 0, the yarn 4 default); for any other cacheKey emit no checksum and rely on hash= + self-heal. Keep tgz entry modes 0644/0755 and ASCII names. Re-validate the recipe whenever yarn bumps CACHE_VERSION (lock __metadata.cacheKey changes). Untested residue: pnp linker (cache layer is linker-independent but unverified) and symlink-bearing tarballs (npm pack never emits them). + FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/yarn-berry-nm + +=== pnpm (pnpm 9.15.9 and 10.34.1 via corepack 0.34.5 (node v24.12.0, macOS Darwin 25.5.0); both `corepack pnpm@N` and packageManager-field pinning verified) === + [CONFIRMED] P1 ground truth: pnpm.overrides {left-pad@1.3.0: file:.socket/...tgz} resolves and lands in the lock on both majors + Both 9.15.9 and 10.34.1 emit byte-identical lockfileVersion: '9.0' locks (diff IDENTICAL). Verbatim: `overrides:\n left-pad@1.3.0: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz`. Importer dep: specifier is NOT unchanged — both specifier AND version are rewritten to the file: URL (`specifier: file:.socket/...tgz` / `version: file:.socket/...tgz`; package.json keeps "1.3.0"). packages: key = `left-pad@file:.socket/.../left-pad-1.3.0.tgz`; resolution has BOTH keys: `{integrity: sha512-VR8nCbFx... (patched tarball sha512), tarball: file:.socket/...tgz}` plus a new top-level `version: 1.3.0` line, and the registry entry's `deprecated:` line is dropped. snapshots: key identical to packages key; dependents reference it as bare `left-pad: file:.socket/...tgz` (no name@ prefix) in their dependencies map. Zero shape diffs between 9 and 10. + [CONFIRMED] P2 surgical reproduction: hand-edited pristine lock + package.json passes --frozen-lockfile and stays byte-stable + edit_lock.py (4 text edits + package.json pnpm.overrides; integrity computed sha512-base64 from the vendored tarball) produced locks BYTE-IDENTICAL to pnpm's own generated locks for both majors before any install. rm -rf node_modules + fresh store: `pnpm install --frozen-lockfile` exit 0, output 'Lockfile is up to date, resolution step is skipped'; marker `/* SOCKET_PATCHED 9f6b2c4e-... */` installed for direct dep AND transitive (consumer). Subsequent plain `pnpm install` left lock byte-identical (sha256 a7c36d374de4c705bdb43d7aee42d944656a3b0d9c5d2c08c5b41664d23ee156 before and after, both majors). + [CONFIRMED] P3 lock-only control: lock edit without package.json override fails frozen / reverts on plain install + Frozen (both majors): exit 1 with ` ERR_PNPM_LOCKFILE_CONFIG_MISMATCH Cannot proceed with the frozen installation. The current "overrides" configuration doesn't match the value found in the lockfile` — NOT ERR_PNPM_OUTDATED_LOCKFILE. Plain install: exit 0, silently re-resolves, REWRITES the lock (overrides section removed entirely) and installs pristine registry bytes (no marker). No warning that a patch was dropped. + [CONFIRMED] P4 fresh-offline: committed files only + empty store + --offline --frozen-lockfile installs patched bytes + Single-dep project; copied ONLY package.json + pnpm-lock.yaml + .socket/ into an empty dir; brand-new --store-dir and XDG cache/data/state. `pnpm install --frozen-lockfile --offline`: exit 0 on both majors, 'Lockfile is up to date, resolution step is skipped', node_modules/left-pad/index.js first line = SOCKET_PATCHED marker. No network needed for file: tarball resolution. + [CONFIRMED] P5 tamper + warm-store: byte-flip fails with integrity error; registry-primed store does not shadow the patch + One byte XORed mid-tarball -> frozen install exit 1, left-pad NOT installed: ` ERR_PNPM_TARBALL_INTEGRITY Got unexpected checksum for ".../.socket/vendor/npm//left-pad-1.3.0.tgz". Wanted "sha512-VR8nCbFx...". Got "sha512-FaR7sFah..."` (both majors; pnpm 10's message additionally suggests `pnpm install --update-checksums` — a footgun to warn about, since it would bless tampered bytes). Warm-store: store primed by installing registry left-pad@1.3.0 (no override), then patched project on the SAME store -> exit 0, patched marker bytes win (store is content-addressed per resolution; file: tarball has its own key). + [CONFIRMED] P6 scoping: versioned selector left-pad@1.3.0 moves only that version; left-pad@1.2.0 stays registry + Lock (both majors): `left-pad@1.2.0:` keeps registry resolution `{integrity: sha512-OQadpCyF...}` with no tarball key; importer left-pad-old still `version: left-pad@1.2.0`. node_modules: left-pad-old version 1.2.0 with 0 marker hits; left-pad and consumer's transitive left-pad each have exactly 1 marker hit. + [CONFIRMED] P7 workspace: root-only pnpm.overrides covers a workspace sub-package's dependency + pnpm-workspace.yaml (packages/*), override ONLY in root package.json. Sub-importer fragment (verbatim, both majors identical): `packages/app:\n dependencies:\n left-pad:\n specifier: file:../../.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz\n version: file:.socket/vendor/npm/9f6b2c4e-.../left-pad-1.3.0.tgz` — specifier is re-relativized PER IMPORTER (../../) while version and the packages/snapshots keys stay lockfile-root-relative. packages/app/package.json is NOT rewritten (still ^1.3.0); frozen install passes after rm -rf node_modules; app gets patched bytes. + SURPRISES: + - The lock importer `specifier` field is REWRITTEN to the override target (not kept as the package.json range), and in workspaces it is re-relativized per importer (file:../../.socket/... for packages/app) while `version` and the packages/snapshots keys stay root-relative — a lock-editing tool must compute per-importer relative paths or frozen installs will fail. + - pnpm 9.15.9 and 10.34.1 produced byte-identical lockfiles in every scenario (same lockfileVersion '9.0', zero shape diffs) — one edit shape serves both majors. + - P3's frozen failure is ERR_PNPM_LOCKFILE_CONFIG_MISMATCH (overrides config vs lock), not ERR_PNPM_OUTDATED_LOCKFILE as expected; and a plain `pnpm install` silently strips the overrides section and reverts to registry bytes with no warning at all. + - The file: tarball packages entry gains a top-level `version: 1.3.0` line and LOSES the registry entry's `deprecated:` line — edits that forget either produce a lock that plain install rewrites (byte-stability check would fail). + - Integrity (sha512 of the tarball bytes) IS enforced for local file: tarballs even offline — strong tamper story — but pnpm 10's ERR_PNPM_TARBALL_INTEGRITY message advertises `pnpm install --update-checksums`, which would launder a tampered vendored artifact if a user follows it blindly. + - snapshots dependents reference the overridden package as bare `left-pad: file:.tgz` (no name@ prefix), unlike registry refs (`left-pad: 1.3.0`). + REC: Adopt pnpm.overrides with the versioned selector + file: tarball for vendor v2 on pnpm 9 and 10 — every claim confirmed, including the committable guarantee (fresh checkout + cold store + --offline --frozen-lockfile installs checksum-verified patched bytes). The tool MUST edit both package.json (pnpm.overrides) and pnpm-lock.yaml together; the lock edit is fully mechanical (4 text edits, reference implementation in spikes/pnpm/edit_lock.py, verified byte-identical to pnpm's own output on both majors): insert overrides: section, rewrite the importer specifier+version (re-relativizing specifier per importer in workspaces), rekey the packages entry with resolution {integrity: sha512-base64(tarball), tarball: file:} + version: X.Y.Z and drop any deprecated: line, rekey snapshots and rewrite dependents' bare refs. One lock shape covers both majors. Edge cases to handle in the design: lock-without-package.json desync fails closed on CI (frozen) but silently reverts on dev plain install — patch-removal/verify tooling should detect a missing overrides pair; document that --update-checksums must never be run to 'fix' a vendored-tarball integrity failure. + FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/pnpm + +=== bun (bun 1.3.14 (0d9b296a), text lockfile bun.lock (default in 1.3.x; bun.lockb never produced); `bun ci` available as alias of `bun install --frozen-lockfile`) === + [CONFIRMED] BN1 ground truth: lock shape for "lp": "./lp.tgz" and "lp2": "file:./lp2.tgz", incl. tuple arity, integrity presence, nested key grammar + bun 1.3.14 writes text bun.lock by default (no flag needed). Local-tarball packages entry is a 3-tuple keyed by ALIAS: `"lp": ["left-pad@./lp.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="]` — element 0 = @, element 1 = deps object, element 2 = integrity which IS present and equals the sha512 of the raw tarball bytes (verified with openssl). No registry element. Bare `./lp.tgz` and `file:./lp2.tgz` produce identical entry shapes. Registry entries are 4-tuples `["left-pad@1.2.0", "", {}, "sha512-..."]` (element 1 = registry, "" = default). Two-level fixture (root left-pad@1.2.0 + local haspad.tgz depending on ^1.3.0) yields flat slash-keyed nested entry: `"haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVN..."]`. Verbatim locks in bn1-file-deps/ and bn1-nested/. + [CONFIRMED] BN2 overrides ground truth: root overrides -> file:.socket/vendor/npm//left-pad-1.3.0.tgz; lock shape; resolutions too + bun install exits 0, installs marker. Lock gains a top-level `"overrides": {"left-pad": "file:.socket/vendor/npm/9f6b2c4e-.../left-pad-1.3.0.tgz"}` section (spec verbatim) AND the `"left-pad"` packages entry becomes the 3-tuple tarball shape `["left-pad@.socket/vendor/npm//left-pad-1.3.0.tgz", {}, "sha512-BeCz4t..."]`; dependency spec stays ^1.3.0. `"resolutions"` works identically — bun normalizes it into the SAME `"overrides"` lock section, byte-identical packages entry. Fixtures bn2-overrides/, bn2-resolutions/. + [CONFIRMED] BN3 lock-only edit (decisive): hand-rewrite left-pad packages entry to local-tarball shape, package.json untouched, frozen install passes + marker + byte-stable + PASS on all three criteria. Registry project (package.json keeps `"left-pad": "1.3.0"`), only the packages entry rewritten from the registry 4-tuple to `["left-pad@.socket/vendor/npm//left-pad-1.3.0.tgz", {}, ""]`. rm -rf node_modules; `bun install --frozen-lockfile` -> exit 0, prints `+ left-pad@.socket/vendor/npm/.../left-pad-1.3.0.tgz`, marker `/* SOCKET-PATCHED left-pad@1.3.0 marker:9f6b2c4e */` present. Plain `bun install` and `bun ci` leave the lock BYTE-IDENTICAL (sha256 cdb14119c5e6... before and after, cmp-verified, re-verified on a replay of the committed fixture). The workspaces section's registry spec string vs tarball packages entry mismatch is accepted. Bonus: the edit survives `bun add is-odd` (entry + marker intact); only `bun update left-pad` reverts the entry to the registry tuple (and leaves stale patched bytes in node_modules until the next clean install). Fixture bn3-lock-only/. + [CONFIRMED] BN4 override semantics: two versions of left-pad in tree + name-keyed override — what moves? + Everything moves. Before: root left-pad@1.2.0 + nested haspad/left-pad@1.3.0. Adding `"overrides": {"left-pad": "file:..."}` collapses BOTH to the single patched root copy; the `"haspad/left-pad"` key disappears from the lock entirely and require() from haspad resolves the patched 1.3.0; the 1.2.0 consumer is force-upgraded. Trap found: a version-scoped key `"left-pad@1.3.0": "file:..."` is SILENTLY IGNORED — copied verbatim into the lock's overrides section but the nested 1.3.0 stays vanilla (fail-open no-op); bun overrides are name-only. However, a lock-only edit of just the `"haspad/left-pad"` entry gives exact per-instance targeting: root stays vanilla 1.2.0, nested patched, frozen install passes, byte-stable (fixture bn4c-targeted-nested/). + [CONFIRMED] BN5 integrity: file: entries carry integrity; tamper -> frozen install fails + file: entries DO carry integrity (sha512 of raw tarball bytes). Byte-flip at offset 100 of the vendored tarball, cold cache: `bun install --frozen-lockfile` exits 1 with verbatim errors `error: Integrity check failed for tarball: left-pad` and `error: IntegrityCheckFailed extracting tarball from left-pad`; node_modules/left-pad not created. Plain (non-frozen) `bun install` ALSO fails with the same error and does NOT rewrite the lock hash — fail-closed in both modes, no auto-heal. Checksum-verified guarantee holds for bun. + [CONFIRMED] BN6 warm cache: registry version primed in BUN_INSTALL_CACHE_DIR -> patched bytes still win + Primed isolated cache with registry left-pad@1.3.0 (cache entry `left-pad@1.3.0@@@1` present), then frozen-installed the lock-edited project against the SAME cache: patched marker installed — local tarball is not shadowed by the name@version cache entry. Reverse direction also clean: after the patched install, a fresh registry project using the same cache still gets vanilla bytes (no cache poisoning either way). + [CONFIRMED] BN7 fresh-checkout proof: committed files only + cold cache; frozen install; marker present + git init/commit/clone of exactly {package.json (untouched registry spec), edited bun.lock, .socket/vendor/npm//left-pad-1.3.0.tgz}; BUN_INSTALL_CACHE_DIR pointed at a nonexistent dir (verified cold). `bun install --frozen-lockfile` exit 0, marker present, left-pad('x',3) works. Repeated with HTTPS_PROXY/HTTP_PROXY=http://127.0.0.1:9 (dead proxy): still exit 0 — the install is fully offline. `bun ci` on the same checkout: exit 0, marker present, lock unchanged. + SURPRISES: + - bun enforces integrity on LOCAL file: tarballs (sha512 of raw tarball bytes in the lock 3-tuple) and fails closed even on plain non-frozen install without auto-healing the hash — stronger than npm's typical local-tarball handling and gives the committable guarantee real teeth. + - Version-scoped override keys ("left-pad@1.3.0": ...) are a SILENT no-op: bun copies the key verbatim into the lock's overrides section but never applies it — a fail-open trap if the vendor tool ever emits npm-style scoped overrides for bun. + - The lock-only edit survives `bun add ` re-resolution untouched; only an explicit `bun update left-pad` reverts it — and that revert leaves stale patched bytes in node_modules (lock and tree silently diverge until the next clean install). + - Tuple arity is shape-significant: local-tarball entries are 3-tuples (no registry element, deps at index 1) while registry entries are 4-tuples (registry at index 1, deps at index 2) — a lock rewriter must change arity, not just substitute fields. + - A name-keyed override deletes the nested "parent/child" lock key entirely and force-collapses ALL versions (even non-matching 1.2.0) onto the patched tarball — collateral version movement that per-entry lock edits avoid (bn4c shows exact per-instance targeting works and is byte-stable). + - `resolutions` and `overrides` are the same feature in bun: both normalize to an identical top-level "overrides" lock section. + REC: Use the lock-only edit as the primary bun mechanism: rewrite the target packages entry (any key, including nested \"parent/child\") from the registry 4-tuple to the 3-tuple `["@", {deps}, "sha512-"]`, drop the vendored .tgz under .socket/vendor/npm//, and leave package.json untouched. It passed every gate: bun ci / --frozen-lockfile exit 0, byte-stable under plain install, integrity-enforced (tamper = hard fail, no auto-heal), immune to warm-cache shadowing, fully offline on fresh checkout, survives `bun add`. It is also strictly more capable than overrides (per-instance/version targeting). Keep the overrides edit (BN2 shape: package.json overrides + lock overrides section + tarball entry) as a documented fallback only — it moves every version of the name and bun ignores version-scoped keys silently. Implementation cautions: preserve the alias-keyed `@` element-0 grammar and the arity change; compute integrity as sha512 of the raw tarball bytes; document `bun update ` as the operation that reverts the patch (consider re-applying after update); note minimum surface is bun 1.2+ text lockfile (this spike pins bun 1.3.14 behavior). + FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/bun + +=== poetry (poetry (Poetry 2.4.1 = lock-version 2.1; Poetry 1.8.5 = lock-version 2.0; Python 3.14.3, pip 26.0)) === + [CONFIRMED] P1 lock shape from 'poetry add ./.socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl' + Both majors produce: [[package]] name="six" version="1.16.0" ... files = [{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}] followed by [package.source] type = "file", url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl". URL is RELATIVE on both majors (even when add was given an absolute path); files[] is a single entry whose hash IS our patched wheel sha256; the source table sits AFTER files=[...], as the LAST subtable of the [[package]] entry (after [package.dependencies] would come too, before next [[package]]/[metadata]). Poetry 2 adds groups=["main"]; 1.8 omits it. Caveat: Poetry 2's add writes an ABSOLUTE file:/// URL into PEP 621 [project].dependencies (pyproject not committable from add); 1.8 writes relative {path=...} under [tool.poetry.dependencies]. + [CONFIRMED] P2 lock-only direct: registry pyproject + hand-spliced six lock entry installs the patched wheel + PASS on Poetry 2.4.1 with [tool.poetry.dependencies] six="1.16.0", PASS with PEP 621 dependencies=["six==1.16.0"], PASS on Poetry 1.8.5. In all three: 'poetry install' exit 0 installing 'six (1.16.0 /...../.socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl)', import six shows '# SOCKET-PATCHED', poetry.lock sha256 byte-unchanged after install. 'poetry sync' (2.4.1) and 'poetry install --sync' (1.8.5) also exit 0, 'No dependencies to install or update', lock unchanged, marker still present. content-hash was left untouched and never complained about. + [CONFIRMED] P3 lock-only transitive: python-dateutil==2.8.2 direct, splice only six's entry + PASS both majors: exit 0, installs python-dateutil 2.8.2 from registry + six 1.16.0 from the vendored file, marker present, lock byte-unchanged, dateutil imports and works. Notably the tool-generated baseline had resolved six to 1.17.0; the spliced 1.16.0 file entry installed anyway (constraint six>=1.5 satisfied) — install is purely lock-driven, no re-resolution check. + [CONFIRMED] P4 hash fail-closed: tampered wheel + stale lock hash + empty POETRY_CACHE_DIR + Both majors exit 1 with: 'RuntimeError: Hash for six (1.16.0 /.../six-1.16.0-py2.py3-none-any.whl) from archive six-1.16.0-py2.py3-none-any.whl not found in known hashes (was: sha256:2597d578238182ae0214bcf91dcf7b1bd3583f8b54ecb4aba6197751da7e4f65)' raised at poetry/installation/executor.py _validate_archive_hash (line 809 in 2.4.1, 812 in 1.8.5), 'Cannot install six.' The tampered wheel was a valid zip (extra line appended inside six.py). + [CONFIRMED] P5 silent-unpatch surfaces on the P2 spliced state + PRESERVE file source: Poetry 2.4.1 plain 'poetry lock' (left lock byte-identical, exit 0); Poetry 1.8.5 'poetry lock --no-update'; 'poetry add packaging' on BOTH majors (full re-resolve keeps locked file source for untouched packages, console even prints 'Updating six (... .whl -> ... .whl)'). REWRITE to registry (silent, exit 0): 'poetry update six' on BOTH majors — six version stays 1.16.0 (pyproject pin) but files[] reverts to the two registry hashes, i.e. next install is silently unpatched; Poetry 2.4.1 'poetry lock --regenerate'; Poetry 1.8.5 plain 'poetry lock' (1.x plain lock is a full re-resolve). Note: 'poetry add requests' originally failed resolution because requests 2.34.2 now requires Python >=3.10 vs the project's >=3.9 (lock untouched on failure); 'packaging' used instead. + [CONFIRMED] P6 'poetry check --lock' on the P2 spliced state exits 0 + Exit 0 on Poetry 2.4.1 [tool.poetry] style (prints unrelated [tool.poetry]->[project] deprecation warnings), exit 0 'All set!' on the PEP 621 variant, exit 0 'All set!' on Poetry 1.8.5. The lock freshness check is content-hash-of-pyproject only and the splice never touches pyproject. + [CONFIRMED] P7 fresh-checkout proof: pyproject + poetry.lock + .socket only, empty cache + Clean dir with exactly {pyproject.toml, poetry.lock, .socket/}, brand-new empty POETRY_CACHE_DIR, fresh in-project venv: install exit 0 and '# SOCKET-PATCHED' marker present on Poetry 2.4.1 and 1.8.5 (direct case) and on the 2.4.1 transitive case. The committable guarantee holds; the relative file url resolves against the project root. + [CONFIRMED] P8 name normalization: pyproject 'PyYAML = "6.0.1"' + Both majors record the PEP 503 canonical lowercase name in the lock: name = "pyyaml". The files[] entries keep the original artifact filename casing (PyYAML-6.0.1-*.whl). So lock-entry matching must canonicalize (lowercase, and presumably -/_/. folding) rather than reuse the pyproject spelling. + SURPRISES: + - Poetry 2.4.1 'poetry add ' rewrites PEP 621 [project].dependencies with an ABSOLUTE 'six @ file:///private/tmp/...' URL — the pyproject from 'add' is not committable, even though the lock it writes uses a relative url. The [tool.poetry.dependencies] {path = "..."} form stays relative on both majors; lock-only splicing avoids the problem entirely. + - 'poetry add ' PRESERVES the spliced file source on BOTH majors despite re-resolving and rewriting the lock — only targeted 'poetry update ' and full re-locks (2.x 'lock --regenerate', 1.x plain 'lock') revert it. 2.x plain 'poetry lock' leaves the spliced lock byte-identical. + - 'poetry update six' un-patches with zero signal: exit 0, version printed as '1.16.0 -> 1.16.0' (pyproject pins it); only files[] hashes and the dropped [package.source] reveal it. Drift detection must diff the lock, not versions or exit codes. + - The transitive splice installed six 1.16.0 from file even though the tool's own resolution in that same lock generation had picked 1.17.0 — poetry install performs no lock-vs-resolver consistency check beyond the pyproject content-hash. + - metadata.content-hash never has to be recomputed: it hashes pyproject only, so lock-only edits pass 'poetry check --lock' and install freshness on both majors. + - A lock where six is BOTH transitive-only and file-sourced is not tool-generatable (six must also be a direct path dep, as in the transitive-path fixture); that exact state is only reachable by splicing — which P3 proved both majors accept. + - Environment gotcha: requests 2.34.2 now requires Python >=3.10, so 'poetry add requests' fails to resolve under the project's >=3.9 range (lock untouched on failure); the add experiment used 'packaging' instead. Poetry 1.8.5 itself runs fine on Python 3.14.3. + REC: Lock-only splicing is the right vendor-v2 mechanism for poetry and works identically on both supported majors: leave pyproject.toml and metadata.content-hash untouched, and rewrite only the target [[package]] entry — replace files[] with a single {file = \"\", hash = \"sha256:\"} and append a [package.source] table (type = \"file\", url = relative \".socket/vendor/pypi//\") as the last subtable of the entry, keeping groups=[\"main\"] when lock-version is 2.1 and omitting it for 2.0. This passes install/sync/check --lock byte-stably, covers direct and transitive deps (even at a version the resolver would not pick), is hash-fail-closed against tampering, and survives fresh checkout with cold caches. Match lock entries by PEP 503 canonical name (pyyaml, not PyYAML). Two follow-ups are mandatory: (1) a drift check, since 'poetry update ', 2.x 'lock --regenerate', and 1.x plain 'poetry lock' silently revert the entry with exit 0 — the cheapest oracle is the patched-wheel sha256 in files[]; (2) docs/setup should steer 1.x users to 'poetry lock --no-update' and never write pyproject path deps via 'poetry add' on 2.x (it embeds absolute file:/// URLs). + FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/poetry + +=== pdm (pdm 2.27.0 (installed via pip into /tmp/pdmv venv; Python 3.14.3; lockfile lock_version 4.5.0)) === + [CONFIRMED] D1 shape: pdm add ./.socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl on pdm init -n scratch project + Lock entry verbatim: [[package]] name="six" version="1.16.0" requires_python=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" path="./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" summary=... groups=["default"] files=[{file="six-1.16.0-py2.py3-none-any.whl", hash="sha256:7015f5a42a0f83fd1b7d3ca0ba10d8777a207c19b6ffebb39e2e1c03af6a281b"}] — i.e. a RELATIVE `path` key (no ${PROJECT_ROOT} in the lock) plus a single files[] entry carrying OUR patched sha256. [metadata]: groups=["default"], strategy=["inherit_metadata"], lock_version="4.5.0", content_hash="sha256:8929...", plus [[metadata.targets]] requires_python="==3.14.*". pyproject gets `file:///${PROJECT_ROOT}/.socket/...whl` appended to dependencies (and KEEPS any prior `six==1.16.0` specifier alongside). Marker installed. + [CONFIRMED] D2 lock-only direct: registry pyproject (six==1.16.0) + hand-edited pdm.lock per D1 shape, content_hash untouched + PASS. Fresh venv + cold PDM_CACHE_DIR: `pdm sync` exit 0 (installs from path, hash-verified), `pdm install --check` exit 0, `pdm install --frozen-lockfile` exit 0 (flag confirmed in 2.27.0 help, alias --no-lock). Marker present in .venv six.py; pdm.lock byte-identical before/after (sha256 91003c30...). pdm never cross-checks that the lock candidate's path shape matches the pyproject registry specifier — only name/version vs content_hash. Negative control: editing pyproject makes `pdm install --check` exit 1 with 'Lockfile hash doesn't match pyproject.toml', so the check is not vacuous. + [CONFIRMED] D3 lock-only transitive: python-dateutil direct / six transitive + PASS, with one nuance: registry resolution locks six 1.17.0 (latest matching six>=1.5), so the splice also downgrades version to 1.16.0 — accepted without complaint. sync/--check/--frozen-lockfile all exit 0, marker present, `import dateutil.parser` works against patched six 1.16.0, lock byte-identical (sha 10e16bfa...). + [CONFIRMED] D4 tamper fail-closed: tampered wheel + cold cache + pdm sync + Exit 1, package NOT installed. Verbatim: `unearth.errors.HashMismatchError: Hash mismatch for file:///...wheel: Expected(sha256): 7015f5a4... Actual(sha256): 33e9994c...` then `[InstallationError]: Some package operations failed.` pdm does NOT skip hash checks on local paths — unearth validates the file:// link against files[] before unpack. Reproduced twice (second run with clean .pdm-python/venv/cache). + [CONFIRMED] D5 silent unpatch from the D2 state + `pdm lock`: SILENTLY UNPATCHES the lock — six entry rewritten back to registry shape (PyPI hashes, no path), exit 0, zero warning (installed env untouched). `pdm update six`: unpatches lock AND reinstalls registry six (marker gone from .venv). Plain `pdm install`: PRESERVES — resolves from lockfile since content_hash matches; lock byte-identical and marker intact. Counter-measure found: a `[tool.pdm.resolution.overrides] six = "file:///${PROJECT_ROOT}/..."` entry in pyproject makes `pdm lock` and `pdm update six` keep the path entry byte-identically. + [PARTIAL] D6 strategy matrix: --static-urls relock + D2 edit; --no-hashes lock + static_urls: `pdm lock --static-urls` works (deprecated alias of -S static_urls); shape: strategy=["inherit_metadata","static_urls"], files entries become {url="https://files.pythonhosted.org/...", hash="sha256:..."}; content_hash IDENTICAL to the default-strategy lock (covers requirements only). Splicing the same D1 path shape (path key + {file=...,hash=...} entry) into the static_urls lock works: sync/--check/--frozen-lockfile all exit 0, marker present, lock byte-stable — a file= entry is accepted inside a static_urls lock. no_hashes: NOT AVAILABLE in pdm 2.27.0 — `pdm lock --no-hashes` is an argparse usage error and `-S no_hashes` gives `[PdmUsageError]: Invalid strategy flag: hashes, supported: cross_platform, static_urls, direct_minimal_versions, inherit_metadata`; hash-less locks could not be tested (partial only for this sub-leg). + [CONFIRMED] D7 freshness: pdm install does not re-lock when content_hash matches + On the D2 edited state: `pdm lock --check` exit 0; two consecutive `pdm install` runs print 'Resolving packages from lockfile... All packages are synced to date' with no Lock step; pdm.lock sha256 AND mtime unchanged (91003c30..., mtime still the edit time). Lock is byte-stable under install. + SURPRISES: + - [tool.pdm.resolution.overrides] six = "file:///${PROJECT_ROOT}/.socket/vendor/pypi//" is the killer mechanism: pdm expands ${PROJECT_ROOT}, generates the exact same relative `path = "./..."` lock shape, works for TRANSITIVE deps without touching the dependent's requirement, and survives `pdm lock` and `pdm update six` byte-identically — closing the D5 silent-unpatch hole. It does change content_hash (overrides feed resolution), so it must be added tool-side followed by a pdm-run lock. + - pdm add does NOT replace the existing registry specifier: pyproject ends up with BOTH "six==1.16.0" and the file:///${PROJECT_ROOT} URL in dependencies; pdm resolves the pair to the path candidate. + - content_hash covers requirements only — identical between default and static_urls locks of the same pyproject — so per-package lock splices can never trip `pdm install --check`/`pdm lock --check` freshness. + - Transitive resolution locks six 1.17.0, not 1.16.0; a vendored 1.16.0 splice is a version downgrade inside the lock and pdm accepts it silently as long as the dependent's range (six>=1.5) is satisfied — no range validation error to rely on, but also none to fight. + - pdm lock --no-hashes no longer exists in 2.27.0 (PdmUsageError lists only cross_platform, static_urls, direct_minimal_versions, inherit_metadata); hashes are effectively always present in 4.5.0 locks, which strengthens the checksum-verified guarantee. + - Test-hygiene gotcha: .pdm-python stores an absolute venv path; cp -R'ing a project carries it and pdm will happily operate on the ORIGINAL project's venv (first D4 run said '1 to update' against the copied pointer). Any harness must delete .pdm-python (it is gitignored in real checkouts, so fresh clones are safe). + - A {file=..., hash=...} entry is accepted inside a strategy=[...,"static_urls"] lock — pdm does not enforce entry-shape consistency with the recorded strategy. + REC: pdm is a strong vendor-v2 target; prefer the pyproject-override route over lock-only splicing. Mechanism: (1) drop the patched wheel at .socket/vendor/pypi//; (2) add `[tool.pdm.resolution.overrides] = \"file:///${PROJECT_ROOT}/.socket/vendor/pypi//\"` to pyproject.toml; (3) run `pdm lock` (the tool generates the lock — relative `path` key + our sha256 in files[]). This covers direct AND transitive deps with one uniform edit, is fully committable/relative, is checksum fail-closed on tamper (unearth HashMismatchError, exit 1), and — unlike a lock-only splice — survives `pdm lock` and `pdm update ` byte-identically. Lock-only splicing (D1 shape: add `path`, replace files[] with single patched-wheel hash, leave content_hash) is a workable fallback on pdm 2.27.0 (sync/--check/--frozen-lockfile all pass, lock byte-stable, fail-closed hashes) but any `pdm lock`/`pdm update` silently reverts it, so it should only be used where pyproject must stay pristine, paired with drift detection. Strictest committable-guarantee install to document for users/CI: `pdm install --check --frozen-lockfile` (or `pdm sync`) on a fresh checkout — verified green with cold caches, fresh venv, marker present. + FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/pdm + +=== pipenv (pipenv 2026.6.2 (pip 26.0 host / pip 26.1.1 seeded in project venvs), Python 3.14.3, macOS arm64) === + [CONFIRMED] V1 lock shape for Pipfile file ref + Pipfile `six = {file = "./.socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl"}` -> `pipenv lock` exit 0. Verbatim default entry: {"file": "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", "hashes": ["sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'"}. Key is `file` (not `path`); `./` prefix KEPT; NO `version` key; NO `index` key; hashes are NOT computed from the local wheel — they are the PyPI REGISTRY hashes for six 1.16.0 (sdist 1e61c374 + original wheel 8abb2f1d; our patched wheel is 573ecfcc and absent). pipenv parses name/version from the wheel filename and fetches index hashes. Despite the mismatched hashes, `pipenv sync` on this tool-generated lock exits 0 and installs the PATCHED wheel (marker imports). + [CONFIRMED] V2 lock-only edit (decisive): registry Pipfile + hand-edited six lock entry survives strictest install + Registry Pipfile `six = "==1.16.0"`; only default.six in Pipfile.lock replaced with V1 shape (file ref, hashes -> [sha256:573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0], index+version dropped, markers kept, _meta untouched). Fresh state (rm .venv, cold PIPENV_CACHE_DIR/PIP_CACHE_DIR, PIPENV_VENV_IN_PROJECT=1): `pipenv sync` exit 0, `pipenv install --deploy` exit 0, `pipenv verify` exit 0 ("Pipfile.lock is up-to-date."), `.venv` import six -> SOCKET_PATCH_MARKER=9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f, lock sha256 byte-unchanged (e122334497c65539e702b817526334bb58888ec8558e56795fca36c83de61565 before and after). CAVEAT: the hash we wrote is decorative — see V4. verify/--deploy only compare _meta.hash (derived from Pipfile) so default-only edits stay green. + [CONFIRMED] V3 lock-only transitive: python-dateutil in Pipfile, six flat in lock, edit six only + Pipfile `python-dateutil = "==2.8.2"` -> lock has six FLAT in default (resolved ==1.17.0; transitive entries carry hashes+markers+version but NO `index` key). Replaced only default.six with the vendored 1.16.0 file ref. Fresh sync/install --deploy/verify all exit 0; venv has dateutil 2.8.2 + patched six 1.16.0 (marker present, dateutil.parser works); lock byte-unchanged (4ab20803...). Note the edit also downgrades 1.17.0->1.16.0 silently — pipenv never cross-checks installed version vs dependents at sync time (per-entry --no-deps installs). + [REFUTED] V4 tamper: tampered wheel -> pipenv sync nonzero with pip hash error + Tampered wheel (sha256 7c7da793670a7c386de23bd1760255ce3d23116352dce6b5369f03f9acb51418, lock demanded 573ecfcc...) at the vendored path: `pipenv sync` exit 0, `pipenv install --deploy` exit 0, `pipenv verify` exit 0, and `import six` shows the tampered payload (SOCKET_PATCH_MARKER='TAMPERED-EVIL'). Root cause (from `pipenv sync --verbose`): file-ref entries are installed in a separate 'Install Phase: Editable Requirements' — pipenv writes the requirement line as `./.socket/...whl ; ` with NO --hash, and invokes `pip install -i https://pypi.org/simple --no-input --upgrade --no-deps -r ` with NO --require-hashes. The `hashes` array on a `file` entry is never enforced. Raw pip 26 CAN fail closed for local wheels (`pip install --require-hashes` with ` --hash=sha256:` -> exit 1, 'THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE ... Expected sha256 8abb2f1d... Got 573ecfcc...'); the gap is purely pipenv's installer path. + [CONFIRMED] V5 CWD: relative file ref resolves against Pipfile dir or CWD? + Resolves against the PIPFILE's directory, not CWD — the pip-inherited hazard does NOT manifest through pipenv. (a) `pipenv sync` from /subdir (no .socket there): exit 0, patched marker installed into /.venv. (b) From an unrelated dir with PIPENV_PIPFILE=/abs/path/Pipfile AND a decoy six wheel (marker DECOY-CWD) planted at $CWD/.socket/vendor/pypi//: exit 0, venv created at the project, installed marker = 9f6b2c4e... (project wheel), decoy never touched. Mechanically pipenv runs its vendored pip from the project root even though the temp requirements file lives under $TMPDIR. + [CONFIRMED] V6 silent unpatch: which operations regenerate the lock to registry + From the V2 edited state: `pipenv lock` exit 0 -> lock REGENERATED, six reverts to registry entry (==1.16.0, registry hashes, index:pypi); vendored ref erased silently. `pipenv update six` exit 0 -> WORSE: rewrites the Pipfile pin `six = "==1.16.0"` to `six = "*"`, relocks to registry six ==1.17.0 and installs it (marker gone). Bare `pipenv install` exit 0 -> lock byte-UNCHANGED (its _meta hash still matches the untouched Pipfile, so no relock) and the vendored patched wheel is installed (marker present) — bare install is safe. + [CONFIRMED] V7 serializer / byte-stability + pipenv's lock formatting is byte-identical to Python `json.dumps(obj, indent=4, sort_keys=True) + "\n"`: 4-space indent, ALL keys sorted alphabetically at every nesting level (_meta/default/develop at top; file/hashes/index/markers/version within entries), default separators, exactly one trailing newline (file ends `}\n`, verified via xxd) — proven by re-rendering a pipenv-written lock and comparing bytes (True). After V2's pipenv sync + install --deploy + verify, lock sha256 unchanged: e122334497c65539e702b817526334bb58888ec8558e56795fca36c83de61565 before and after (same for V3: 4ab20803...). ensure_ascii behavior untested (all fixture content is ASCII). + SURPRISES: + - pipenv lock fills `hashes` for a local file ref with the PyPI REGISTRY hashes (looked up via name/version parsed from the wheel filename), not the local file's sha256 — so even the tool-generated lock for a vendored wheel contains hashes that don't match the artifact, and sync still succeeds. + - The `hashes` array on `file` lock entries is completely decorative: pipenv installs file refs in a separate 'Editable Requirements' pip phase with --no-deps and no --hash/--require-hashes. Tampered artifact installs with exit 0 across sync/--deploy/verify (V4 refuted). Raw pip 26 would have failed closed. + - `pipenv update six` doesn't just relock — it REWRITES the user's Pipfile pin from `==1.16.0` to `*` and upgrades to 1.17.0, a double silent-unpatch (Pipfile + lock). + - Bare `pipenv install` does NOT relock when the Pipfile is unchanged (only _meta.hash is compared), so the patched lock state survives it — only `lock`/`update`(/`upgrade`) clobber. + - Transitive lock entries omit the `index` key; direct registry entries carry it. The lock-only edit must drop `index` and `version` to match the tool's own file-ref shape exactly. + - Relative `file` refs resolve against the Pipfile directory even via PIPENV_PIPFILE from an unrelated CWD with a decoy planted at CWD — pipenv does not inherit pip's requirements-file-relative-path-vs-CWD hazard. + REC: Pipenv vendor v2 is VIABLE via the lock-only edit (V2/V3 shape): replace only the default. entry with {file: \"./.socket/vendor/pypi//\", hashes: [sha256:], markers: } (drop index+version), leave _meta untouched, serialize with json.dumps(indent=4, sort_keys=True)+\"\\n\" for guaranteed byte-stability. This survives pipenv sync, install --deploy, verify, and bare pipenv install on a fresh checkout with cold caches, and path resolution is safely Pipfile-dir-relative. HOWEVER, the committable guarantee's 'checksum-verified where the format supports it' clause must be classified as NOT SUPPORTED for pipenv: pipenv never enforces hashes on file entries (tamper installs silently, V4), so Socket tooling must provide tamper-evidence itself — verify the vendored wheel's sha256 against the lock entry in `socket patch verify`/CI (the hash we write into the lock is the right self-documenting place, and becomes enforced for free if pipenv ever fixes its file-ref install phase). Treat `pipenv lock` and `pipenv update/upgrade` as unpatch events: detect drift by checking the default. entry still carries the .socket/vendor file ref (cheap string probe) and re-apply; `pipenv update ` additionally reverts any Pipfile pin to `*`, so re-application must not assume the Pipfile is intact. Do NOT rely on the Pipfile file-ref form (direct-file/transitive-file pairs) as the primary mechanism — it works but mutates the user's Pipfile and still gets registry hashes in the lock; prefer lock-only. + FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/pipenv + +=== gemChecksums (bundler 2.7.2 (ruby 3.3.11, RubyGems 3.5.22, ruby:3.3 docker image, aarch64-linux)) === + [CONFIRMED] G1 enable + capture: lockfile_checksums config before first lock, and bundle lock --add-checksums on an existing lock, both produce a CHECKSUMS section; capture registry line grammar verbatim + Both routes produce the byte-identical section. Verbatim (2-space indent, single space before token, 64 lowercase hex): 'CHECKSUMS\n rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1'. 'bundle config set --local lockfile_checksums true' then 'bundle lock' exits 0; separately, a lock created WITHOUT the config has no CHECKSUMS section and 'bundle lock --add-checksums' (exit 0, 'Writing lockfile to /app/Gemfile.lock') adds the identical line. + [CONFIRMED] G2 path-gem CHECKSUMS form (decisive): what does CHECKSUMS contain for a path-sourced gem? + NOT omitted, NOT sha256'd: the path gem gets a BARE entry ' rack (3.1.8)' (name + version, no token). Full tool-generated lock: PATH/remote: vendored/rack-3.1.8 (Bundler strips the Gemfile's leading './'), empty 'GEM remote: https://rubygems.org/ specs:' block retained, DEPENDENCIES entry becomes 'rack (= 3.1.8)!' (trailing bang), CHECKSUMS ' rack (3.1.8)'. bundle install exits 0, leaves the lock byte-identical, uses the vendored dir IN PLACE (never copies into vendor/bundle): $LOADED_FEATURES shows /app/vendored/rack-3.1.8/lib/rack.rb whose line 1 is the patch marker. Gem dir was the installed gems/rack-3.1.8 copy + specifications/rack-3.1.8.gemspec copied to vendored/rack-3.1.8/rack.gemspec. + [CONFIRMED] G3 byte-stability: hand-written lock in the captured pair-edited form survives bundle install, BUNDLE_FROZEN=true bundle install, and bundle lock byte-identically with all exit 0 + Hand-written lock (PATH section + DEPENDENCIES '!' pin + bare CHECKSUMS entry) was diff-identical to Bundler's own G2 output before any run. From a fresh dir containing only Gemfile, Gemfile.lock, .bundle/config, vendored/ (committed files only), each step in a fresh container with vendor/ removed between (cold caches): bundle install exit 0, BUNDLE_FROZEN=true bundle install exit 0, bundle lock exit 0 ('Writing lockfile'); lock sha256 3086e757...60a2 unchanged across all three ('ALL BYTE-IDENTICAL'). Committable guarantee validated again directly on the committed fixture tree, including patched-file load check. + [REFUTED] G4 stale checksum failure mode: registry sha256 left on the path gem's CHECKSUMS line breaks install/frozen-install/lock + Nothing breaks and nothing changes on Bundler 2.7.2: bundle install exit 0, BUNDLE_FROZEN=true bundle install exit 0, bundle lock exit 0, and all three leave the stale token byte-for-byte in place — Bundler never verifies checksums for PATH sources and preserves existing CHECKSUMS entries on rewrite. The v1 bug is LATENT, not loud: only a from-scratch regen (delete lock, bundle lock) emits the bare form, i.e. permanent diff churn vs anything Bundler would write. Negative control proves enforcement is live for registry gems: corrupting the sha256 of registry-sourced rack fails cold install with exit 37, 'Bundler found mismatched checksums. This is a potential security risk. ... from the lockfile CHECKSUMS at Gemfile.lock:14:16 ... from the API at https://rubygems.org/' (caught at metadata time, pre-download). + [CONFIRMED] G5 platform entries: CHECKSUMS lines are not platform-suffixed for normal linux installs of a pure-ruby gem + Pure-ruby rack gets exactly one un-suffixed line ' rack (3.1.8) sha256=...' even though PLATFORMS lists both aarch64-linux and ruby. The suffix grammar DOES exist for native gems: locking ffi 1.17.2 yields one CHECKSUMS line per platform spec mirroring the GEM specs entries, e.g. ' ffi (1.17.2-aarch64-linux-gnu) sha256=c910bd3c...' alongside bare ' ffi (1.17.2) sha256=...'. So the emitter must match (version[-platform]) tokens against specs entries, but for the pure-ruby vendored case there is a single bare-version line. + SURPRISES: + - G4 refuted the expected loud failure: a stale registry sha256 on a path gem's CHECKSUMS line is silently preserved by install, frozen install, AND bundle lock on Bundler 2.7.2 — checksum verification is skipped entirely for PATH sources, so the v1 bug manifests only as permanent lock-vs-regen divergence, not an error. + - Reverse direction is the loud one (pins the ROLLBACK emitter): a bare token-less CHECKSUMS entry on a REGISTRY-sourced gem fails BUNDLE_FROZEN=true bundle install with exit 16: 'Your lockfile has an empty CHECKSUMS entry for "rack", but can't be updated because frozen mode is set'; plain bundle install succeeds but REWRITES the lock to fill the sha back in (not byte-stable). Rollback must restore the registry sha256 token verbatim. + - Registry checksum mismatches are caught against the rubygems.org compact-index API at metadata-fetch time (exit 37), before any gem download — so a patched .gem placed under a registry source would fail even with warm caches. + - Path gems are used in place, never copied into vendor/bundle, and never checksum-verified — the gem format's committable guarantee for vendored artifacts rests on git content alone, with no Bundler-side integrity check available. + - Bundler normalizes Gemfile path: './vendored/rack-3.1.8' to lock 'remote: vendored/rack-3.1.8' (leading './' stripped) — an emitter writing the './' form would not be byte-stable under bundle lock. + - The installed gemspec from vendor/bundle/ruby/3.3.0/specifications/ works as-is when copied to the root of the vendored gem dir (stub format, s.files only lists docs — harmless since path gems resolve load paths from require_paths). + REC: Adopt the PATH-source conversion for gem vendoring. Emitter spec, pinned by tool-generated fixtures: (1) move the gem from GEM specs to a PATH section with 'remote: ', keep the (possibly empty) GEM block; (2) append '!' to the gem's DEPENDENCIES line; (3) replace its CHECKSUMS line with the bare ' ()' form — strip the sha256 token (and any platform-suffixed sibling lines for that name/version, since PATH specs collapse to one bare-version entry); (4) vendored dir = installed gem dir + specifications/-.gemspec copied to /.gemspec. This form is byte-stable under bundle install, frozen install, and bundle lock (Bundler 2.7.2). Rollback must restore the original registry 'sha256=' token exactly — a bare entry on a registry gem hard-fails frozen installs (exit 16) and causes lock churn on plain installs. Caveats to document: Bundler performs no checksum verification for path gems (integrity rides on git), and leaving the stale registry sha (v1 bug) errors nowhere on 2.7.x — detect/repair it proactively rather than relying on Bundler to complain. Note all locks here were generated on aarch64-linux; PLATFORMS content is host-dependent, so the emitter must never touch PLATFORMS. + FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/gem-checksums + diff --git a/spikes/bun/README.md b/spikes/bun/README.md new file mode 100644 index 0000000..2b24ed6 --- /dev/null +++ b/spikes/bun/README.md @@ -0,0 +1,76 @@ +# bun vendor-v2 spike fixtures + +Tool: **bun 1.3.14 (0d9b296a)**, macOS (Darwin 25.5.0, arm64). Node v24.12.0 used only as a +require() oracle. Captured 2026-06-10. All installs ran with an isolated `BUN_INSTALL_CACHE_DIR`. + +bun 1.3.x writes the **text lockfile `bun.lock` by default** (no `--save-text-lockfile` needed; +`bun.lockb` is never produced). `bun ci` exists and is an alias of +`bun install --frozen-lockfile`. + +Patched artifact: `artifacts/left-pad-1.3.0-patched.tgz` — registry left-pad-1.3.0.tgz +(sha1 5b8a3a7765dfe001261dde915589e782f8c94d1e) unpacked, marker comment +`/* SOCKET-PATCHED left-pad@1.3.0 marker:9f6b2c4e */` prepended to `package/index.js`, +repacked with `tar czf` keeping the `package/` prefix. +Its `sha512` (base64) is `BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ==` +— this exact string appears as the integrity element of every tarball lock entry below +(**integrity = sha512 of the raw tarball bytes**). +`artifacts/haspad-1.0.0.tgz` is a shell-built one-file package depending on `left-pad: ^1.3.0`, +used to force a nested lock key. + +Every `after/bun.lock` was generated by `bun install` itself, with one deliberate exception: +the two lock-only-edit pairs (bn3, bn4c), where the *edit* is the thing under test. There the +edited entry copies bun's own bn1/bn2-generated shape verbatim, and bun then *blessed* the file: +`bun install --frozen-lockfile` and `bun ci` exit 0 and a subsequent plain `bun install` leaves +the lock **byte-identical** (cmp-verified, including on a replay of the committed fixture). + +## Pairs + +- `bn1-file-deps/` — ground truth for `"lp": "./lp.tgz"` and `"lp2": "file:./lp2.tgz"`. + Local-tarball packages entry is a **3-tuple**: + `"lp": ["left-pad@./lp.tgz", {deps-object}, ""]` — + key = the *alias*, element 0 = `@`, **no registry element**, + integrity **present**. Bare `./lp.tgz` and `file:./lp2.tgz` specs produce identical entry + shapes (spec string preserved only in the workspaces section). +- `bn1-nested/` — two-level key grammar. Registry entry is a **4-tuple** + `["name@version", "", {deps}, "sha512-..."]`; conflict nesting uses a + flat slash key: `"haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-..."]`. +- `bn2-overrides/` — root `"overrides": {"left-pad": "file:.socket/vendor/npm//left-pad-1.3.0.tgz"}`. + Lock gains a top-level `"overrides"` section (spec copied verbatim) and the `"left-pad"` + packages entry becomes the 3-tuple tarball shape with the `.socket/...` path. Marker installs. +- `bn2-resolutions/` — same but via `"resolutions"`. bun normalizes it into the **same + `"overrides"` lock section**; lock is otherwise identical to bn2-overrides. +- `bn3-lock-only/` — **the decisive pair.** before = pure registry project + bun-generated lock. + after = *identical package.json* (still `"left-pad": "1.3.0"`), only the packages entry + rewritten to the bn1 tarball shape + the vendored tarball added. + `rm -rf node_modules && bun install --frozen-lockfile` → exit 0, marker present; + plain `bun install` and `bun ci` keep the lock byte-identical. PASS. +- `bn4-override-collapse/` — before: two left-pad versions in the tree (root 1.2.0, nested + `haspad/left-pad` 1.3.0). after: name-keyed override → **both instances collapse to the single + patched root copy; the nested key disappears from the lock** (`require` from haspad resolves + the patched 1.3.0). Name-keyed overrides move *every* version. +- `bn4b-version-key-ignored/` — trap: `"overrides": {"left-pad@1.3.0": "file:..."}` is + **silently ignored** (copied verbatim into the lock's overrides section, but the nested 1.3.0 + stays vanilla registry — fail-open no-op). bun overrides are name-only. +- `bn4c-targeted-nested/` — lock-only edit of *just* `"haspad/left-pad"` to the tarball shape: + root stays vanilla 1.2.0, nested becomes patched 1.3.0, frozen install passes, byte-stable. + Lock edits give per-instance targeting that overrides cannot. + +## Verbatim outcomes not embodied in a pair + +- **BN5 tamper:** byte-flip the vendored tarball → `bun install --frozen-lockfile` *and* plain + `bun install` exit 1 with + `error: Integrity check failed for tarball: left-pad` / + `error: IntegrityCheckFailed extracting tarball from left-pad`; nothing installed, lock + unchanged. Fail-closed. +- **BN6 warm cache:** cache primed with registry left-pad@1.3.0 (cache entry + `left-pad@1.3.0@@@1`) → patched tarball bytes still win; and the patched extraction does + **not** poison later registry installs from the same cache. +- **BN7 fresh checkout:** `git clone` of {package.json, edited bun.lock, .socket tarball} + + cold cache + dead `HTTPS_PROXY` (network blocked) → `bun install --frozen-lockfile` and + `bun ci` exit 0, marker present, fully offline. +- **Survival:** the lock-only edit survives `bun add is-odd`. `bun update left-pad` reverts the + lock entry to the registry tuple (while stale patched bytes linger in node_modules until the + next clean install) — the one operation that undoes the patch. + +Replay any pair: `cd /after && BUN_INSTALL_CACHE_DIR=$(mktemp -d) bun ci && head -1 node_modules/left-pad/index.js` +(bn1/bn4 pairs install `lp`/`haspad` aliases; check the corresponding node_modules path). diff --git a/spikes/bun/bn1-file-deps/after/bun.lock b/spikes/bun/bn1-file-deps/after/bun.lock new file mode 100644 index 0000000..6990295 --- /dev/null +++ b/spikes/bun/bn1-file-deps/after/bun.lock @@ -0,0 +1,18 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn1-ground-truth", + "dependencies": { + "lp": "./lp.tgz", + "lp2": "file:./lp2.tgz", + }, + }, + }, + "packages": { + "lp": ["left-pad@./lp.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], + + "lp2": ["left-pad@./lp2.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], + } +} diff --git a/spikes/bun/bn1-file-deps/after/package.json b/spikes/bun/bn1-file-deps/after/package.json new file mode 100644 index 0000000..950e276 --- /dev/null +++ b/spikes/bun/bn1-file-deps/after/package.json @@ -0,0 +1,8 @@ +{ + "name": "bn1-ground-truth", + "version": "1.0.0", + "dependencies": { + "lp": "./lp.tgz", + "lp2": "file:./lp2.tgz" + } +} diff --git a/spikes/bun/bn1-file-deps/before/package.json b/spikes/bun/bn1-file-deps/before/package.json new file mode 100644 index 0000000..950e276 --- /dev/null +++ b/spikes/bun/bn1-file-deps/before/package.json @@ -0,0 +1,8 @@ +{ + "name": "bn1-ground-truth", + "version": "1.0.0", + "dependencies": { + "lp": "./lp.tgz", + "lp2": "file:./lp2.tgz" + } +} diff --git a/spikes/bun/bn1-nested/after/bun.lock b/spikes/bun/bn1-nested/after/bun.lock new file mode 100644 index 0000000..13377a5 --- /dev/null +++ b/spikes/bun/bn1-nested/after/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn1-nested", + "dependencies": { + "haspad": "file:./haspad-1.0.0.tgz", + "left-pad": "1.2.0", + }, + }, + }, + "packages": { + "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], + + "left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], + + "haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + } +} diff --git a/spikes/bun/bn1-nested/after/package.json b/spikes/bun/bn1-nested/after/package.json new file mode 100644 index 0000000..bb8109c --- /dev/null +++ b/spikes/bun/bn1-nested/after/package.json @@ -0,0 +1,8 @@ +{ + "name": "bn1-nested", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.2.0", + "haspad": "file:./haspad-1.0.0.tgz" + } +} diff --git a/spikes/bun/bn1-nested/before/package.json b/spikes/bun/bn1-nested/before/package.json new file mode 100644 index 0000000..bb8109c --- /dev/null +++ b/spikes/bun/bn1-nested/before/package.json @@ -0,0 +1,8 @@ +{ + "name": "bn1-nested", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.2.0", + "haspad": "file:./haspad-1.0.0.tgz" + } +} diff --git a/spikes/bun/bn2-overrides/after/bun.lock b/spikes/bun/bn2-overrides/after/bun.lock new file mode 100644 index 0000000..fa3106d --- /dev/null +++ b/spikes/bun/bn2-overrides/after/bun.lock @@ -0,0 +1,18 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn2-overrides", + "dependencies": { + "left-pad": "^1.3.0", + }, + }, + }, + "overrides": { + "left-pad": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", + }, + "packages": { + "left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], + } +} diff --git a/spikes/bun/bn2-overrides/after/package.json b/spikes/bun/bn2-overrides/after/package.json new file mode 100644 index 0000000..70b6d69 --- /dev/null +++ b/spikes/bun/bn2-overrides/after/package.json @@ -0,0 +1,10 @@ +{ + "name": "bn2-overrides", + "version": "1.0.0", + "dependencies": { + "left-pad": "^1.3.0" + }, + "overrides": { + "left-pad": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } +} diff --git a/spikes/bun/bn2-overrides/before/bun.lock b/spikes/bun/bn2-overrides/before/bun.lock new file mode 100644 index 0000000..64ac20e --- /dev/null +++ b/spikes/bun/bn2-overrides/before/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn2-overrides", + "dependencies": { + "left-pad": "^1.3.0", + }, + }, + }, + "packages": { + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + } +} diff --git a/spikes/bun/bn2-overrides/before/package.json b/spikes/bun/bn2-overrides/before/package.json new file mode 100644 index 0000000..9734154 --- /dev/null +++ b/spikes/bun/bn2-overrides/before/package.json @@ -0,0 +1,7 @@ +{ + "name": "bn2-overrides", + "version": "1.0.0", + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/spikes/bun/bn2-resolutions/after/bun.lock b/spikes/bun/bn2-resolutions/after/bun.lock new file mode 100644 index 0000000..133c148 --- /dev/null +++ b/spikes/bun/bn2-resolutions/after/bun.lock @@ -0,0 +1,18 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn2-resolutions", + "dependencies": { + "left-pad": "^1.3.0", + }, + }, + }, + "overrides": { + "left-pad": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", + }, + "packages": { + "left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], + } +} diff --git a/spikes/bun/bn2-resolutions/after/package.json b/spikes/bun/bn2-resolutions/after/package.json new file mode 100644 index 0000000..5b7ddaa --- /dev/null +++ b/spikes/bun/bn2-resolutions/after/package.json @@ -0,0 +1,10 @@ +{ + "name": "bn2-resolutions", + "version": "1.0.0", + "dependencies": { + "left-pad": "^1.3.0" + }, + "resolutions": { + "left-pad": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } +} diff --git a/spikes/bun/bn3-lock-only/after/bun.lock b/spikes/bun/bn3-lock-only/after/bun.lock new file mode 100644 index 0000000..a241cb8 --- /dev/null +++ b/spikes/bun/bn3-lock-only/after/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn3-lockonly", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], + } +} diff --git a/spikes/bun/bn3-lock-only/after/package.json b/spikes/bun/bn3-lock-only/after/package.json new file mode 100644 index 0000000..14649ef --- /dev/null +++ b/spikes/bun/bn3-lock-only/after/package.json @@ -0,0 +1,7 @@ +{ + "name": "bn3-lockonly", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0" + } +} diff --git a/spikes/bun/bn3-lock-only/before/bun.lock b/spikes/bun/bn3-lock-only/before/bun.lock new file mode 100644 index 0000000..b0c5264 --- /dev/null +++ b/spikes/bun/bn3-lock-only/before/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn3-lockonly", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + } +} diff --git a/spikes/bun/bn3-lock-only/before/package.json b/spikes/bun/bn3-lock-only/before/package.json new file mode 100644 index 0000000..14649ef --- /dev/null +++ b/spikes/bun/bn3-lock-only/before/package.json @@ -0,0 +1,7 @@ +{ + "name": "bn3-lockonly", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0" + } +} diff --git a/spikes/bun/bn4-override-collapse/after/bun.lock b/spikes/bun/bn4-override-collapse/after/bun.lock new file mode 100644 index 0000000..c8e7975 --- /dev/null +++ b/spikes/bun/bn4-override-collapse/after/bun.lock @@ -0,0 +1,21 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn4-twover", + "dependencies": { + "haspad": "file:./haspad-1.0.0.tgz", + "left-pad": "1.2.0", + }, + }, + }, + "overrides": { + "left-pad": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", + }, + "packages": { + "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], + + "left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], + } +} diff --git a/spikes/bun/bn4-override-collapse/after/package.json b/spikes/bun/bn4-override-collapse/after/package.json new file mode 100644 index 0000000..ed30676 --- /dev/null +++ b/spikes/bun/bn4-override-collapse/after/package.json @@ -0,0 +1,11 @@ +{ + "name": "bn4-twover", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.2.0", + "haspad": "file:./haspad-1.0.0.tgz" + }, + "overrides": { + "left-pad": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } +} diff --git a/spikes/bun/bn4-override-collapse/before/bun.lock b/spikes/bun/bn4-override-collapse/before/bun.lock new file mode 100644 index 0000000..13377a5 --- /dev/null +++ b/spikes/bun/bn4-override-collapse/before/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn1-nested", + "dependencies": { + "haspad": "file:./haspad-1.0.0.tgz", + "left-pad": "1.2.0", + }, + }, + }, + "packages": { + "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], + + "left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], + + "haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + } +} diff --git a/spikes/bun/bn4-override-collapse/before/package.json b/spikes/bun/bn4-override-collapse/before/package.json new file mode 100644 index 0000000..bb8109c --- /dev/null +++ b/spikes/bun/bn4-override-collapse/before/package.json @@ -0,0 +1,8 @@ +{ + "name": "bn1-nested", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.2.0", + "haspad": "file:./haspad-1.0.0.tgz" + } +} diff --git a/spikes/bun/bn4b-version-key-ignored/after/bun.lock b/spikes/bun/bn4b-version-key-ignored/after/bun.lock new file mode 100644 index 0000000..74634b1 --- /dev/null +++ b/spikes/bun/bn4b-version-key-ignored/after/bun.lock @@ -0,0 +1,23 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn4b", + "dependencies": { + "haspad": "file:./haspad-1.0.0.tgz", + "left-pad": "1.2.0", + }, + }, + }, + "overrides": { + "left-pad@1.3.0": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", + }, + "packages": { + "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], + + "left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], + + "haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + } +} diff --git a/spikes/bun/bn4b-version-key-ignored/after/package.json b/spikes/bun/bn4b-version-key-ignored/after/package.json new file mode 100644 index 0000000..a5d3eba --- /dev/null +++ b/spikes/bun/bn4b-version-key-ignored/after/package.json @@ -0,0 +1,11 @@ +{ + "name": "bn4b", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.2.0", + "haspad": "file:./haspad-1.0.0.tgz" + }, + "overrides": { + "left-pad@1.3.0": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } +} diff --git a/spikes/bun/bn4c-targeted-nested/after/bun.lock b/spikes/bun/bn4c-targeted-nested/after/bun.lock new file mode 100644 index 0000000..32634bf --- /dev/null +++ b/spikes/bun/bn4c-targeted-nested/after/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn4c-targeted", + "dependencies": { + "haspad": "file:./haspad-1.0.0.tgz", + "left-pad": "1.2.0", + }, + }, + }, + "packages": { + "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], + + "left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], + + "haspad/left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], + } +} diff --git a/spikes/bun/bn4c-targeted-nested/after/package.json b/spikes/bun/bn4c-targeted-nested/after/package.json new file mode 100644 index 0000000..0bce809 --- /dev/null +++ b/spikes/bun/bn4c-targeted-nested/after/package.json @@ -0,0 +1,8 @@ +{ + "name": "bn4c-targeted", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.2.0", + "haspad": "file:./haspad-1.0.0.tgz" + } +} diff --git a/spikes/bun/bn4c-targeted-nested/before/bun.lock b/spikes/bun/bn4c-targeted-nested/before/bun.lock new file mode 100644 index 0000000..3505b96 --- /dev/null +++ b/spikes/bun/bn4c-targeted-nested/before/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn4c-targeted", + "dependencies": { + "haspad": "file:./haspad-1.0.0.tgz", + "left-pad": "1.2.0", + }, + }, + }, + "packages": { + "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], + + "left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], + + "haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + } +} diff --git a/spikes/bun/bn4c-targeted-nested/before/package.json b/spikes/bun/bn4c-targeted-nested/before/package.json new file mode 100644 index 0000000..0bce809 --- /dev/null +++ b/spikes/bun/bn4c-targeted-nested/before/package.json @@ -0,0 +1,8 @@ +{ + "name": "bn4c-targeted", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.2.0", + "haspad": "file:./haspad-1.0.0.tgz" + } +} diff --git a/spikes/gem-checksums/README.md b/spikes/gem-checksums/README.md new file mode 100644 index 0000000..a959447 --- /dev/null +++ b/spikes/gem-checksums/README.md @@ -0,0 +1,111 @@ +# Spike: Bundler >= 2.6 CHECKSUMS for vendored (path-sourced) gem patching + +Tool versions (everything ran inside docker, fresh container per step = cold caches): + +- image: `ruby:3.3` (digest base `56d789a4b8e8`), aarch64-linux +- ruby 3.3.11 (2026-03-26 revision 1f2d15125a) [aarch64-linux] +- RubyGems 3.5.22 +- **Bundler 2.7.2** (installed via `gem install bundler -v '~> 2.7' --no-document`) +- invocation pattern: `docker run --rm -v :/app -w /app -e BUNDLE_APP_CONFIG=/app/.bundle ruby:3.3 ...` + +Every `after/Gemfile.lock` was written by Bundler itself (`bundle lock` or `bundle install`), +never hand-written. Locks were generated on aarch64-linux, so `PLATFORMS` contains +`aarch64-linux` + `ruby`; regenerating on x86_64 would add `x86_64-linux`. + +Common config (committed as `.bundle/config` in each tree): + +```yaml +--- +BUNDLE_PATH: "vendor/bundle" +BUNDLE_LOCKFILE_CHECKSUMS: "true" +``` + +## Pairs + +### registry-with-checksums/ (G1) +- `before/`: Gemfile (`gem "rack", "3.1.8"` from rubygems.org) + `.bundle/config`, no lock. +- `after/`: lock produced by `bundle lock` with `lockfile_checksums true` set before first lock. + Verbatim registry CHECKSUMS grammar (2-space indent, single space before token, 64 lowercase hex): + + ``` + CHECKSUMS + rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 + ``` + + `bundle lock --add-checksums` on a lock created *without* the config produces the + byte-identical CHECKSUMS section (verified). + +### path-with-checksums/ (G2 — pins the emitter) +- `before/`: the registry-locked project (what socket-patch sees pre-patch). +- `after/`: Gemfile switched to `gem "rack", "3.1.8", path: "./vendored/rack-3.1.8"`; + `vendored/rack-3.1.8/` = installed gem dir copied from `vendor/bundle/ruby/3.3.0/gems/` + + gemspec copied from `specifications/rack-3.1.8.gemspec` to `vendored/rack-3.1.8/rack.gemspec` + + patch marker `# socket-patch: patched rack-3.1.8 (spike marker)` as line 1 of `lib/rack.rb`. + Lock written by `bundle lock`. The path gem is **not omitted** from CHECKSUMS — it gets a + **bare entry with no sha256 token**: + + ``` + PATH + remote: vendored/rack-3.1.8 + specs: + rack (3.1.8) + + GEM + remote: https://rubygems.org/ + specs: + + ... + DEPENDENCIES + rack (= 3.1.8)! + + CHECKSUMS + rack (3.1.8) + ``` + + Notes for the emitter: Bundler strips the Gemfile's leading `./` in `remote:`; + the DEPENDENCIES entry gains a trailing `!`; the empty `GEM ... specs:` block stays. +- Byte-stability (G3): a hand-written lock in exactly this form was diff-identical to + Bundler's own output, and from a fresh checkout with cold caches all of + `bundle install`, `BUNDLE_FROZEN=true bundle install`, `bundle lock` exited 0 and left the + lock byte-identical (sha256 `3086e757...` unchanged across all three). +- Committable guarantee: cold `BUNDLE_FROZEN=true bundle install` on `after/` (committed files + only) exits 0 and `require "rack"` loads `/app/vendored/rack-3.1.8/lib/rack.rb` whose first + line is the patch marker. Path gems are used **in place** (never copied to vendor/bundle) and + are **never checksum-verified** — the format has no artifact to hash. + +### stale-checksum-v1-bug/ (G4) +- `before/`: same project as path-with-checksums/after but the lock (hand-edited, simulating the + v1 emitter) keeps the REGISTRY sha256 token on the path gem's CHECKSUMS line. +- `after/`: what `bundle lock` writes given that lock — **byte-identical**; the stale token is + silently preserved. +- Findings: `bundle install`, `BUNDLE_FROZEN=true bundle install`, and `bundle lock` all exit 0 + and never touch or verify the stale token (Bundler skips checksum verification for PATH + sources entirely). The v1 lock is therefore *latently* divergent: deleting the lock and + re-running `bundle lock` produces the bare ` rack (3.1.8)` form, i.e. permanent diff churn + vs. anything Bundler would emit, but no loud failure on Bundler 2.7.2. +- Negative control proving CHECKSUMS enforcement is live for registry gems: corrupting the + sha256 of registry-sourced rack fails cold `bundle install` with exit 37: + `Bundler found mismatched checksums. This is a potential security risk.` (caught against the + rubygems.org API at metadata time, before download). + +### bare-checksum-registry-gem/ (reverse probe — pins the rollback emitter) +- `before/`: registry-sourced lock whose CHECKSUMS entry was stripped to the bare + ` rack (3.1.8)` form (no token). +- `after/`: lock as rewritten by plain `bundle install` — Bundler fills the sha256 back in + (byte-identical to registry-with-checksums/after/Gemfile.lock). +- `BUNDLE_FROZEN=true bundle install` on `before/` **fails, exit 16**: + + ``` + Your lockfile has an empty CHECKSUMS entry for "rack", but can't be updated + because frozen mode is set + ``` + + So: bare entry is *required* for path gems but *breaks frozen installs* for registry gems — + rollback must restore the registry sha256 token, and the patch emitter must strip it. + +## G5: platform suffixes +Pure-ruby rack gets exactly one CHECKSUMS line with **no platform suffix** even though +PLATFORMS lists `aarch64-linux` + `ruby`. The suffix *does* exist for native gems: locking +`ffi 1.17.2` yields one line per platform spec, e.g. +` ffi (1.17.2-aarch64-linux-gnu) sha256=...` alongside the bare ` ffi (1.17.2) sha256=...` +— the `(version-platform)` token mirrors the GEM specs entries exactly. diff --git a/spikes/gem-checksums/bare-checksum-registry-gem/after/.bundle/config b/spikes/gem-checksums/bare-checksum-registry-gem/after/.bundle/config new file mode 100644 index 0000000..6eb400d --- /dev/null +++ b/spikes/gem-checksums/bare-checksum-registry-gem/after/.bundle/config @@ -0,0 +1,3 @@ +--- +BUNDLE_PATH: "vendor/bundle" +BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile b/spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile new file mode 100644 index 0000000..864c947 --- /dev/null +++ b/spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rack", "3.1.8" diff --git a/spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile.lock b/spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile.lock new file mode 100644 index 0000000..7898b2f --- /dev/null +++ b/spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile.lock @@ -0,0 +1,17 @@ +GEM + remote: https://rubygems.org/ + specs: + rack (3.1.8) + +PLATFORMS + aarch64-linux + ruby + +DEPENDENCIES + rack (= 3.1.8) + +CHECKSUMS + rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 + +BUNDLED WITH + 2.7.2 diff --git a/spikes/gem-checksums/bare-checksum-registry-gem/before/.bundle/config b/spikes/gem-checksums/bare-checksum-registry-gem/before/.bundle/config new file mode 100644 index 0000000..6eb400d --- /dev/null +++ b/spikes/gem-checksums/bare-checksum-registry-gem/before/.bundle/config @@ -0,0 +1,3 @@ +--- +BUNDLE_PATH: "vendor/bundle" +BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile b/spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile new file mode 100644 index 0000000..864c947 --- /dev/null +++ b/spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rack", "3.1.8" diff --git a/spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile.lock b/spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile.lock new file mode 100644 index 0000000..9a599c6 --- /dev/null +++ b/spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile.lock @@ -0,0 +1,17 @@ +GEM + remote: https://rubygems.org/ + specs: + rack (3.1.8) + +PLATFORMS + aarch64-linux + ruby + +DEPENDENCIES + rack (= 3.1.8) + +CHECKSUMS + rack (3.1.8) + +BUNDLED WITH + 2.7.2 diff --git a/spikes/gem-checksums/path-with-checksums/after/.bundle/config b/spikes/gem-checksums/path-with-checksums/after/.bundle/config new file mode 100644 index 0000000..6eb400d --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/.bundle/config @@ -0,0 +1,3 @@ +--- +BUNDLE_PATH: "vendor/bundle" +BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/path-with-checksums/after/Gemfile b/spikes/gem-checksums/path-with-checksums/after/Gemfile new file mode 100644 index 0000000..6d26ec6 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rack", "3.1.8", path: "./vendored/rack-3.1.8" diff --git a/spikes/gem-checksums/path-with-checksums/after/Gemfile.lock b/spikes/gem-checksums/path-with-checksums/after/Gemfile.lock new file mode 100644 index 0000000..98b0124 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/Gemfile.lock @@ -0,0 +1,21 @@ +PATH + remote: vendored/rack-3.1.8 + specs: + rack (3.1.8) + +GEM + remote: https://rubygems.org/ + specs: + +PLATFORMS + aarch64-linux + ruby + +DEPENDENCIES + rack (= 3.1.8)! + +CHECKSUMS + rack (3.1.8) + +BUNDLED WITH + 2.7.2 diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CHANGELOG.md b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CHANGELOG.md new file mode 100644 index 0000000..18069d3 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CHANGELOG.md @@ -0,0 +1,998 @@ +# Changelog + +All notable changes to this project will be documented in this file. For info on how to format all future additions to this file please reference [Keep A Changelog](https://keepachangelog.com/en/1.0.0/). + +## [3.1.8] - 2024-10-14 + +- Resolve deprecation warnings about uri `DEFAULT_PARSER`. ([#2249](https://github.com/rack/rack/pull/2249), [@earlopain]) + +## [3.1.7] - 2024-07-11 + +### Fixed + +- Do not remove escaped opening/closing quotes for content-disposition filenames. ([#2229](https://github.com/rack/rack/pull/2229), [@jeremyevans]) +- Fix encoding setting for non-binary IO-like objects in MockRequest#env_for. ([#2227](https://github.com/rack/rack/pull/2227), [@jeremyevans]) +- `Rack::Response` should not generate invalid `content-length` header. ([#2219](https://github.com/rack/rack/pull/2219), [@ioquatix]) +- Allow empty PATH_INFO. ([#2214](https://github.com/rack/rack/pull/2214), [@ioquatix]) + +## [3.1.6] - 2024-07-03 + +### Fixed + +- Fix several edge cases in `Rack::Request#parse_http_accept_header`'s implementation. ([#2226](https://github.com/rack/rack/pull/2226), [@ioquatix]) + +## [3.1.5] - 2024-07-02 + +### Security + +- Fix potential ReDoS attack in `Rack::Request#parse_http_accept_header`. ([GHSA-cj83-2ww7-mvq7](https://github.com/rack/rack/security/advisories/GHSA-cj83-2ww7-mvq7), [@dwisiswant0](https://github.com/dwisiswant0)) + +## [3.1.4] - 2024-06-22 + +### Fixed + +- Fix `Rack::Lint` matching some paths incorrectly as authority form. ([#2220](https://github.com/rack/rack/pull/2220), [@ioquatix]) + +## [3.1.3] - 2024-06-12 + +### Fixed + +- Fix passing non-strings to `Rack::Utils.escape_html`. ([#2202](https://github.com/rack/rack/pull/2202), [@earlopain]) +- `Rack::MockResponse` gracefully handles empty cookies ([#2203](https://github.com/rack/rack/pull/2203) [@wynksaiddestroy]) + +## [3.1.2] - 2024-06-11 + +- `Rack::Response` will take in to consideration chunked encoding responses ([#2204](https://github.com/rack/rack/pull/2204), [@tenderlove]) + +## [3.1.1] - 2024-06-11 + +- Oops! I shouldn't have shipped that + +## [3.1.0] - 2024-06-11 + +:warning: **This release includes several breaking changes.** Refer to the **Removed** section below for the list of deprecated methods that have been removed in this release. + +Rack v3.1 is primarily a maintenance release that removes features deprecated in Rack v3.0. Alongside these removals, there are several improvements to the Rack SPEC, mainly focused on enhancing input and output handling. These changes aim to make Rack more efficient and align better with the requirements of server implementations and relevant HTTP specifications. + +### SPEC Changes + +- `rack.input` is now optional. ([#1997](https://github.com/rack/rack/pull/1997), [#2018](https://github.com/rack/rack/pull/2018), [@ioquatix]) +- `PATH_INFO` is now validated according to the HTTP/1.1 specification. ([#2117](https://github.com/rack/rack/pull/2117), [#2181](https://github.com/rack/rack/pull/2181), [@ioquatix]) + - `OPTIONS *` is now accepted. ([#2114](https://github.com/rack/rack/pull/2114), [@doriantaylor](https://github.com/doriantaylor)) +- Introduce optional `rack.protocol` request and response header for handling connection upgrades. ([#1954](https://github.com/rack/rack/pull/1954), [@ioquatix]) + +### Added + +- Introduce `Rack::Multipart::MissingInputError` for improved handling of missing input in `#parse_multipart`. ([#2018](https://github.com/rack/rack/pull/2018), [@ioquatix]) +- Introduce `module Rack::BadRequest` which is included in multipart and query parser errors. ([#2019](https://github.com/rack/rack/pull/2019), [@ioquatix]) +- Add `.mjs` MIME type ([#2057](https://github.com/rack/rack/pull/2057), [@axilleas](https://github.com/axilleas)) +- `set_cookie_header` utility now supports the `partitioned` cookie attribute. This is required by Chrome in some embedded contexts. ([#2131](https://github.com/rack/rack/pull/2131), [@flavio-b](https://github.com/flavio-b)) +- Introduce `rack.early_hints` for sending `103 Early Hints` informational responses. ([#1831](https://github.com/rack/rack/pull/1831), [@casperisfine](https://github.com/casperisfine), [@jeremyevans]) + +### Changed + +- MIME type for JavaScript files (`.js`) changed from `application/javascript` to `text/javascript` ([`1bd0f15`](https://github.com/rack/rack/commit/1bd0f1597d8f4a90d47115f3e156a8ce7870c9c8), [@ioquatix]) +- Update MIME types associated to `.ttf`, `.woff`, `.woff2` and `.otf` extensions to use mondern `font/*` types. ([#2065](https://github.com/rack/rack/pull/2065), [@davidstosik]) +- `Rack::Utils.escape_html` is now delegated to `CGI.escapeHTML`. `'` is escaped to `#39;` instead of `#x27;`. (decimal vs hexadecimal) ([#2099](https://github.com/rack/rack/pull/2099), [@JunichiIto](https://github.com/JunichiIto)) +- Clarify use of `@buffered` and only update `content-length` when `Rack::Response#finish` is invoked. ([#2149](https://github.com/rack/rack/pull/2149), [@ioquatix]) + +### Deprecated + +- Deprecate automatic cache invalidation in `Request#{GET,POST}` ([#2073](https://github.com/rack/rack/pull/2073), [@jeremyevans]) +- Only cookie keys that are not valid according to the HTTP specifications are escaped. We are planning to deprecate this behaviour, so now a deprecation message will be emitted in this case. In the future, invalid cookie keys may not be accepted. ([#2191](https://github.com/rack/rack/pull/2191), [@ioquatix]) +- `Rack::Logger` is deprecated. ([#2197](https://github.com/rack/rack/pull/2197), [@ioquatix]) +- Add fallback lookup and deprecation warning for obsolete status symbols. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn](https://github.com/wtn)) +- Deprecate `Rack::Request#values_at`, use `request.params.values_at` instead ([#2183](https://github.com/rack/rack/pull/2183), [@ioquatix]) + +### Removed + +- Remove deprecated `Rack::Auth::Digest` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::Cascade::NotFound` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::Chunked` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::File`, use `Rack::Files` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::QueryParser` `key_space_limit` parameter with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::Response#header`, use `Rack::Response#headers` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated cookie methods from `Rack::Utils`: `add_cookie_to_header`, `make_delete_cookie_header`, `add_remove_cookie_to_header`. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::Utils::HeaderHash`. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::VERSION`, `Rack::VERSION_STRING`, `Rack.version`, use `Rack.release` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove non-standard status codes 306, 509, & 510 and update descriptions for 413, 422, & 451. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn](https://github.com/wtn)) +- Remove any dependency on `transfer-encoding: chunked`. ([#2195](https://github.com/rack/rack/pull/2195), [@ioquatix]) +- Remove deprecated `Rack::Request#[]`, use `request.params[key]` instead ([#2183](https://github.com/rack/rack/pull/2183), [@ioquatix]) + +### Fixed + +- In `Rack::Files`, ignore the `Range` header if served file is 0 bytes. ([#2159](https://github.com/rack/rack/pull/2159), [@zarqman]) + +## [3.0.11] - 2024-05-10 + +- Backport #2062 to 3-0-stable: Do not allow `BodyProxy` to respond to `to_str`, make `to_ary` call close . ([#2062](https://github.com/rack/rack/pull/2062), [@jeremyevans](https://github.com/jeremyevans)) + +## [3.0.10] - 2024-03-21 + +- Backport #2104 to 3-0-stable: Return empty when parsing a multi-part POST with only one end delimiter. ([#2164](https://github.com/rack/rack/pull/2164), [@JoeDupuis](https://github.com/JoeDupuis)) + +## [3.0.9.1] - 2024-02-21 + +### Security + +* [CVE-2024-26146] Fixed ReDoS in Accept header parsing +* [CVE-2024-25126] Fixed ReDoS in Content Type header parsing +* [CVE-2024-26141] Reject Range headers which are too large + +[CVE-2024-26146]: https://github.com/advisories/GHSA-54rr-7fvw-6x8f +[CVE-2024-25126]: https://github.com/advisories/GHSA-22f2-v57c-j9cx +[CVE-2024-26141]: https://github.com/advisories/GHSA-xj5v-6v4g-jfw6 + +## [3.0.9] - 2024-01-31 + +- Fix incorrect content-length header that was emitted when `Rack::Response#write` was used in some situations. ([#2150](https://github.com/rack/rack/pull/2150), [@mattbrictson](https://github.com/mattbrictson)) + +## [3.0.8] - 2023-06-14 + +- Fix some unused variable verbose warnings. ([#2084](https://github.com/rack/rack/pull/2084), [@jeremyevans], [@skipkayhil](https://github.com/skipkayhil)) + +## [3.0.7] - 2023-03-16 + +- Make query parameters without `=` have `nil` values. ([#2059](https://github.com/rack/rack/pull/2059), [@jeremyevans]) + +## [3.0.6.1] - 2023-03-13 + +### Security + +- [CVE-2023-27539] Avoid ReDoS in header parsing + +## [3.0.6] - 2023-03-13 + +- Add `QueryParser#missing_value` for handling missing values + tests. ([#2052](https://github.com/rack/rack/pull/2052), [@ioquatix]) + +## [3.0.5] - 2023-03-13 + +- Split form/query parsing into two steps. ([#2038](https://github.com/rack/rack/pull/2038), [@matthewd](https://github.com/matthewd)) + +## [3.0.4.2] - 2023-03-02 + +### Security + +- [CVE-2023-27530] Introduce multipart_total_part_limit to limit total parts + +## [3.0.4.1] - 2023-01-17 + +### Security + +- [CVE-2022-44571] Fix ReDoS vulnerability in multipart parser +- [CVE-2022-44570] Fix ReDoS in Rack::Utils.get_byte_ranges +- [CVE-2022-44572] Forbid control characters in attributes (also ReDoS) + +## [3.0.4] - 2023-01-17 + +- `Rack::Request#POST` should consistently raise errors. Cache errors that occur when invoking `Rack::Request#POST` so they can be raised again later. ([#2010](https://github.com/rack/rack/pull/2010), [@ioquatix]) +- Fix `Rack::Lint` error message for `HTTP_CONTENT_TYPE` and `HTTP_CONTENT_LENGTH`. ([#2007](https://github.com/rack/rack/pull/2007), [@byroot](https://github.com/byroot)) +- Extend `Rack::MethodOverride` to handle `QueryParser::ParamsTooDeepError` error. ([#2006](https://github.com/rack/rack/pull/2006), [@byroot](https://github.com/byroot)) + +## [3.0.3] - 2022-12-27 + +### Fixed + +- `Rack::URLMap` uses non-deprecated form of `Regexp.new`. ([#1998](https://github.com/rack/rack/pull/1998), [@weizheheng](https://github.com/weizheheng)) + +## [3.0.2] - 2022-12-05 + +### Fixed + +- `Utils.build_nested_query` URL-encodes nested field names including the square brackets. +- Allow `Rack::Response` to pass through streaming bodies. ([#1993](https://github.com/rack/rack/pull/1993), [@ioquatix]) + +## [3.0.1] - 2022-11-18 + +### Fixed + +- `MethodOverride` does not look for an override if a request does not include form/parseable data. +- `Rack::Lint::Wrapper` correctly handles `respond_to?` with `to_ary`, `each`, `call` and `to_path`, forwarding to the body. ([#1981](https://github.com/rack/rack/pull/1981), [@ioquatix]) + +## [3.0.0] - 2022-09-06 + +- No changes + +## [3.0.0.rc1] - 2022-09-04 + +### SPEC Changes + +- Stream argument must implement `<<` https://github.com/rack/rack/pull/1959 +- `close` may be called on `rack.input` https://github.com/rack/rack/pull/1956 +- `rack.response_finished` may be used for executing code after the response has been finished https://github.com/rack/rack/pull/1952 + +## [3.0.0.beta1] - 2022-08-08 + +### Security + +- Do not use semicolon as GET parameter separator. ([#1733](https://github.com/rack/rack/pull/1733), [@jeremyevans]) + +### SPEC Changes + +- Response array must now be non-frozen. +- Response `status` must now be an integer greater than or equal to 100. +- Response `headers` must now be an unfrozen hash. +- Response header keys can no longer include uppercase characters. +- Response header values can be an `Array` to handle multiple values (and no longer supports `\n` encoded headers). +- Response body can now respond to `#call` (streaming body) instead of `#each` (enumerable body), for the equivalent of response hijacking in previous versions. +- Middleware must no longer call `#each` on the body, but they can call `#to_ary` on the body if it responds to `#to_ary`. +- `rack.input` is no longer required to be rewindable. +- `rack.multithread`/`rack.multiprocess`/`rack.run_once`/`rack.version` are no longer required environment keys. +- `SERVER_PROTOCOL` is now a required environment key, matching the HTTP protocol used in the request. +- `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. +- `rack.hijack_io` has been removed completely. +- `rack.response_finished` is an optional environment key which contains an array of callable objects that must accept `#call(env, status, headers, error)` and are invoked after the response is finished (either successfully or unsuccessfully). +- It is okay to call `#close` on `rack.input` to indicate that you no longer need or care about the input. +- The stream argument supplied to the streaming body and hijack must support `#<<` for writing output. + +### Removed + +- Remove `rack.multithread`/`rack.multiprocess`/`rack.run_once`. These variables generally come too late to be useful. ([#1720](https://github.com/rack/rack/pull/1720), [@ioquatix], [@jeremyevans])) +- Remove deprecated Rack::Request::SCHEME_WHITELIST. ([@jeremyevans]) +- Remove internal cookie deletion using pattern matching, there are very few practical cases where it would be useful and browsers handle it correctly without us doing anything special. ([#1844](https://github.com/rack/rack/pull/1844), [@ioquatix]) +- Remove `rack.version` as it comes too late to be useful. ([#1938](https://github.com/rack/rack/pull/1938), [@ioquatix]) +- Extract `rackup` command, `Rack::Server`, `Rack::Handler`, `Rack::Lobster` and related code into a separate gem. ([#1937](https://github.com/rack/rack/pull/1937), [@ioquatix]) + +### Added + +- `Rack::Headers` added to support lower-case header keys. ([@jeremyevans]) +- `Rack::Utils#set_cookie_header` now supports `escape_key: false` to avoid key escaping. ([@jeremyevans]) +- `Rack::RewindableInput` supports size. ([@ahorek](https://github.com/ahorek)) +- `Rack::RewindableInput::Middleware` added for making `rack.input` rewindable. ([@jeremyevans]) +- The RFC 7239 Forwarded header is now supported and considered by default when looking for information on forwarding, falling back to the X-Forwarded-* headers. `Rack::Request.forwarded_priority` accessor has been added for configuring the priority of which header to check. ([#1423](https://github.com/rack/rack/issues/1423), [@jeremyevans]) +- Allow response headers to contain array of values. ([#1598](https://github.com/rack/rack/issues/1598), [@ioquatix]) +- Support callable body for explicit streaming support and clarify streaming response body behaviour. ([#1745](https://github.com/rack/rack/pull/1745), [@ioquatix], [#1748](https://github.com/rack/rack/pull/1748), [@wjordan]) +- Allow `Rack::Builder#run` to take a block instead of an argument. ([#1942](https://github.com/rack/rack/pull/1942), [@ioquatix]) +- Add `rack.response_finished` to `Rack::Lint`. ([#1802](https://github.com/rack/rack/pull/1802), [@BlakeWilliams], [#1952](https://github.com/rack/rack/pull/1952), [@ioquatix]) +- The stream argument must implement `#<<`. ([#1959](https://github.com/rack/rack/pull/1959), [@ioquatix]) + +### Changed + +- BREAKING CHANGE: Require `status` to be an Integer. ([#1662](https://github.com/rack/rack/pull/1662), [@olleolleolle](https://github.com/olleolleolle)) +- BREAKING CHANGE: Query parsing now treats parameters without `=` as having the empty string value instead of nil value, to conform to the URL spec. ([#1696](https://github.com/rack/rack/issues/1696), [@jeremyevans]) +- Relax validations around `Rack::Request#host` and `Rack::Request#hostname`. ([#1606](https://github.com/rack/rack/issues/1606), [@pvande](https://github.com/pvande)) +- Removed antiquated handlers: FCGI, LSWS, SCGI, Thin. ([#1658](https://github.com/rack/rack/pull/1658), [@ioquatix]) +- Removed options from `Rack::Builder.parse_file` and `Rack::Builder.load_file`. ([#1663](https://github.com/rack/rack/pull/1663), [@ioquatix]) +- `Rack::HTTP_VERSION` has been removed and the `HTTP_VERSION` env setting is no longer set in the CGI and Webrick handlers. ([#970](https://github.com/rack/rack/issues/970), [@jeremyevans]) +- `Rack::Request#[]` and `#[]=` now warn even in non-verbose mode. ([#1277](https://github.com/rack/rack/issues/1277), [@jeremyevans]) +- Decrease default allowed parameter recursion level from 100 to 32. ([#1640](https://github.com/rack/rack/issues/1640), [@jeremyevans]) +- Attempting to parse a multipart response with an empty body now raises Rack::Multipart::EmptyContentError. ([#1603](https://github.com/rack/rack/issues/1603), [@jeremyevans]) +- `Rack::Utils.secure_compare` uses OpenSSL's faster implementation if available. ([#1711](https://github.com/rack/rack/pull/1711), [@bdewater](https://github.com/bdewater)) +- `Rack::Request#POST` now caches an empty hash if input content type is not parseable. ([#749](https://github.com/rack/rack/pull/749), [@jeremyevans]) +- BREAKING CHANGE: Updated `trusted_proxy?` to match full 127.0.0.0/8 network. ([#1781](https://github.com/rack/rack/pull/1781), [@snbloch](https://github.com/snbloch)) +- Explicitly deprecate `Rack::File` which was an alias for `Rack::Files`. ([#1811](https://github.com/rack/rack/pull/1720), [@ioquatix]). +- Moved `Rack::Session` into [separate gem](https://github.com/rack/rack-session). ([#1805](https://github.com/rack/rack/pull/1805), [@ioquatix]) +- `rackup -D` option to daemonizes no longer changes the working directory to the root. ([#1813](https://github.com/rack/rack/pull/1813), [@jeremyevans]) +- The `x-forwarded-proto` header is now considered before the `x-forwarded-scheme` header for determining the forwarded protocol. `Rack::Request.x_forwarded_proto_priority` accessor has been added for configuring the priority of which header to check. ([#1809](https://github.com/rack/rack/issues/1809), [@jeremyevans]) +- `Rack::Request.forwarded_authority` (and methods that call it, such as `host`) now returns the last authority in the forwarded header, instead of the first, as earlier forwarded authorities can be forged by clients. This restores the Rack 2.1 behavior. ([#1829](https://github.com/rack/rack/issues/1809), [@jeremyevans]) +- Use lower case cookie attributes when creating cookies, and fold cookie attributes to lower case when reading cookies (specifically impacting `secure` and `httponly` attributes). ([#1849](https://github.com/rack/rack/pull/1849), [@ioquatix]) +- The response array must now be mutable (non-frozen) so middleware can modify it without allocating a new Array,therefore reducing object allocations. ([#1887](https://github.com/rack/rack/pull/1887), [#1927](https://github.com/rack/rack/pull/1927), [@amatsuda], [@ioquatix]) +- `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. `rack.hijack_io` is no longer required/specified. ([#1939](https://github.com/rack/rack/pull/1939), [@ioquatix]) +- Allow calling close on `rack.input`. ([#1956](https://github.com/rack/rack/pull/1956), [@ioquatix]) + +### Fixed + +- Make Rack::MockResponse handle non-hash headers. ([#1629](https://github.com/rack/rack/issues/1629), [@jeremyevans]) +- TempfileReaper now deletes temp files if application raises an exception. ([#1679](https://github.com/rack/rack/issues/1679), [@jeremyevans]) +- Handle cookies with values that end in '=' ([#1645](https://github.com/rack/rack/pull/1645), [@lukaso](https://github.com/lukaso)) +- Make `Rack::NullLogger` respond to `#fatal!` [@jeremyevans]) +- Fix multipart filename generation for filenames that contain spaces. Encode spaces as "%20" instead of "+" which will be decoded properly by the multipart parser. ([#1736](https://github.com/rack/rack/pull/1645), [@muirdm](https://github.com/muirdm)) +- `Rack::Request#scheme` returns `ws` or `wss` when one of the `X-Forwarded-Scheme` / `X-Forwarded-Proto` headers is set to `ws` or `wss`, respectively. ([#1730](https://github.com/rack/rack/issues/1730), [@erwanst](https://github.com/erwanst)) + +## [2.2.4] - 2022-06-30 + +- Better support for lower case headers in `Rack::ETag` middleware. ([#1919](https://github.com/rack/rack/pull/1919), [@ioquatix](https://github.com/ioquatix)) +- Use custom exception on params too deep error. ([#1838](https://github.com/rack/rack/pull/1838), [@simi](https://github.com/simi)) + +## [2.2.3.1] - 2022-05-27 + +### Security + +- [CVE-2022-30123] Fix shell escaping issue in Common Logger +- [CVE-2022-30122] Restrict parsing of broken MIME attachments + +## [2.2.3] - 2020-06-15 + +### Security + +- [[CVE-2020-8184](https://nvd.nist.gov/vuln/detail/CVE-2020-8184)] Do not allow percent-encoded cookie name to override existing cookie names. BREAKING CHANGE: Accessing cookie names that require URL encoding with decoded name no longer works. ([@fletchto99](https://github.com/fletchto99)) + +## [2.2.2] - 2020-02-11 + +### Fixed + +- Fix incorrect `Rack::Request#host` value. ([#1591](https://github.com/rack/rack/pull/1591), [@ioquatix]) +- Revert `Rack::Handler::Thin` implementation. ([#1583](https://github.com/rack/rack/pull/1583), [@jeremyevans]) +- Double assignment is still needed to prevent an "unused variable" warning. ([#1589](https://github.com/rack/rack/pull/1589), [@kamipo](https://github.com/kamipo)) +- Fix to handle same_site option for session pool. ([#1587](https://github.com/rack/rack/pull/1587), [@kamipo](https://github.com/kamipo)) + +## [2.2.1] - 2020-02-09 + +### Fixed + +- Rework `Rack::Request#ip` to handle empty `forwarded_for`. ([#1577](https://github.com/rack/rack/pull/1577), [@ioquatix]) + +## [2.2.0] - 2020-02-08 + +### SPEC Changes + +- `rack.session` request environment entry must respond to `to_hash` and return unfrozen Hash. ([@jeremyevans]) +- Request environment cannot be frozen. ([@jeremyevans]) +- CGI values in the request environment with non-ASCII characters must use ASCII-8BIT encoding. ([@jeremyevans]) +- Improve SPEC/lint relating to SERVER_NAME, SERVER_PORT and HTTP_HOST. ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix]) + +### Added + +- `rackup` supports multiple `-r` options and will require all arguments. ([@jeremyevans]) +- `Server` supports an array of paths to require for the `:require` option. ([@khotta](https://github.com/khotta)) +- `Files` supports multipart range requests. ([@fatkodima](https://github.com/fatkodima)) +- `Multipart::UploadedFile` supports an IO-like object instead of using the filesystem, using `:filename` and `:io` options. ([@jeremyevans]) +- `Multipart::UploadedFile` supports keyword arguments `:path`, `:content_type`, and `:binary` in addition to positional arguments. ([@jeremyevans]) +- `Static` supports a `:cascade` option for calling the app if there is no matching file. ([@jeremyevans]) +- `Session::Abstract::SessionHash#dig`. ([@jeremyevans]) +- `Response.[]` and `MockResponse.[]` for creating instances using status, headers, and body. ([@ioquatix]) +- Convenient cache and content type methods for `Rack::Response`. ([#1555](https://github.com/rack/rack/pull/1555), [@ioquatix]) + +### Changed + +- `Request#params` no longer rescues EOFError. ([@jeremyevans]) +- `Directory` uses a streaming approach, significantly improving time to first byte for large directories. ([@jeremyevans]) +- `Directory` no longer includes a Parent directory link in the root directory index. ([@jeremyevans]) +- `QueryParser#parse_nested_query` uses original backtrace when reraising exception with new class. ([@jeremyevans]) +- `ConditionalGet` follows RFC 7232 precedence if both If-None-Match and If-Modified-Since headers are provided. ([@jeremyevans]) +- `.ru` files supports the `frozen-string-literal` magic comment. ([@eregon](https://github.com/eregon)) +- Rely on autoload to load constants instead of requiring internal files, make sure to require 'rack' and not just 'rack/...'. ([@jeremyevans]) +- BREAKING CHANGE: `Etag` will continue sending ETag even if the response should not be cached. Streaming no longer works without a workaround, see [#1619](https://github.com/rack/rack/issues/1619#issuecomment-848460528). ([@henm](https://github.com/henm)) +- `Request#host_with_port` no longer includes a colon for a missing or empty port. ([@AlexWayfer](https://github.com/AlexWayfer)) +- All handlers uses keywords arguments instead of an options hash argument. ([@ioquatix]) +- `Files` handling of range requests no longer return a body that supports `to_path`, to ensure range requests are handled correctly. ([@jeremyevans]) +- `Multipart::Generator` only includes `Content-Length` for files with paths, and `Content-Disposition` `filename` if the `UploadedFile` instance has one. ([@jeremyevans]) +- `Request#ssl?` is true for the `wss` scheme (secure websockets). ([@jeremyevans]) +- `Rack::HeaderHash` is memoized by default. ([#1549](https://github.com/rack/rack/pull/1549), [@ioquatix]) +- `Rack::Directory` allow directory traversal inside root directory. ([#1417](https://github.com/rack/rack/pull/1417), [@ThomasSevestre](https://github.com/ThomasSevestre)) +- Sort encodings by server preference. ([#1184](https://github.com/rack/rack/pull/1184), [@ioquatix], [@wjordan](https://github.com/wjordan)) +- Rework host/hostname/authority implementation in `Rack::Request`. `#host` and `#host_with_port` have been changed to correctly return IPv6 addresses formatted with square brackets, as defined by [RFC3986](https://tools.ietf.org/html/rfc3986#section-3.2.2). ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix]) +- `Rack::Builder` parsing options on first `#\` line is deprecated. ([#1574](https://github.com/rack/rack/pull/1574), [@ioquatix]) + +### Removed + +- `Directory#path` as it was not used and always returned nil. ([@jeremyevans]) +- `BodyProxy#each` as it was only needed to work around a bug in Ruby <1.9.3. ([@jeremyevans]) +- `URLMap::INFINITY` and `URLMap::NEGATIVE_INFINITY`, in favor of `Float::INFINITY`. ([@ch1c0t](https://github.com/ch1c0t)) +- Deprecation of `Rack::File`. It will be deprecated again in rack 2.2 or 3.0. ([@rafaelfranca](https://github.com/rafaelfranca)) +- Support for Ruby 2.2 as it is well past EOL. ([@ioquatix]) +- Remove `Rack::Files#response_body` as the implementation was broken. ([#1153](https://github.com/rack/rack/pull/1153), [@ioquatix]) +- Remove `SERVER_ADDR` which was never part of the original SPEC. ([#1573](https://github.com/rack/rack/pull/1573), [@ioquatix]) + +### Fixed + +- `Directory` correctly handles root paths containing glob metacharacters. ([@jeremyevans]) +- `Cascade` uses a new response object for each call if initialized with no apps. ([@jeremyevans]) +- `BodyProxy` correctly delegates keyword arguments to the body object on Ruby 2.7+. ([@jeremyevans]) +- `BodyProxy#method` correctly handles methods delegated to the body object. ([@jeremyevans]) +- `Request#host` and `Request#host_with_port` handle IPv6 addresses correctly. ([@AlexWayfer](https://github.com/AlexWayfer)) +- `Lint` checks when response hijacking that `rack.hijack` is called with a valid object. ([@jeremyevans]) +- `Response#write` correctly updates `Content-Length` if initialized with a body. ([@jeremyevans]) +- `CommonLogger` includes `SCRIPT_NAME` when logging. ([@Erol](https://github.com/Erol)) +- `Utils.parse_nested_query` correctly handles empty queries, using an empty instance of the params class instead of a hash. ([@jeremyevans]) +- `Directory` correctly escapes paths in links. ([@yous](https://github.com/yous)) +- `Request#delete_cookie` and related `Utils` methods handle `:domain` and `:path` options in same call. ([@jeremyevans]) +- `Request#delete_cookie` and related `Utils` methods do an exact match on `:domain` and `:path` options. ([@jeremyevans]) +- `Static` no longer adds headers when a gzipped file request has a 304 response. ([@chooh](https://github.com/chooh)) +- `ContentLength` sets `Content-Length` response header even for bodies not responding to `to_ary`. ([@jeremyevans]) +- Thin handler supports options passed directly to `Thin::Controllers::Controller`. ([@jeremyevans]) +- WEBrick handler no longer ignores `:BindAddress` option. ([@jeremyevans]) +- `ShowExceptions` handles invalid POST data. ([@jeremyevans]) +- Basic authentication requires a password, even if the password is empty. ([@jeremyevans]) +- `Lint` checks response is array with 3 elements, per SPEC. ([@jeremyevans]) +- Support for using `:SSLEnable` option when using WEBrick handler. (Gregor Melhorn) +- Close response body after buffering it when buffering. ([@ioquatix]) +- Only accept `;` as delimiter when parsing cookies. ([@mrageh](https://github.com/mrageh)) +- `Utils::HeaderHash#clear` clears the name mapping as well. ([@raxoft](https://github.com/raxoft)) +- Support for passing `nil` `Rack::Files.new`, which notably fixes Rails' current `ActiveStorage::FileServer` implementation. ([@ioquatix]) + +### Documentation + +- CHANGELOG updates. ([@aupajo](https://github.com/aupajo)) +- Added [CONTRIBUTING](CONTRIBUTING.md). ([@dblock](https://github.com/dblock)) + +## [2.0.9] - 2020-02-08 + +- Handle case where session id key is requested but missing ([@jeremyevans]) +- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans]) +- Add support for `SameSite=None` cookie value. ([@hennikul](https://github.com/hennikul)) + +## [2.1.2] - 2020-01-27 + +- Fix multipart parser for some files to prevent denial of service ([@aiomaster](https://github.com/aiomaster)) +- Fix `Rack::Builder#use` with keyword arguments ([@kamipo](https://github.com/kamipo)) +- Skip deflating in Rack::Deflater if Content-Length is 0 ([@jeremyevans]) +- Remove `SessionHash#transform_keys`, no longer needed ([@pavel](https://github.com/pavel)) +- Add to_hash to wrap Hash and Session classes ([@oleh-demyanyuk](https://github.com/oleh-demyanyuk)) +- Handle case where session id key is requested but missing ([@jeremyevans]) + +## [2.1.1] - 2020-01-12 + +- Remove `Rack::Chunked` from `Rack::Server` default middleware. ([#1475](https://github.com/rack/rack/pull/1475), [@ioquatix]) +- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans]) + +## [2.1.0] - 2020-01-10 + +### Added + +- Add support for `SameSite=None` cookie value. ([@hennikul](https://github.com/hennikul)) +- Add trailer headers. ([@eileencodes](https://github.com/eileencodes)) +- Add MIME Types for video streaming. ([@styd](https://github.com/styd)) +- Add MIME Type for WASM. ([@buildrtech](https://github.com/buildrtech)) +- Add `Early Hints(103)` to status codes. ([@egtra](https://github.com/egtra)) +- Add `Too Early(425)` to status codes. ([@y-yagi]((https://github.com/y-yagi))) +- Add `Bandwidth Limit Exceeded(509)` to status codes. ([@CJKinni](https://github.com/CJKinni)) +- Add method for custom `ip_filter`. ([@svcastaneda](https://github.com/svcastaneda)) +- Add boot-time profiling capabilities to `rackup`. ([@tenderlove](https://github.com/tenderlove)) +- Add multi mapping support for `X-Accel-Mappings` header. ([@yoshuki](https://github.com/yoshuki)) +- Add `sync: false` option to `Rack::Deflater`. (Eric Wong) +- Add `Builder#freeze_app` to freeze application and all middleware instances. ([@jeremyevans]) +- Add API to extract cookies from `Rack::MockResponse`. ([@petercline](https://github.com/petercline)) + +### Changed + +- Don't propagate nil values from middleware. ([@ioquatix]) +- Lazily initialize the response body and only buffer it if required. ([@ioquatix]) +- Fix deflater zlib buffer errors on empty body part. ([@felixbuenemann](https://github.com/felixbuenemann)) +- Set `X-Accel-Redirect` to percent-encoded path. ([@diskkid](https://github.com/diskkid)) +- Remove unnecessary buffer growing when parsing multipart. ([@tainoe](https://github.com/tainoe)) +- Expand the root path in `Rack::Static` upon initialization. ([@rosenfeld](https://github.com/rosenfeld)) +- Make `ShowExceptions` work with binary data. ([@axyjo](https://github.com/axyjo)) +- Use buffer string when parsing multipart requests. ([@janko-m](https://github.com/janko-m)) +- Support optional UTF-8 Byte Order Mark (BOM) in config.ru. ([@mikegee](https://github.com/mikegee)) +- Handle `X-Forwarded-For` with optional port. ([@dpritchett](https://github.com/dpritchett)) +- Use `Time#httpdate` format for Expires, as proposed by RFC 7231. ([@nanaya](https://github.com/nanaya)) +- Make `Utils.status_code` raise an error when the status symbol is invalid instead of `500`. ([@adambutler](https://github.com/adambutler)) +- Rename `Request::SCHEME_WHITELIST` to `Request::ALLOWED_SCHEMES`. +- Make `Multipart::Parser.get_filename` accept files with `+` in their name. ([@lucaskanashiro](https://github.com/lucaskanashiro)) +- Add Falcon to the default handler fallbacks. ([@ioquatix]) +- Update codebase to avoid string mutations in preparation for `frozen_string_literals`. ([@pat](https://github.com/pat)) +- Change `MockRequest#env_for` to rely on the input optionally responding to `#size` instead of `#length`. ([@janko](https://github.com/janko)) +- Rename `Rack::File` -> `Rack::Files` and add deprecation notice. ([@postmodern](https://github.com/postmodern)) +- Prefer Base64 “strict encoding” for Base64 cookies. ([@ioquatix]) + +### Removed + +- BREAKING CHANGE: Remove `to_ary` from Response ([@tenderlove](https://github.com/tenderlove)) +- Deprecate `Rack::Session::Memcache` in favor of `Rack::Session::Dalli` from dalli gem ([@fatkodima](https://github.com/fatkodima)) + +### Fixed + +- Eliminate warnings for Ruby 2.7. ([@osamtimizer](https://github.com/osamtimizer])) + +### Documentation + +- Update broken example in `Session::Abstract::ID` documentation. ([tonytonyjan](https://github.com/tonytonyjan)) +- Add Padrino to the list of frameworks implementing Rack. ([@wikimatze](https://github.com/wikimatze)) +- Remove Mongrel from the suggested server options in the help output. ([@tricknotes](https://github.com/tricknotes)) +- Replace `HISTORY.md` and `NEWS.md` with `CHANGELOG.md`. ([@twitnithegirl](https://github.com/twitnithegirl)) +- CHANGELOG updates. ([@drenmi](https://github.com/Drenmi), [@p8](https://github.com/p8)) + +## [2.0.8] - 2019-12-08 + +### Security + +- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) + +## [1.6.12] - 2019-12-08 + +### Security + +- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) + +## [2.0.7] - 2019-04-02 + +### Fixed + +- Remove calls to `#eof?` on Rack input in `Multipart::Parser`, as this breaks the specification. ([@matthewd](https://github.com/matthewd)) +- Preserve forwarded IP addresses for trusted proxy chains. ([@SamSaffron](https://github.com/SamSaffron)) + +## [2.0.6] - 2018-11-05 + +### Fixed + +- [[CVE-2018-16470](https://nvd.nist.gov/vuln/detail/CVE-2018-16470)] Reduce buffer size of `Multipart::Parser` to avoid pathological parsing. ([@tenderlove](https://github.com/tenderlove)) +- Fix a call to a non-existing method `#accepts_html` in the `ShowExceptions` middleware. ([@tomelm](https://github.com/tomelm)) +- [[CVE-2018-16471](https://nvd.nist.gov/vuln/detail/CVE-2018-16471)] Whitelist HTTP and HTTPS schemes in `Request#scheme` to prevent a possible XSS attack. ([@PatrickTulskie](https://github.com/PatrickTulskie)) + +## [2.0.5] - 2018-04-23 + +### Fixed + +- Record errors originating from invalid UTF8 in `MethodOverride` middleware instead of breaking. ([@mclark](https://github.com/mclark)) + +## [2.0.4] - 2018-01-31 + +### Changed + +- Ensure the `Lock` middleware passes the original `env` object. ([@lugray](https://github.com/lugray)) +- Improve performance of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) +- Increase buffer size in `Multipart::Parser` for better performance. ([@jkowens](https://github.com/jkowens)) +- Reduce memory usage of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) +- Replace ConcurrentRuby dependency with native `Queue`. ([@devmchakan](https://github.com/devmchakan)) + +### Fixed + +- Require the correct digest algorithm in the `ETag` middleware. ([@matthewd](https://github.com/matthewd)) + +### Documentation + +- Update homepage links to use SSL. ([@hugoabonizio](https://github.com/hugoabonizio)) + +## [2.0.3] - 2017-05-15 + +### Changed + +- Ensure `env` values are ASCII 8-bit encoded. ([@eileencodes](https://github.com/eileencodes)) + +### Fixed + +- Prevent exceptions when a class with mixins inherits from `Session::Abstract::ID`. ([@jnraine](https://github.com/jnraine)) + +## [2.0.2] - 2017-05-08 + +### Added + +- Allow `Session::Abstract::SessionHash#fetch` to accept a block with a default value. ([@yannvanhalewyn](https://github.com/yannvanhalewyn)) +- Add `Builder#freeze_app` to freeze application and all middleware. ([@jeremyevans]) + +### Changed + +- Freeze default session options to avoid accidental mutation. ([@kirs](https://github.com/kirs)) +- Detect partial hijack without hash headers. ([@devmchakan](https://github.com/devmchakan)) +- Update tests to use MiniTest 6 matchers. ([@tonytonyjan](https://github.com/tonytonyjan)) +- Allow 205 Reset Content responses to set a Content-Length, as RFC 7231 proposes setting this to 0. ([@devmchakan](https://github.com/devmchakan)) + +### Fixed + +- Handle `NULL` bytes in multipart filenames. ([@casperisfine](https://github.com/casperisfine)) +- Remove warnings due to miscapitalized global. ([@ioquatix]) +- Prevent exceptions caused by a race condition on multi-threaded servers. ([@sophiedeziel](https://github.com/sophiedeziel)) +- Add RDoc as an explicit dependency for `doc` group. ([@tonytonyjan](https://github.com/tonytonyjan)) +- Record errors originating from `Multipart::Parser` in the `MethodOverride` middleware instead of letting them bubble up. ([@carlzulauf](https://github.com/carlzulauf)) +- Remove remaining use of removed `Utils#bytesize` method from the `File` middleware. ([@brauliomartinezlm](https://github.com/brauliomartinezlm)) + +### Removed + +- Remove `deflate` encoding support to reduce caching overhead. ([@devmchakan](https://github.com/devmchakan)) + +### Documentation + +- Update broken example in `Deflater` documentation. ([@mwpastore](https://github.com/mwpastore)) + +## [2.0.1] - 2016-06-30 + +### Changed + +- Remove JSON as an explicit dependency. ([@mperham](https://github.com/mperham)) + + +# History/News Archive +Items below this line are from the previously maintained HISTORY.md and NEWS.md files. + +## [2.0.0.rc1] 2016-05-06 +- Rack::Session::Abstract::ID is deprecated. Please change to use Rack::Session::Abstract::Persisted + +## [2.0.0.alpha] 2015-12-04 +- First-party "SameSite" cookies. Browsers omit SameSite cookies from third-party requests, closing the door on many CSRF attacks. +- Pass `same_site: true` (or `:strict`) to enable: response.set_cookie 'foo', value: 'bar', same_site: true or `same_site: :lax` to use Lax enforcement: response.set_cookie 'foo', value: 'bar', same_site: :lax +- Based on version 7 of the Same-site Cookies internet draft: + https://tools.ietf.org/html/draft-west-first-party-cookies-07 +- Thanks to Ben Toews (@mastahyeti) and Bob Long (@bobjflong) for updating to drafts 5 and 7. +- Add `Rack::Events` middleware for adding event based middleware: middleware that does not care about the response body, but only cares about doing work at particular points in the request / response lifecycle. +- Add `Rack::Request#authority` to calculate the authority under which the response is being made (this will be handy for h2 pushes). +- Add `Rack::Response::Helpers#cache_control` and `cache_control=`. Use this for setting cache control headers on your response objects. +- Add `Rack::Response::Helpers#etag` and `etag=`. Use this for setting etag values on the response. +- Introduce `Rack::Response::Helpers#add_header` to add a value to a multi-valued response header. Implemented in terms of other `Response#*_header` methods, so it's available to any response-like class that includes the `Helpers` module. +- Add `Rack::Request#add_header` to match. +- `Rack::Session::Abstract::ID` IS DEPRECATED. Please switch to `Rack::Session::Abstract::Persisted`. `Rack::Session::Abstract::Persisted` uses a request object rather than the `env` hash. +- Pull `ENV` access inside the request object in to a module. This will help with legacy Request objects that are ENV based but don't want to inherit from Rack::Request +- Move most methods on the `Rack::Request` to a module `Rack::Request::Helpers` and use public API to get values from the request object. This enables users to mix `Rack::Request::Helpers` in to their own objects so they can implement `(get|set|fetch|each)_header` as they see fit (for example a proxy object). +- Files and directories with + in the name are served correctly. Rather than unescaping paths like a form, we unescape with a URI parser using `Rack::Utils.unescape_path`. Fixes #265 +- Tempfiles are automatically closed in the case that there were too + many posted. +- Added methods for manipulating response headers that don't assume + they're stored as a Hash. Response-like classes may include the + Rack::Response::Helpers module if they define these methods: + - Rack::Response#has_header? + - Rack::Response#get_header + - Rack::Response#set_header + - Rack::Response#delete_header +- Introduce Util.get_byte_ranges that will parse the value of the HTTP_RANGE string passed to it without depending on the `env` hash. `byte_ranges` is deprecated in favor of this method. +- Change Session internals to use Request objects for looking up session information. This allows us to only allocate one request object when dealing with session objects (rather than doing it every time we need to manipulate cookies, etc). +- Add `Rack::Request#initialize_copy` so that the env is duped when the request gets duped. +- Added methods for manipulating request specific data. This includes + data set as CGI parameters, and just any arbitrary data the user wants + to associate with a particular request. New methods: + - Rack::Request#has_header? + - Rack::Request#get_header + - Rack::Request#fetch_header + - Rack::Request#each_header + - Rack::Request#set_header + - Rack::Request#delete_header +- lib/rack/utils.rb: add a method for constructing "delete" cookie + headers. This allows us to construct cookie headers without depending + on the side effects of mutating a hash. +- Prevent extremely deep parameters from being parsed. CVE-2015-3225 + +## [1.6.1] 2015-05-06 + - Fix CVE-2014-9490, denial of service attack in OkJson + - Use a monotonic time for Rack::Runtime, if available + - RACK_MULTIPART_LIMIT changed to RACK_MULTIPART_PART_LIMIT (RACK_MULTIPART_LIMIT is deprecated and will be removed in 1.7.0) + +## [1.5.3] 2015-05-06 + - Fix CVE-2014-9490, denial of service attack in OkJson + - Backport bug fixes to 1.5 series + +## [1.6.0] 2014-01-18 + - Response#unauthorized? helper + - Deflater now accepts an options hash to control compression on a per-request level + - Builder#warmup method for app preloading + - Request#accept_language method to extract HTTP_ACCEPT_LANGUAGE + - Add quiet mode of rack server, rackup --quiet + - Update HTTP Status Codes to RFC 7231 + - Less strict header name validation according to RFC 2616 + - SPEC updated to specify headers conform to RFC7230 specification + - Etag correctly marks etags as weak + - Request#port supports multiple x-http-forwarded-proto values + - Utils#multipart_part_limit configures the maximum number of parts a request can contain + - Default host to localhost when in development mode + - Various bugfixes and performance improvements + +## [1.5.2] 2013-02-07 + - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie + - Fix CVE-2013-0262, symlink path traversal in Rack::File + - Add various methods to Session for enhanced Rails compatibility + - Request#trusted_proxy? now only matches whole strings + - Add JSON cookie coder, to be default in Rack 1.6+ due to security concerns + - URLMap host matching in environments that don't set the Host header fixed + - Fix a race condition that could result in overwritten pidfiles + - Various documentation additions + +## [1.4.5] 2013-02-07 + - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie + - Fix CVE-2013-0262, symlink path traversal in Rack::File + +## [1.1.6, 1.2.8, 1.3.10] 2013-02-07 + - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie + +## [1.5.1] 2013-01-28 + - Rack::Lint check_hijack now conforms to other parts of SPEC + - Added hash-like methods to Abstract::ID::SessionHash for compatibility + - Various documentation corrections + +## [1.5.0] 2013-01-21 + - Introduced hijack SPEC, for before-response and after-response hijacking + - SessionHash is no longer a Hash subclass + - Rack::File cache_control parameter is removed, in place of headers options + - Rack::Auth::AbstractRequest#scheme now yields strings, not symbols + - Rack::Utils cookie functions now format expires in RFC 2822 format + - Rack::File now has a default mime type + - rackup -b 'run Rack::Files.new(".")', option provides command line configs + - Rack::Deflater will no longer double encode bodies + - Rack::Mime#match? provides convenience for Accept header matching + - Rack::Utils#q_values provides splitting for Accept headers + - Rack::Utils#best_q_match provides a helper for Accept headers + - Rack::Handler.pick provides convenience for finding available servers + - Puma added to the list of default servers (preferred over Webrick) + - Various middleware now correctly close body when replacing it + - Rack::Request#params is no longer persistent with only GET params + - Rack::Request#update_param and #delete_param provide persistent operations + - Rack::Request#trusted_proxy? now returns true for local unix sockets + - Rack::Response no longer forces Content-Types + - Rack::Sendfile provides local mapping configuration options + - Rack::Utils#rfc2109 provides old netscape style time output + - Updated HTTP status codes + - Ruby 1.8.6 likely no longer passes tests, and is no longer fully supported + +## [1.4.4, 1.3.9, 1.2.7, 1.1.5] 2013-01-13 + - [SEC] Rack::Auth::AbstractRequest no longer symbolizes arbitrary strings + - Fixed erroneous test case in the 1.3.x series + +## [1.4.3] 2013-01-07 + - Security: Prevent unbounded reads in large multipart boundaries + +## [1.3.8] 2013-01-07 + - Security: Prevent unbounded reads in large multipart boundaries + +## [1.4.2] 2013-01-06 + - Add warnings when users do not provide a session secret + - Fix parsing performance for unquoted filenames + - Updated URI backports + - Fix URI backport version matching, and silence constant warnings + - Correct parameter parsing with empty values + - Correct rackup '-I' flag, to allow multiple uses + - Correct rackup pidfile handling + - Report rackup line numbers correctly + - Fix request loops caused by non-stale nonces with time limits + - Fix reloader on Windows + - Prevent infinite recursions from Response#to_ary + - Various middleware better conforms to the body close specification + - Updated language for the body close specification + - Additional notes regarding ECMA escape compatibility issues + - Fix the parsing of multiple ranges in range headers + - Prevent errors from empty parameter keys + - Added PATCH verb to Rack::Request + - Various documentation updates + - Fix session merge semantics (fixes rack-test) + - Rack::Static :index can now handle multiple directories + - All tests now utilize Rack::Lint (special thanks to Lars Gierth) + - Rack::File cache_control parameter is now deprecated, and removed by 1.5 + - Correct Rack::Directory script name escaping + - Rack::Static supports header rules for sophisticated configurations + - Multipart parsing now works without a Content-Length header + - New logos courtesy of Zachary Scott! + - Rack::BodyProxy now explicitly defines #each, useful for C extensions + - Cookies that are not URI escaped no longer cause exceptions + +## [1.3.7] 2013-01-06 + - Add warnings when users do not provide a session secret + - Fix parsing performance for unquoted filenames + - Updated URI backports + - Fix URI backport version matching, and silence constant warnings + - Correct parameter parsing with empty values + - Correct rackup '-I' flag, to allow multiple uses + - Correct rackup pidfile handling + - Report rackup line numbers correctly + - Fix request loops caused by non-stale nonces with time limits + - Fix reloader on Windows + - Prevent infinite recursions from Response#to_ary + - Various middleware better conforms to the body close specification + - Updated language for the body close specification + - Additional notes regarding ECMA escape compatibility issues + - Fix the parsing of multiple ranges in range headers + +## [1.2.6] 2013-01-06 + - Add warnings when users do not provide a session secret + - Fix parsing performance for unquoted filenames + +## [1.1.4] 2013-01-06 + - Add warnings when users do not provide a session secret + +## [1.4.1] 2012-01-22 + - Alter the keyspace limit calculations to reduce issues with nested params + - Add a workaround for multipart parsing where files contain unescaped "%" + - Added Rack::Response::Helpers#method_not_allowed? (code 405) + - Rack::File now returns 404 for illegal directory traversals + - Rack::File now returns 405 for illegal methods (non HEAD/GET) + - Rack::Cascade now catches 405 by default, as well as 404 + - Cookies missing '--' no longer cause an exception to be raised + - Various style changes and documentation spelling errors + - Rack::BodyProxy always ensures to execute its block + - Additional test coverage around cookies and secrets + - Rack::Session::Cookie can now be supplied either secret or old_secret + - Tests are no longer dependent on set order + - Rack::Static no longer defaults to serving index files + - Rack.release was fixed + +## [1.4.0] 2011-12-28 + - Ruby 1.8.6 support has officially been dropped. Not all tests pass. + - Raise sane error messages for broken config.ru + - Allow combining run and map in a config.ru + - Rack::ContentType will not set Content-Type for responses without a body + - Status code 205 does not send a response body + - Rack::Response::Helpers will not rely on instance variables + - Rack::Utils.build_query no longer outputs '=' for nil query values + - Various mime types added + - Rack::MockRequest now supports HEAD + - Rack::Directory now supports files that contain RFC3986 reserved chars + - Rack::File now only supports GET and HEAD requests + - Rack::Server#start now passes the block to Rack::Handler::#run + - Rack::Static now supports an index option + - Added the Teapot status code + - rackup now defaults to Thin instead of Mongrel (if installed) + - Support added for HTTP_X_FORWARDED_SCHEME + - Numerous bug fixes, including many fixes for new and alternate rubies + +## [1.1.3] 2011-12-28 + - Security fix. http://www.ocert.org/advisories/ocert-2011-003.html + Further information here: http://jruby.org/2011/12/27/jruby-1-6-5-1 + +## [1.3.5] 2011-10-17 + - Fix annoying warnings caused by the backport in 1.3.4 + +## [1.3.4] 2011-10-01 + - Backport security fix from 1.9.3, also fixes some roundtrip issues in URI + - Small documentation update + - Fix an issue where BodyProxy could cause an infinite recursion + - Add some supporting files for travis-ci + +## [1.2.4] 2011-09-16 + - Fix a bug with MRI regex engine to prevent XSS by malformed unicode + +## [1.3.3] 2011-09-16 + - Fix bug with broken query parameters in Rack::ShowExceptions + - Rack::Request#cookies no longer swallows exceptions on broken input + - Prevents XSS attacks enabled by bug in Ruby 1.8's regexp engine + - Rack::ConditionalGet handles broken If-Modified-Since helpers + +## [1.3.2] 2011-07-16 + - Fix for Rails and rack-test, Rack::Utils#escape calls to_s + +## [1.3.1] 2011-07-13 + - Fix 1.9.1 support + - Fix JRuby support + - Properly handle $KCODE in Rack::Utils.escape + - Make method_missing/respond_to behavior consistent for Rack::Lock, + Rack::Auth::Digest::Request and Rack::Multipart::UploadedFile + - Reenable passing rack.session to session middleware + - Rack::CommonLogger handles streaming responses correctly + - Rack::MockResponse calls close on the body object + - Fix a DOS vector from MRI stdlib backport + +## [1.2.3] 2011-05-22 + - Pulled in relevant bug fixes from 1.3 + - Fixed 1.8.6 support + +## [1.3.0] 2011-05-22 + - Various performance optimizations + - Various multipart fixes + - Various multipart refactors + - Infinite loop fix for multipart + - Test coverage for Rack::Server returns + - Allow files with '..', but not path components that are '..' + - rackup accepts handler-specific options on the command line + - Request#params no longer merges POST into GET (but returns the same) + - Use URI.encode_www_form_component instead. Use core methods for escaping. + - Allow multi-line comments in the config file + - Bug L#94 reported by Nikolai Lugovoi, query parameter unescaping. + - Rack::Response now deletes Content-Length when appropriate + - Rack::Deflater now supports streaming + - Improved Rack::Handler loading and searching + - Support for the PATCH verb + - env['rack.session.options'] now contains session options + - Cookies respect renew + - Session middleware uses SecureRandom.hex + +## [1.2.2, 1.1.2] 2011-03-13 + - Security fix in Rack::Auth::Digest::MD5: when authenticator + returned nil, permission was granted on empty password. + +## [1.2.1] 2010-06-15 + - Make CGI handler rewindable + - Rename spec/ to test/ to not conflict with SPEC on lesser + operating systems + +## [1.2.0] 2010-06-13 + - Removed Camping adapter: Camping 2.0 supports Rack as-is + - Removed parsing of quoted values + - Add Request.trace? and Request.options? + - Add mime-type for .webm and .htc + - Fix HTTP_X_FORWARDED_FOR + - Various multipart fixes + - Switch test suite to bacon + +## [1.1.0] 2010-01-03 + - Moved Auth::OpenID to rack-contrib. + - SPEC change that relaxes Lint slightly to allow subclasses of the + required types + - SPEC change to document rack.input binary mode in greater detail + - SPEC define optional rack.logger specification + - File servers support X-Cascade header + - Imported Config middleware + - Imported ETag middleware + - Imported Runtime middleware + - Imported Sendfile middleware + - New Logger and NullLogger middlewares + - Added mime type for .ogv and .manifest. + - Don't squeeze PATH_INFO slashes + - Use Content-Type to determine POST params parsing + - Update Rack::Utils::HTTP_STATUS_CODES hash + - Add status code lookup utility + - Response should call #to_i on the status + - Add Request#user_agent + - Request#host knows about forwarded host + - Return an empty string for Request#host if HTTP_HOST and + SERVER_NAME are both missing + - Allow MockRequest to accept hash params + - Optimizations to HeaderHash + - Refactored rackup into Rack::Server + - Added Utils.build_nested_query to complement Utils.parse_nested_query + - Added Utils::Multipart.build_multipart to complement + Utils::Multipart.parse_multipart + - Extracted set and delete cookie helpers into Utils so they can be + used outside Response + - Extract parse_query and parse_multipart in Request so subclasses + can change their behavior + - Enforce binary encoding in RewindableInput + - Set correct external_encoding for handlers that don't use RewindableInput + +## [1.0.1] 2009-10-18 + - Bump remainder of rack.versions. + - Support the pure Ruby FCGI implementation. + - Fix for form names containing "=": split first then unescape components + - Fixes the handling of the filename parameter with semicolons in names. + - Add anchor to nested params parsing regexp to prevent stack overflows + - Use more compatible gzip write api instead of "<<". + - Make sure that Reloader doesn't break when executed via ruby -e + - Make sure WEBrick respects the :Host option + - Many Ruby 1.9 fixes. + +## [1.0.0] 2009-04-25 + - SPEC change: Rack::VERSION has been pushed to [1,0]. + - SPEC change: header values must be Strings now, split on "\n". + - SPEC change: Content-Length can be missing, in this case chunked transfer + encoding is used. + - SPEC change: rack.input must be rewindable and support reading into + a buffer, wrap with Rack::RewindableInput if it isn't. + - SPEC change: rack.session is now specified. + - SPEC change: Bodies can now additionally respond to #to_path with + a filename to be served. + - NOTE: String bodies break in 1.9, use an Array consisting of a + single String instead. + - New middleware Rack::Lock. + - New middleware Rack::ContentType. + - Rack::Reloader has been rewritten. + - Major update to Rack::Auth::OpenID. + - Support for nested parameter parsing in Rack::Response. + - Support for redirects in Rack::Response. + - HttpOnly cookie support in Rack::Response. + - The Rakefile has been rewritten. + - Many bugfixes and small improvements. + +## [0.9.1] 2009-01-09 + - Fix directory traversal exploits in Rack::File and Rack::Directory. + +## [0.9] 2009-01-06 + - Rack is now managed by the Rack Core Team. + - Rack::Lint is stricter and follows the HTTP RFCs more closely. + - Added ConditionalGet middleware. + - Added ContentLength middleware. + - Added Deflater middleware. + - Added Head middleware. + - Added MethodOverride middleware. + - Rack::Mime now provides popular MIME-types and their extension. + - Mongrel Header now streams. + - Added Thin handler. + - Official support for swiftiplied Mongrel. + - Secure cookies. + - Made HeaderHash case-preserving. + - Many bugfixes and small improvements. + +## [0.4] 2008-08-21 + - New middleware, Rack::Deflater, by Christoffer Sawicki. + - OpenID authentication now needs ruby-openid 2. + - New Memcache sessions, by blink. + - Explicit EventedMongrel handler, by Joshua Peek + - Rack::Reloader is not loaded in rackup development mode. + - rackup can daemonize with -D. + - Many bugfixes, especially for pool sessions, URLMap, thread safety + and tempfile handling. + - Improved tests. + - Rack moved to Git. + +## [0.3] 2008-02-26 + - LiteSpeed handler, by Adrian Madrid. + - SCGI handler, by Jeremy Evans. + - Pool sessions, by blink. + - OpenID authentication, by blink. + - :Port and :File options for opening FastCGI sockets, by blink. + - Last-Modified HTTP header for Rack::File, by blink. + - Rack::Builder#use now accepts blocks, by Corey Jewett. + (See example/protectedlobster.ru) + - HTTP status 201 can contain a Content-Type and a body now. + - Many bugfixes, especially related to Cookie handling. + +## [0.2] 2007-05-16 + - HTTP Basic authentication. + - Cookie Sessions. + - Static file handler. + - Improved Rack::Request. + - Improved Rack::Response. + - Added Rack::ShowStatus, for better default error messages. + - Bug fixes in the Camping adapter. + - Removed Rails adapter, was too alpha. + +## [0.1] 2007-03-03 + +[@ioquatix]: https://github.com/ioquatix "Samuel Williams" +[@jeremyevans]: https://github.com/jeremyevans "Jeremy Evans" +[@amatsuda]: https://github.com/amatsuda "Akira Matsuda" +[@wjordan]: https://github.com/wjordan "Will Jordan" +[@BlakeWilliams]: https://github.com/BlakeWilliams "Blake Williams" +[@davidstosik]: https://github.com/davidstosik "David Stosik" diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CONTRIBUTING.md b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CONTRIBUTING.md new file mode 100644 index 0000000..a95263d --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CONTRIBUTING.md @@ -0,0 +1,144 @@ +# Contributing to Rack + +Rack is work of [hundreds of +contributors](https://github.com/rack/rack/graphs/contributors). You're +encouraged to submit [pull requests](https://github.com/rack/rack/pulls) and +[propose features and discuss issues](https://github.com/rack/rack/issues). + +## Backports + +Only security patches are ideal for backporting to non-main release versions. If +you're not sure if your bug fix is backportable, you should open a discussion to +discuss it first. + +The [Security Policy] documents which release versions will receive security +backports. + +## Fork the Project + +Fork the [project on GitHub](https://github.com/rack/rack) and check out your +copy. + +``` +git clone https://github.com/(your-github-username)/rack.git +cd rack +git remote add upstream https://github.com/rack/rack.git +``` + +## Create a Topic Branch + +Make sure your fork is up-to-date and create a topic branch for your feature or +bug fix. + +``` +git checkout main +git pull upstream main +git checkout -b my-feature-branch +``` + +## Running All Tests + +Install all dependencies. + +``` +bundle install +``` + +Run all tests. + +``` +rake test +``` + +## Write Tests + +Try to write a test that reproduces the problem you're trying to fix or +describes a feature that you want to build. + +We definitely appreciate pull requests that highlight or reproduce a problem, +even without a fix. + +## Write Code + +Implement your feature or bug fix. + +Make sure that all tests pass: + +``` +bundle exec rake test +``` + +## Write Documentation + +Document any external behavior in the [README](README.md). + +## Update Changelog + +Add a line to [CHANGELOG](CHANGELOG.md). + +## Commit Changes + +Make sure git knows your name and email address: + +``` +git config --global user.name "Your Name" +git config --global user.email "contributor@example.com" +``` + +Writing good commit logs is important. A commit log should describe what changed +and why. + +``` +git add ... +git commit +``` + +## Push + +``` +git push origin my-feature-branch +``` + +## Make a Pull Request + +Go to your fork of rack on GitHub and select your feature branch. Click the +'Pull Request' button and fill out the form. Pull requests are usually +reviewed within a few days. + +## Rebase + +If you've been working on a change for a while, rebase with upstream/main. + +``` +git fetch upstream +git rebase upstream/main +git push origin my-feature-branch -f +``` + +## Make Required Changes + +Amend your previous commit and force push the changes. + +``` +git commit --amend +git push origin my-feature-branch -f +``` + +## Check on Your Pull Request + +Go back to your pull request after a few minutes and see whether it passed +tests with GitHub Actions. Everything should look green, otherwise fix issues and +amend your commit as described above. + +## Be Patient + +It's likely that your change will not be merged and that the nitpicky +maintainers will ask you to do more, or fix seemingly benign problems. Hang in +there! + +## Thank You + +Please do know that we really appreciate and value your time and work. We love +you, really. + +[Security Policy]: SECURITY.md diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/MIT-LICENSE b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/MIT-LICENSE new file mode 100644 index 0000000..fb33b7f --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/MIT-LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (C) 2007-2021 Leah Neukirchen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/README.md b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/README.md new file mode 100644 index 0000000..3a197b1 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/README.md @@ -0,0 +1,328 @@ +# ![Rack](contrib/logo.webp) + +Rack provides a minimal, modular, and adaptable interface for developing web +applications in Ruby. By wrapping HTTP requests and responses in the simplest +way possible, it unifies and distills the bridge between web servers, web +frameworks, and web application into a single method call. + +The exact details of this are described in the [Rack Specification], which all +Rack applications should conform to. + +## Version support + +| Version | Support | +|----------|------------------------------------| +| 3.0.x | Bug fixes and security patches. | +| 2.2.x | Security patches only. | +| <= 2.1.x | End of support. | + +Please see the [Security Policy] for more information. + +## Rack 3.0 + +This is the latest version of Rack. It contains API improvements but also some +breaking changes. Please check the [Upgrade Guide](UPGRADE-GUIDE.md) for more +details about migrating servers, middlewares and applications designed for Rack 2 +to Rack 3. For detailed information on specific changes, check the [Change Log](CHANGELOG.md). + +## Rack 2.2 + +This version of Rack is receiving security patches only, and effort should be +made to move to Rack 3. + +Starting in Ruby 3.4 the `base64` dependency will no longer be a default gem, +and may cause a warning or error about `base64` being missing. To correct this, +add `base64` as a dependency to your project. + +## Installation + +Add the rack gem to your application bundle, or follow the instructions provided +by a [supported web framework](#supported-web-frameworks): + +```bash +# Install it generally: +$ gem install rack + +# or, add it to your current application gemfile: +$ bundle add rack +``` + +If you need features from `Rack::Session` or `bin/rackup` please add those gems separately. + +```bash +$ gem install rack-session rackup +``` + +## Usage + +Create a file called `config.ru` with the following contents: + +```ruby +run do |env| + [200, {}, ["Hello World"]] +end +``` + +Run this using the rackup gem or another [supported web +server](#supported-web-servers). + +```bash +$ gem install rackup +$ rackup +$ curl http://localhost:9292 +Hello World +``` + +## Supported web servers + +Rack is supported by a wide range of servers, including: + +* [Agoo](https://github.com/ohler55/agoo) +* [Falcon](https://github.com/socketry/falcon) +* [Iodine](https://github.com/boazsegev/iodine) +* [NGINX Unit](https://unit.nginx.org/) +* [Phusion Passenger](https://www.phusionpassenger.com/) (which is mod_rack for + Apache and for nginx) +* [Puma](https://puma.io/) +* [Thin](https://github.com/macournoyer/thin) +* [Unicorn](https://yhbt.net/unicorn/) +* [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) +* [Lamby](https://lamby.custominktech.com) (for AWS Lambda) + +You will need to consult the server documentation to find out what features and +limitations they may have. In general, any valid Rack app will run the same on +all these servers, without changing anything. + +### Rackup + +Rack provides a separate gem, [rackup](https://github.com/rack/rackup) which is +a generic interface for running a Rack application on supported servers, which +include `WEBRick`, `Puma`, `Falcon` and others. + +## Supported web frameworks + +These frameworks and many others support the [Rack Specification]: + +* [Camping](https://github.com/camping/camping) +* [Hanami](https://hanamirb.org/) +* [Ramaze](https://github.com/ramaze/ramaze) +* [Padrino](https://padrinorb.com/) +* [Roda](https://github.com/jeremyevans/roda) +* [Ruby on Rails](https://rubyonrails.org/) +* [Rum](https://github.com/leahneukirchen/rum) +* [Sinatra](https://sinatrarb.com/) +* [Utopia](https://github.com/socketry/utopia) +* [WABuR](https://github.com/ohler55/wabur) + +## Available middleware shipped with Rack + +Between the server and the framework, Rack can be customized to your +applications needs using middleware. Rack itself ships with the following +middleware: + +* `Rack::CommonLogger` for creating Apache-style logfiles. +* `Rack::ConditionalGet` for returning [Not + Modified](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) + responses when the response has not changed. +* `Rack::Config` for modifying the environment before processing the request. +* `Rack::ContentLength` for setting a `content-length` header based on body + size. +* `Rack::ContentType` for setting a default `content-type` header for responses. +* `Rack::Deflater` for compressing responses with gzip. +* `Rack::ETag` for setting `etag` header on bodies that can be buffered. +* `Rack::Events` for providing easy hooks when a request is received and when + the response is sent. +* `Rack::Files` for serving static files. +* `Rack::Head` for returning an empty body for HEAD requests. +* `Rack::Lint` for checking conformance to the [Rack Specification]. +* `Rack::Lock` for serializing requests using a mutex. +* `Rack::Logger` for setting a logger to handle logging errors. +* `Rack::MethodOverride` for modifying the request method based on a submitted + parameter. +* `Rack::Recursive` for including data from other paths in the application, and + for performing internal redirects. +* `Rack::Reloader` for reloading files if they have been modified. +* `Rack::Runtime` for including a response header with the time taken to process + the request. +* `Rack::Sendfile` for working with web servers that can use optimized file + serving for file system paths. +* `Rack::ShowException` for catching unhandled exceptions and presenting them in + a nice and helpful way with clickable backtrace. +* `Rack::ShowStatus` for using nice error pages for empty client error + responses. +* `Rack::Static` for more configurable serving of static files. +* `Rack::TempfileReaper` for removing temporary files creating during a request. + +All these components use the same interface, which is described in detail in the +[Rack Specification]. These optional components can be used in any way you wish. + +### Convenience interfaces + +If you want to develop outside of existing frameworks, implement your own ones, +or develop middleware, Rack provides many helpers to create Rack applications +quickly and without doing the same web stuff all over: + +* `Rack::Request` which also provides query string parsing and multipart + handling. +* `Rack::Response` for convenient generation of HTTP replies and cookie + handling. +* `Rack::MockRequest` and `Rack::MockResponse` for efficient and quick testing + of Rack application without real HTTP round-trips. +* `Rack::Cascade` for trying additional Rack applications if an application + returns a not found or method not supported response. +* `Rack::Directory` for serving files under a given directory, with directory + indexes. +* `Rack::MediaType` for parsing content-type headers. +* `Rack::Mime` for determining content-type based on file extension. +* `Rack::RewindableInput` for making any IO object rewindable, using a temporary + file buffer. +* `Rack::URLMap` to route to multiple applications inside the same process. + +## Configuration + +Rack exposes several configuration parameters to control various features of the +implementation. + +### `param_depth_limit` + +```ruby +Rack::Utils.param_depth_limit = 32 # default +``` + +The maximum amount of nesting allowed in parameters. For example, if set to 3, +this query string would be allowed: + +``` +?a[b][c]=d +``` + +but this query string would not be allowed: + +``` +?a[b][c][d]=e +``` + +Limiting the depth prevents a possible stack overflow when parsing parameters. + +### `multipart_file_limit` + +```ruby +Rack::Utils.multipart_file_limit = 128 # default +``` + +The maximum number of parts with a filename a request can contain. Accepting +too many parts can lead to the server running out of file handles. + +The default is 128, which means that a single request can't upload more than 128 +files at once. Set to 0 for no limit. + +Can also be set via the `RACK_MULTIPART_FILE_LIMIT` environment variable. + +(This is also aliased as `multipart_part_limit` and `RACK_MULTIPART_PART_LIMIT` for compatibility) + + +### `multipart_total_part_limit` + +The maximum total number of parts a request can contain of any type, including +both file and non-file form fields. + +The default is 4096, which means that a single request can't contain more than +4096 parts. + +Set to 0 for no limit. + +Can also be set via the `RACK_MULTIPART_TOTAL_PART_LIMIT` environment variable. + + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for specific details about how to make a +contribution to Rack. + +Please post bugs, suggestions and patches to [GitHub +Issues](https://github.com/rack/rack/issues). + +Please check our [Security Policy](https://github.com/rack/rack/security/policy) +for responsible disclosure and security bug reporting process. Due to wide usage +of the library, it is strongly preferred that we manage timing in order to +provide viable patches at the time of disclosure. Your assistance in this matter +is greatly appreciated. + +## See Also + +### `rack-contrib` + +The plethora of useful middleware created the need for a project that collects +fresh Rack middleware. `rack-contrib` includes a variety of add-on components +for Rack and it is easy to contribute new modules. + +* https://github.com/rack/rack-contrib + +### `rack-session` + +Provides convenient session management for Rack. + +* https://github.com/rack/rack-session + +## Thanks + +The Rack Core Team, consisting of + +* Aaron Patterson [tenderlove](https://github.com/tenderlove) +* Samuel Williams [ioquatix](https://github.com/ioquatix) +* Jeremy Evans [jeremyevans](https://github.com/jeremyevans) +* Eileen Uchitelle [eileencodes](https://github.com/eileencodes) +* Matthew Draper [matthewd](https://github.com/matthewd) +* Rafael França [rafaelfranca](https://github.com/rafaelfranca) + +and the Rack Alumni + +* Ryan Tomayko [rtomayko](https://github.com/rtomayko) +* Scytrin dai Kinthra [scytrin](https://github.com/scytrin) +* Leah Neukirchen [leahneukirchen](https://github.com/leahneukirchen) +* James Tucker [raggi](https://github.com/raggi) +* Josh Peek [josh](https://github.com/josh) +* José Valim [josevalim](https://github.com/josevalim) +* Michael Fellinger [manveru](https://github.com/manveru) +* Santiago Pastorino [spastorino](https://github.com/spastorino) +* Konstantin Haase [rkh](https://github.com/rkh) + +would like to thank: + +* Adrian Madrid, for the LiteSpeed handler. +* Christoffer Sawicki, for the first Rails adapter and `Rack::Deflater`. +* Tim Fletcher, for the HTTP authentication code. +* Luc Heinrich for the Cookie sessions, the static file handler and bugfixes. +* Armin Ronacher, for the logo and racktools. +* Alex Beregszaszi, Alexander Kahn, Anil Wadghule, Aredridel, Ben Alpert, Dan + Kubb, Daniel Roethlisberger, Matt Todd, Tom Robinson, Phil Hagelberg, S. Brent + Faulkner, Bosko Milekic, Daniel Rodríguez Troitiño, Genki Takiuchi, Geoffrey + Grosenbach, Julien Sanchez, Kamal Fariz Mahyuddin, Masayoshi Takahashi, + Patrick Aljordm, Mig, Kazuhiro Nishiyama, Jon Bardin, Konstantin Haase, Larry + Siden, Matias Korhonen, Sam Ruby, Simon Chiang, Tim Connor, Timur Batyrshin, + and Zach Brock for bug fixing and other improvements. +* Eric Wong, Hongli Lai, Jeremy Kemper for their continuous support and API + improvements. +* Yehuda Katz and Carl Lerche for refactoring rackup. +* Brian Candler, for `Rack::ContentType`. +* Graham Batty, for improved handler loading. +* Stephen Bannasch, for bug reports and documentation. +* Gary Wright, for proposing a better `Rack::Response` interface. +* Jonathan Buch, for improvements regarding `Rack::Response`. +* Armin Röhrl, for tracking down bugs in the Cookie generator. +* Alexander Kellett for testing the Gem and reviewing the announcement. +* Marcus Rückert, for help with configuring and debugging lighttpd. +* The WSGI team for the well-done and documented work they've done and Rack + builds up on. +* All bug reporters and patch contributors not mentioned above. + +## License + +Rack is released under the [MIT License](MIT-LICENSE). + +[Rack Specification]: SPEC.rdoc +[Security Policy]: SECURITY.md diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/SPEC.rdoc b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/SPEC.rdoc new file mode 100644 index 0000000..ed5d982 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/SPEC.rdoc @@ -0,0 +1,365 @@ +This specification aims to formalize the Rack protocol. You +can (and should) use Rack::Lint to enforce it. + +When you develop middleware, be sure to add a Lint before and +after to catch all mistakes. + += Rack applications + +A Rack application is a Ruby object (not a class) that +responds to +call+. +It takes exactly one argument, the *environment* +and returns a non-frozen Array of exactly three values: +The *status*, +the *headers*, +and the *body*. + +== The Environment + +The environment must be an unfrozen instance of Hash that includes +CGI-like headers. The Rack application is free to modify the +environment. + +The environment is required to include these variables +(adopted from {PEP 333}[https://peps.python.org/pep-0333/]), except when they'd be empty, but see +below. +REQUEST_METHOD:: The HTTP request method, such as + "GET" or "POST". This cannot ever + be an empty string, and so is + always required. +SCRIPT_NAME:: The initial portion of the request + URL's "path" that corresponds to the + application object, so that the + application knows its virtual + "location". This may be an empty + string, if the application corresponds + to the "root" of the server. +PATH_INFO:: The remainder of the request URL's + "path", designating the virtual + "location" of the request's target + within the application. This may be an + empty string, if the request URL targets + the application root and does not have a + trailing slash. This value may be + percent-encoded when originating from + a URL. +QUERY_STRING:: The portion of the request URL that + follows the ?, if any. May be + empty, but is always required! +SERVER_NAME:: When combined with SCRIPT_NAME and + PATH_INFO, these variables can be + used to complete the URL. Note, however, + that HTTP_HOST, if present, + should be used in preference to + SERVER_NAME for reconstructing + the request URL. + SERVER_NAME can never be an empty + string, and so is always required. +SERVER_PORT:: An optional +Integer+ which is the port the + server is running on. Should be specified if + the server is running on a non-standard port. +SERVER_PROTOCOL:: A string representing the HTTP version used + for the request. +HTTP_ Variables:: Variables corresponding to the + client-supplied HTTP request + headers (i.e., variables whose + names begin with HTTP_). The + presence or absence of these + variables should correspond with + the presence or absence of the + appropriate HTTP header in the + request. See + {RFC3875 section 4.1.18}[https://tools.ietf.org/html/rfc3875#section-4.1.18] + for specific behavior. +In addition to this, the Rack environment must include these +Rack-specific variables: +rack.url_scheme:: +http+ or +https+, depending on the + request URL. +rack.input:: See below, the input stream. +rack.errors:: See below, the error stream. +rack.hijack?:: See below, if present and true, indicates + that the server supports partial hijacking. +rack.hijack:: See below, if present, an object responding + to +call+ that is used to perform a full + hijack. +rack.protocol:: An optional +Array+ of +String+, containing + the protocols advertised by the client in + the +upgrade+ header (HTTP/1) or the + +:protocol+ pseudo-header (HTTP/2). +Additional environment specifications have approved to +standardized middleware APIs. None of these are required to +be implemented by the server. +rack.session:: A hash-like interface for storing + request session data. + The store must implement: + store(key, value) (aliased as []=); + fetch(key, default = nil) (aliased as []); + delete(key); + clear; + to_hash (returning unfrozen Hash instance); +rack.logger:: A common object interface for logging messages. + The object must implement: + info(message, &block) + debug(message, &block) + warn(message, &block) + error(message, &block) + fatal(message, &block) +rack.multipart.buffer_size:: An Integer hint to the multipart parser as to what chunk size to use for reads and writes. +rack.multipart.tempfile_factory:: An object responding to #call with two arguments, the filename and content_type given for the multipart form field, and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate the tempfile for each multipart form file upload field, rather than the default class of Tempfile. +The server or the application can store their own data in the +environment, too. The keys must contain at least one dot, +and should be prefixed uniquely. The prefix rack. +is reserved for use with the Rack core distribution and other +accepted specifications and must not be used otherwise. + +The SERVER_PORT must be an Integer if set. +The SERVER_NAME must be a valid authority as defined by RFC7540. +The HTTP_HOST must be a valid authority as defined by RFC7540. +The SERVER_PROTOCOL must match the regexp HTTP/\d(\.\d)?. +The environment must not contain the keys +HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH +(use the versions without HTTP_). +The CGI keys (named without a period) must have String values. +If the string values for CGI keys contain non-ASCII characters, +they should use ASCII-8BIT encoding. +There are the following restrictions: +* rack.url_scheme must either be +http+ or +https+. +* There may be a valid input stream in rack.input. +* There must be a valid error stream in rack.errors. +* There may be a valid hijack callback in rack.hijack +* There may be a valid early hints callback in rack.early_hints +* The REQUEST_METHOD must be a valid token. +* The SCRIPT_NAME, if non-empty, must start with / +* The PATH_INFO, if provided, must be a valid request target or an empty string. + * Only OPTIONS requests may have PATH_INFO set to * (asterisk-form). + * Only CONNECT requests may have PATH_INFO set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. + * CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form). + * Otherwise, PATH_INFO must start with a / and must not include a fragment part starting with '#' (origin-form). +* The CONTENT_LENGTH, if given, must consist of digits only. +* One of SCRIPT_NAME or PATH_INFO must be + set. PATH_INFO should be / if + SCRIPT_NAME is empty. + SCRIPT_NAME never should be /, but instead be empty. +rack.response_finished:: An array of callables run by the server after the response has been +processed. This would typically be invoked after sending the response to the client, but it could also be +invoked if an error occurs while generating the response or sending the response; in that case, the error +argument will be a subclass of +Exception+. +The callables are invoked with +env, status, headers, error+ arguments and should not raise any +exceptions. They should be invoked in reverse order of registration. + +=== The Input Stream + +The input stream is an IO-like object which contains the raw HTTP +POST data. +When applicable, its external encoding must be "ASCII-8BIT" and it +must be opened in binary mode. +The input stream must respond to +gets+, +each+, and +read+. +* +gets+ must be called without arguments and return a string, + or +nil+ on EOF. +* +read+ behaves like IO#read. + Its signature is read([length, [buffer]]). + + If given, +length+ must be a non-negative Integer (>= 0) or +nil+, + and +buffer+ must be a String and may not be nil. + + If +length+ is given and not nil, then this method reads at most + +length+ bytes from the input stream. + + If +length+ is not given or nil, then this method reads + all data until EOF. + + When EOF is reached, this method returns nil if +length+ is given + and not nil, or "" if +length+ is not given or is nil. + + If +buffer+ is given, then the read data will be placed + into +buffer+ instead of a newly created String object. +* +each+ must be called without arguments and only yield Strings. +* +close+ can be called on the input stream to indicate that + any remaining input is not needed. + +=== The Error Stream + +The error stream must respond to +puts+, +write+ and +flush+. +* +puts+ must be called with a single argument that responds to +to_s+. +* +write+ must be called with a single argument that is a String. +* +flush+ must be called without arguments and must be called + in order to make the error appear for sure. +* +close+ must never be called on the error stream. + +=== Hijacking + +The hijacking interfaces provides a means for an application to take +control of the HTTP connection. There are two distinct hijack +interfaces: full hijacking where the application takes over the raw +connection, and partial hijacking where the application takes over +just the response body stream. In both cases, the application is +responsible for closing the hijacked stream. + +Full hijacking only works with HTTP/1. Partial hijacking is functionally +equivalent to streaming bodies, and is still optionally supported for +backwards compatibility with older Rack versions. + +==== Full Hijack + +Full hijack is used to completely take over an HTTP/1 connection. It +occurs before any headers are written and causes the request to +ignores any response generated by the application. + +It is intended to be used when applications need access to raw HTTP/1 +connection. + +If +rack.hijack+ is present in +env+, it must respond to +call+ +and return an +IO+ instance which can be used to read and write +to the underlying connection using HTTP/1 semantics and +formatting. + +==== Partial Hijack + +Partial hijack is used for bi-directional streaming of the request and +response body. It occurs after the status and headers are written by +the server and causes the server to ignore the Body of the response. + +It is intended to be used when applications need bi-directional +streaming. + +If +rack.hijack?+ is present in +env+ and truthy, +an application may set the special response header +rack.hijack+ +to an object that responds to +call+, +accepting a +stream+ argument. + +After the response status and headers have been sent, this hijack +callback will be invoked with a +stream+ argument which follows the +same interface as outlined in "Streaming Body". Servers must +ignore the +body+ part of the response tuple when the ++rack.hijack+ response header is present. Using an empty +Array+ +instance is recommended. + +The special response header +rack.hijack+ must only be set +if the request +env+ has a truthy +rack.hijack?+. + +=== Early Hints + +The application or any middleware may call the rack.early_hints +with an object which would be valid as the headers of a Rack response. + +If rack.early_hints is present, it must respond to #call. +If rack.early_hints is called, it must be called with +valid Rack response headers. + +== The Response + +=== The Status + +This is an HTTP status. It must be an Integer greater than or equal to +100. + +=== The Headers + +The headers must be a unfrozen Hash. +The header keys must be Strings. +Special headers starting "rack." are for communicating with the +server, and must not be sent back to the client. +The header must not contain a +Status+ key. +Header keys must conform to RFC7230 token specification, i.e. cannot +contain non-printable ASCII, DQUOTE or "(),/:;<=>?@[\]{}". +Header keys must not contain uppercase ASCII characters (A-Z). +Header values must be either a String instance, +or an Array of String instances, +such that each String instance must not contain characters below 037. + +==== The +content-type+ Header + +There must not be a content-type header key when the +Status+ is 1xx, +204, or 304. + +==== The +content-length+ Header + +There must not be a content-length header key when the ++Status+ is 1xx, 204, or 304. + +==== The +rack.protocol+ Header + +If the +rack.protocol+ header is present, it must be a +String+, and +must be one of the values from the +rack.protocol+ array from the +environment. + +Setting this value informs the server that it should perform a +connection upgrade. In HTTP/1, this is done using the +upgrade+ +header. In HTTP/2, this is done by accepting the request. + +=== The Body + +The Body is typically an +Array+ of +String+ instances, an enumerable +that yields +String+ instances, a +Proc+ instance, or a File-like +object. + +The Body must respond to +each+ or +call+. It may optionally respond +to +to_path+ or +to_ary+. A Body that responds to +each+ is considered +to be an Enumerable Body. A Body that responds to +call+ is considered +to be a Streaming Body. + +A Body that responds to both +each+ and +call+ must be treated as an +Enumerable Body, not a Streaming Body. If it responds to +each+, you +must call +each+ and not +call+. If the Body doesn't respond to ++each+, then you can assume it responds to +call+. + +The Body must either be consumed or returned. The Body is consumed by +optionally calling either +each+ or +call+. +Then, if the Body responds to +close+, it must be called to release +any resources associated with the generation of the body. +In other words, +close+ must always be called at least once; typically +after the web server has sent the response to the client, but also in +cases where the Rack application makes internal/virtual requests and +discards the response. + + +After calling +close+, the Body is considered closed and should not +be consumed again. +If the original Body is replaced by a new Body, the new Body must +also consume the original Body by calling +close+ if possible. + +If the Body responds to +to_path+, it must return a +String+ +path for the local file system whose contents are identical +to that produced by calling +each+; this may be used by the +server as an alternative, possibly more efficient way to +transport the response. The +to_path+ method does not consume +the body. + +==== Enumerable Body + +The Enumerable Body must respond to +each+. +It must only be called once. +It must not be called after being closed, +and must only yield String values. + +Middleware must not call +each+ directly on the Body. +Instead, middleware can return a new Body that calls +each+ on the +original Body, yielding at least once per iteration. + +If the Body responds to +to_ary+, it must return an +Array+ whose +contents are identical to that produced by calling +each+. +Middleware may call +to_ary+ directly on the Body and return a new +Body in its place. In other words, middleware can only process the +Body directly if it responds to +to_ary+. If the Body responds to both ++to_ary+ and +close+, its implementation of +to_ary+ must call ++close+. + +==== Streaming Body + +The Streaming Body must respond to +call+. +It must only be called once. +It must not be called after being closed. +It takes a +stream+ argument. + +The +stream+ argument must implement: +read, write, <<, flush, close, close_read, close_write, closed? + +The semantics of these IO methods must be a best effort match to +those of a normal Ruby IO or Socket object, using standard arguments +and raising standard exceptions. Servers are encouraged to simply +pass on real IO objects, although it is recognized that this approach +is not directly compatible with HTTP/2. + +== Thanks +Some parts of this specification are adopted from {PEP 333 – Python Web Server Gateway Interface v1.0}[https://peps.python.org/pep-0333/] +I'd like to thank everyone involved in that effort. diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack.rb new file mode 100644 index 0000000..6021248 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack.rb @@ -0,0 +1,66 @@ +# socket-patch: patched rack-3.1.8 (spike marker) +# frozen_string_literal: true + +# Copyright (C) 2007-2019 Leah Neukirchen +# +# Rack is freely distributable under the terms of an MIT-style license. +# See MIT-LICENSE or https://opensource.org/licenses/MIT. + +# The Rack main module, serving as a namespace for all core Rack +# modules and classes. +# +# All modules meant for use in your application are autoloaded here, +# so it should be enough just to require 'rack' in your code. + +require_relative 'rack/version' +require_relative 'rack/constants' + +module Rack + autoload :BadRequest, "rack/bad_request" + autoload :BodyProxy, "rack/body_proxy" + autoload :Builder, "rack/builder" + autoload :Cascade, "rack/cascade" + autoload :CommonLogger, "rack/common_logger" + autoload :ConditionalGet, "rack/conditional_get" + autoload :Config, "rack/config" + autoload :ContentLength, "rack/content_length" + autoload :ContentType, "rack/content_type" + autoload :Deflater, "rack/deflater" + autoload :Directory, "rack/directory" + autoload :ETag, "rack/etag" + autoload :Events, "rack/events" + autoload :Files, "rack/files" + autoload :ForwardRequest, "rack/recursive" + autoload :Head, "rack/head" + autoload :Headers, "rack/headers" + autoload :Lint, "rack/lint" + autoload :Lock, "rack/lock" + autoload :Logger, "rack/logger" + autoload :MediaType, "rack/media_type" + autoload :MethodOverride, "rack/method_override" + autoload :Mime, "rack/mime" + autoload :MockRequest, "rack/mock_request" + autoload :MockResponse, "rack/mock_response" + autoload :Multipart, "rack/multipart" + autoload :NullLogger, "rack/null_logger" + autoload :QueryParser, "rack/query_parser" + autoload :Recursive, "rack/recursive" + autoload :Reloader, "rack/reloader" + autoload :Request, "rack/request" + autoload :Response, "rack/response" + autoload :RewindableInput, "rack/rewindable_input" + autoload :Runtime, "rack/runtime" + autoload :Sendfile, "rack/sendfile" + autoload :ShowExceptions, "rack/show_exceptions" + autoload :ShowStatus, "rack/show_status" + autoload :Static, "rack/static" + autoload :TempfileReaper, "rack/tempfile_reaper" + autoload :URLMap, "rack/urlmap" + autoload :Utils, "rack/utils" + + module Auth + autoload :Basic, "rack/auth/basic" + autoload :AbstractHandler, "rack/auth/abstract/handler" + autoload :AbstractRequest, "rack/auth/abstract/request" + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb new file mode 100644 index 0000000..4731ee8 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative '../../constants' + +module Rack + module Auth + # Rack::Auth::AbstractHandler implements common authentication functionality. + # + # +realm+ should be set for all handlers. + + class AbstractHandler + + attr_accessor :realm + + def initialize(app, realm = nil, &authenticator) + @app, @realm, @authenticator = app, realm, authenticator + end + + + private + + def unauthorized(www_authenticate = challenge) + return [ 401, + { CONTENT_TYPE => 'text/plain', + CONTENT_LENGTH => '0', + 'www-authenticate' => www_authenticate.to_s }, + [] + ] + end + + def bad_request + return [ 400, + { CONTENT_TYPE => 'text/plain', + CONTENT_LENGTH => '0' }, + [] + ] + end + + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb new file mode 100644 index 0000000..f872331 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require_relative '../../request' + +module Rack + module Auth + class AbstractRequest + + def initialize(env) + @env = env + end + + def request + @request ||= Request.new(@env) + end + + def provided? + !authorization_key.nil? && valid? + end + + def valid? + !@env[authorization_key].nil? + end + + def parts + @parts ||= @env[authorization_key].split(' ', 2) + end + + def scheme + @scheme ||= parts.first&.downcase + end + + def params + @params ||= parts.last + end + + + private + + AUTHORIZATION_KEYS = ['HTTP_AUTHORIZATION', 'X-HTTP_AUTHORIZATION', 'X_HTTP_AUTHORIZATION'] + + def authorization_key + @authorization_key ||= AUTHORIZATION_KEYS.detect { |key| @env.has_key?(key) } + end + + end + + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb new file mode 100644 index 0000000..67ffc49 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_relative 'abstract/handler' +require_relative 'abstract/request' + +module Rack + module Auth + # Rack::Auth::Basic implements HTTP Basic Authentication, as per RFC 2617. + # + # Initialize with the Rack application that you want protecting, + # and a block that checks if a username and password pair are valid. + + class Basic < AbstractHandler + + def call(env) + auth = Basic::Request.new(env) + + return unauthorized unless auth.provided? + + return bad_request unless auth.basic? + + if valid?(auth) + env['REMOTE_USER'] = auth.username + + return @app.call(env) + end + + unauthorized + end + + + private + + def challenge + 'Basic realm="%s"' % realm + end + + def valid?(auth) + @authenticator.call(*auth.credentials) + end + + class Request < Auth::AbstractRequest + def basic? + "basic" == scheme && credentials.length == 2 + end + + def credentials + @credentials ||= params.unpack1('m').split(':', 2) + end + + def username + credentials.first + end + end + + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/bad_request.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/bad_request.rb new file mode 100644 index 0000000..8eaa94e --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/bad_request.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Rack + # Represents a 400 Bad Request error when input data fails to meet the + # requirements. + module BadRequest + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb new file mode 100644 index 0000000..7291579 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Rack + # Proxy for response bodies allowing calling a block when + # the response body is closed (after the response has been fully + # sent to the client). + class BodyProxy + # Set the response body to wrap, and the block to call when the + # response has been fully sent. + def initialize(body, &block) + @body = body + @block = block + @closed = false + end + + # Return whether the wrapped body responds to the method. + def respond_to_missing?(method_name, include_all = false) + case method_name + when :to_str + false + else + super or @body.respond_to?(method_name, include_all) + end + end + + # If not already closed, close the wrapped body and + # then call the block the proxy was initialized with. + def close + return if @closed + @closed = true + begin + @body.close if @body.respond_to?(:close) + ensure + @block.call + end + end + + # Whether the proxy is closed. The proxy starts as not closed, + # and becomes closed on the first call to close. + def closed? + @closed + end + + # Delegate missing methods to the wrapped body. + def method_missing(method_name, *args, &block) + case method_name + when :to_str + super + when :to_ary + begin + @body.__send__(method_name, *args, &block) + ensure + close + end + else + @body.__send__(method_name, *args, &block) + end + end + # :nocov: + ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) + # :nocov: + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/builder.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/builder.rb new file mode 100644 index 0000000..9faeffb --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/builder.rb @@ -0,0 +1,290 @@ +# frozen_string_literal: true + +require_relative 'urlmap' + +module Rack; end +Rack::BUILDER_TOPLEVEL_BINDING = ->(builder){builder.instance_eval{binding}} + +module Rack + # Rack::Builder provides a domain-specific language (DSL) to construct Rack + # applications. It is primarily used to parse +config.ru+ files which + # instantiate several middleware and a final application which are hosted + # by a Rack-compatible web server. + # + # Example: + # + # app = Rack::Builder.new do + # use Rack::CommonLogger + # map "/ok" do + # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } + # end + # end + # + # run app + # + # Or + # + # app = Rack::Builder.app do + # use Rack::CommonLogger + # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } + # end + # + # run app + # + # +use+ adds middleware to the stack, +run+ dispatches to an application. + # You can use +map+ to construct a Rack::URLMap in a convenient way. + class Builder + + # https://stackoverflow.com/questions/2223882/whats-the-difference-between-utf-8-and-utf-8-without-bom + UTF_8_BOM = '\xef\xbb\xbf' + + # Parse the given config file to get a Rack application. + # + # If the config file ends in +.ru+, it is treated as a + # rackup file and the contents will be treated as if + # specified inside a Rack::Builder block. + # + # If the config file does not end in +.ru+, it is + # required and Rack will use the basename of the file + # to guess which constant will be the Rack application to run. + # + # Examples: + # + # Rack::Builder.parse_file('config.ru') + # # Rack application built using Rack::Builder.new + # + # Rack::Builder.parse_file('app.rb') + # # requires app.rb, which can be anywhere in Ruby's + # # load path. After requiring, assumes App constant + # # is a Rack application + # + # Rack::Builder.parse_file('./my_app.rb') + # # requires ./my_app.rb, which should be in the + # # process's current directory. After requiring, + # # assumes MyApp constant is a Rack application + def self.parse_file(path, **options) + if path.end_with?('.ru') + return self.load_file(path, **options) + else + require path + return Object.const_get(::File.basename(path, '.rb').split('_').map(&:capitalize).join('')) + end + end + + # Load the given file as a rackup file, treating the + # contents as if specified inside a Rack::Builder block. + # + # Ignores content in the file after +__END__+, so that + # use of +__END__+ will not result in a syntax error. + # + # Example config.ru file: + # + # $ cat config.ru + # + # use Rack::ContentLength + # require './app.rb' + # run App + def self.load_file(path, **options) + config = ::File.read(path) + config.slice!(/\A#{UTF_8_BOM}/) if config.encoding == Encoding::UTF_8 + + if config[/^#\\(.*)/] + fail "Parsing options from the first comment line is no longer supported: #{path}" + end + + config.sub!(/^__END__\n.*\Z/m, '') + + return new_from_string(config, path, **options) + end + + # Evaluate the given +builder_script+ string in the context of + # a Rack::Builder block, returning a Rack application. + def self.new_from_string(builder_script, path = "(rackup)", **options) + builder = self.new(**options) + + # We want to build a variant of TOPLEVEL_BINDING with self as a Rack::Builder instance. + # We cannot use instance_eval(String) as that would resolve constants differently. + binding = BUILDER_TOPLEVEL_BINDING.call(builder) + eval(builder_script, binding, path) + + return builder.to_app + end + + # Initialize a new Rack::Builder instance. +default_app+ specifies the + # default application if +run+ is not called later. If a block + # is given, it is evaluated in the context of the instance. + def initialize(default_app = nil, **options, &block) + @use = [] + @map = nil + @run = default_app + @warmup = nil + @freeze_app = false + @options = options + + instance_eval(&block) if block_given? + end + + # Any options provided to the Rack::Builder instance at initialization. + # These options can be server-specific. Some general options are: + # + # * +:isolation+: One of +process+, +thread+ or +fiber+. The execution + # isolation model to use. + attr :options + + # Create a new Rack::Builder instance and return the Rack application + # generated from it. + def self.app(default_app = nil, &block) + self.new(default_app, &block).to_app + end + + # Specifies middleware to use in a stack. + # + # class Middleware + # def initialize(app) + # @app = app + # end + # + # def call(env) + # env["rack.some_header"] = "setting an example" + # @app.call(env) + # end + # end + # + # use Middleware + # run lambda { |env| [200, { "content-type" => "text/plain" }, ["OK"]] } + # + # All requests through to this application will first be processed by the middleware class. + # The +call+ method in this example sets an additional environment key which then can be + # referenced in the application if required. + def use(middleware, *args, &block) + if @map + mapping, @map = @map, nil + @use << proc { |app| generate_map(app, mapping) } + end + @use << proc { |app| middleware.new(app, *args, &block) } + end + # :nocov: + ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true) + # :nocov: + + # Takes a block or argument that is an object that responds to #call and + # returns a Rack response. + # + # You can use a block: + # + # run do |env| + # [200, { "content-type" => "text/plain" }, ["Hello World!"]] + # end + # + # You can also provide a lambda: + # + # run lambda { |env| [200, { "content-type" => "text/plain" }, ["OK"]] } + # + # You can also provide a class instance: + # + # class Heartbeat + # def call(env) + # [200, { "content-type" => "text/plain" }, ["OK"]] + # end + # end + # + # run Heartbeat.new + # + def run(app = nil, &block) + raise ArgumentError, "Both app and block given!" if app && block_given? + + @run = app || block + end + + # Takes a lambda or block that is used to warm-up the application. This block is called + # before the Rack application is returned by to_app. + # + # warmup do |app| + # client = Rack::MockRequest.new(app) + # client.get('/') + # end + # + # use SomeMiddleware + # run MyApp + def warmup(prc = nil, &block) + @warmup = prc || block + end + + # Creates a route within the application. Routes under the mapped path will be sent to + # the Rack application specified by run inside the block. Other requests will be sent to the + # default application specified by run outside the block. + # + # class App + # def call(env) + # [200, {'content-type' => 'text/plain'}, ["Hello World"]] + # end + # end + # + # class Heartbeat + # def call(env) + # [200, { "content-type" => "text/plain" }, ["OK"]] + # end + # end + # + # app = Rack::Builder.app do + # map '/heartbeat' do + # run Heartbeat.new + # end + # run App.new + # end + # + # run app + # + # The +use+ method can also be used inside the block to specify middleware to run under a specific path: + # + # app = Rack::Builder.app do + # map '/heartbeat' do + # use Middleware + # run Heartbeat.new + # end + # run App.new + # end + # + # This example includes a piece of middleware which will run before +/heartbeat+ requests hit +Heartbeat+. + # + # Note that providing a +path+ of +/+ will ignore any default application given in a +run+ statement + # outside the block. + def map(path, &block) + @map ||= {} + @map[path] = block + end + + # Freeze the app (set using run) and all middleware instances when building the application + # in to_app. + def freeze_app + @freeze_app = true + end + + # Return the Rack application generated by this instance. + def to_app + app = @map ? generate_map(@run, @map) : @run + fail "missing run or map statement" unless app + app.freeze if @freeze_app + app = @use.reverse.inject(app) { |a, e| e[a].tap { |x| x.freeze if @freeze_app } } + @warmup.call(app) if @warmup + app + end + + # Call the Rack application generated by this builder instance. Note that + # this rebuilds the Rack application and runs the warmup code (if any) + # every time it is called, so it should not be used if performance is important. + def call(env) + to_app.call(env) + end + + private + + # Generate a URLMap instance by generating new Rack applications for each + # map block in this instance. + def generate_map(default_app, mapping) + mapped = default_app ? { '/' => default_app } : {} + mapping.each { |r, b| mapped[r] = self.class.new(default_app, &b).to_app } + URLMap.new(mapped) + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/cascade.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/cascade.rb new file mode 100644 index 0000000..9c952fd --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/cascade.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative 'constants' + +module Rack + # Rack::Cascade tries a request on several apps, and returns the + # first response that is not 404 or 405 (or in a list of configured + # status codes). If all applications tried return one of the configured + # status codes, return the last response. + + class Cascade + # An array of applications to try in order. + attr_reader :apps + + # Set the apps to send requests to, and what statuses result in + # cascading. Arguments: + # + # apps: An enumerable of rack applications. + # cascade_for: The statuses to use cascading for. If a response is received + # from an app, the next app is tried. + def initialize(apps, cascade_for = [404, 405]) + @apps = [] + apps.each { |app| add app } + + @cascade_for = {} + [*cascade_for].each { |status| @cascade_for[status] = true } + end + + # Call each app in order. If the responses uses a status that requires + # cascading, try the next app. If all responses require cascading, + # return the response from the last app. + def call(env) + return [404, { CONTENT_TYPE => "text/plain" }, []] if @apps.empty? + result = nil + last_body = nil + + @apps.each do |app| + # The SPEC says that the body must be closed after it has been iterated + # by the server, or if it is replaced by a middleware action. Cascade + # replaces the body each time a cascade happens. It is assumed that nil + # does not respond to close, otherwise the previous application body + # will be closed. The final application body will not be closed, as it + # will be passed to the server as a result. + last_body.close if last_body.respond_to? :close + + result = app.call(env) + return result unless @cascade_for.include?(result[0].to_i) + last_body = result[2] + end + + result + end + + # Append an app to the list of apps to cascade. This app will + # be tried last. + def add(app) + @apps << app + end + + # Whether the given app is one of the apps to cascade to. + def include?(app) + @apps.include?(app) + end + + alias_method :<<, :add + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/common_logger.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/common_logger.rb new file mode 100644 index 0000000..2feb067 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/common_logger.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' +require_relative 'body_proxy' +require_relative 'request' + +module Rack + # Rack::CommonLogger forwards every request to the given +app+, and + # logs a line in the + # {Apache common log format}[http://httpd.apache.org/docs/1.3/logs.html#common] + # to the configured logger. + class CommonLogger + # Common Log Format: http://httpd.apache.org/docs/1.3/logs.html#common + # + # lilith.local - - [07/Aug/2006 23:58:02 -0400] "GET / HTTP/1.1" 500 - + # + # %{%s - %s [%s] "%s %s%s %s" %d %s\n} % + # + # The actual format is slightly different than the above due to the + # separation of SCRIPT_NAME and PATH_INFO, and because the elapsed + # time in seconds is included at the end. + FORMAT = %{%s - %s [%s] "%s %s%s%s %s" %d %s %0.4f\n} + + # +logger+ can be any object that supports the +write+ or +<<+ methods, + # which includes the standard library Logger. These methods are called + # with a single string argument, the log message. + # If +logger+ is nil, CommonLogger will fall back env['rack.errors']. + def initialize(app, logger = nil) + @app = app + @logger = logger + end + + # Log all requests in common_log format after a response has been + # returned. Note that if the app raises an exception, the request + # will not be logged, so if exception handling middleware are used, + # they should be loaded after this middleware. Additionally, because + # the logging happens after the request body has been fully sent, any + # exceptions raised during the sending of the response body will + # cause the request not to be logged. + def call(env) + began_at = Utils.clock_time + status, headers, body = response = @app.call(env) + + response[2] = BodyProxy.new(body) { log(env, status, headers, began_at) } + response + end + + private + + # Log the request to the configured logger. + def log(env, status, response_headers, began_at) + request = Rack::Request.new(env) + length = extract_content_length(response_headers) + + msg = sprintf(FORMAT, + request.ip || "-", + request.get_header("REMOTE_USER") || "-", + Time.now.strftime("%d/%b/%Y:%H:%M:%S %z"), + request.request_method, + request.script_name, + request.path_info, + request.query_string.empty? ? "" : "?#{request.query_string}", + request.get_header(SERVER_PROTOCOL), + status.to_s[0..3], + length, + Utils.clock_time - began_at) + + msg.gsub!(/[^[:print:]\n]/) { |c| sprintf("\\x%x", c.ord) } + + logger = @logger || request.get_header(RACK_ERRORS) + # Standard library logger doesn't support write but it supports << which actually + # calls to write on the log device without formatting + if logger.respond_to?(:write) + logger.write(msg) + else + logger << msg + end + end + + # Attempt to determine the content length for the response to + # include it in the logged data. + def extract_content_length(headers) + value = headers[CONTENT_LENGTH] + !value || value.to_s == '0' ? '-' : value + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb new file mode 100644 index 0000000..c3b334a --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' +require_relative 'body_proxy' + +module Rack + + # Middleware that enables conditional GET using if-none-match and + # if-modified-since. The application should set either or both of the + # last-modified or etag response headers according to RFC 2616. When + # either of the conditions is met, the response body is set to be zero + # length and the response status is set to 304 Not Modified. + # + # Applications that defer response body generation until the body's each + # message is received will avoid response body generation completely when + # a conditional GET matches. + # + # Adapted from Michael Klishin's Merb implementation: + # https://github.com/wycats/merb/blob/master/merb-core/lib/merb-core/rack/middleware/conditional_get.rb + class ConditionalGet + def initialize(app) + @app = app + end + + # Return empty 304 response if the response has not been + # modified since the last request. + def call(env) + case env[REQUEST_METHOD] + when "GET", "HEAD" + status, headers, body = response = @app.call(env) + + if status == 200 && fresh?(env, headers) + response[0] = 304 + headers.delete(CONTENT_TYPE) + headers.delete(CONTENT_LENGTH) + response[2] = Rack::BodyProxy.new([]) do + body.close if body.respond_to?(:close) + end + end + response + else + @app.call(env) + end + end + + private + + # Return whether the response has not been modified since the + # last request. + def fresh?(env, headers) + # if-none-match has priority over if-modified-since per RFC 7232 + if none_match = env['HTTP_IF_NONE_MATCH'] + etag_matches?(none_match, headers) + elsif (modified_since = env['HTTP_IF_MODIFIED_SINCE']) && (modified_since = to_rfc2822(modified_since)) + modified_since?(modified_since, headers) + end + end + + # Whether the etag response header matches the if-none-match request header. + # If so, the request has not been modified. + def etag_matches?(none_match, headers) + headers[ETAG] == none_match + end + + # Whether the last-modified response header matches the if-modified-since + # request header. If so, the request has not been modified. + def modified_since?(modified_since, headers) + last_modified = to_rfc2822(headers['last-modified']) and + modified_since >= last_modified + end + + # Return a Time object for the given string (which should be in RFC2822 + # format), or nil if the string cannot be parsed. + def to_rfc2822(since) + # shortest possible valid date is the obsolete: 1 Nov 97 09:55 A + # anything shorter is invalid, this avoids exceptions for common cases + # most common being the empty string + if since && since.length >= 16 + # NOTE: there is no trivial way to write this in a non exception way + # _rfc2822 returns a hash but is not that usable + Time.rfc2822(since) rescue nil + end + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/config.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/config.rb new file mode 100644 index 0000000..41f6f7d --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/config.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Rack + # Rack::Config modifies the environment using the block given during + # initialization. + # + # Example: + # use Rack::Config do |env| + # env['my-key'] = 'some-value' + # end + class Config + def initialize(app, &block) + @app = app + @block = block + end + + def call(env) + @block.call(env) + @app.call(env) + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/constants.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/constants.rb new file mode 100644 index 0000000..e9b6e10 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/constants.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Rack + # Request env keys + HTTP_HOST = 'HTTP_HOST' + HTTP_PORT = 'HTTP_PORT' + HTTPS = 'HTTPS' + PATH_INFO = 'PATH_INFO' + REQUEST_METHOD = 'REQUEST_METHOD' + REQUEST_PATH = 'REQUEST_PATH' + SCRIPT_NAME = 'SCRIPT_NAME' + QUERY_STRING = 'QUERY_STRING' + SERVER_PROTOCOL = 'SERVER_PROTOCOL' + SERVER_NAME = 'SERVER_NAME' + SERVER_PORT = 'SERVER_PORT' + HTTP_COOKIE = 'HTTP_COOKIE' + + # Response Header Keys + CACHE_CONTROL = 'cache-control' + CONTENT_LENGTH = 'content-length' + CONTENT_TYPE = 'content-type' + ETAG = 'etag' + EXPIRES = 'expires' + SET_COOKIE = 'set-cookie' + TRANSFER_ENCODING = 'transfer-encoding' + + # HTTP method verbs + GET = 'GET' + POST = 'POST' + PUT = 'PUT' + PATCH = 'PATCH' + DELETE = 'DELETE' + HEAD = 'HEAD' + OPTIONS = 'OPTIONS' + CONNECT = 'CONNECT' + LINK = 'LINK' + UNLINK = 'UNLINK' + TRACE = 'TRACE' + + # Rack environment variables + RACK_VERSION = 'rack.version' + RACK_TEMPFILES = 'rack.tempfiles' + RACK_EARLY_HINTS = 'rack.early_hints' + RACK_ERRORS = 'rack.errors' + RACK_LOGGER = 'rack.logger' + RACK_INPUT = 'rack.input' + RACK_SESSION = 'rack.session' + RACK_SESSION_OPTIONS = 'rack.session.options' + RACK_SHOWSTATUS_DETAIL = 'rack.showstatus.detail' + RACK_URL_SCHEME = 'rack.url_scheme' + RACK_HIJACK = 'rack.hijack' + RACK_IS_HIJACK = 'rack.hijack?' + RACK_RECURSIVE_INCLUDE = 'rack.recursive.include' + RACK_MULTIPART_BUFFER_SIZE = 'rack.multipart.buffer_size' + RACK_MULTIPART_TEMPFILE_FACTORY = 'rack.multipart.tempfile_factory' + RACK_RESPONSE_FINISHED = 'rack.response_finished' + RACK_REQUEST_FORM_INPUT = 'rack.request.form_input' + RACK_REQUEST_FORM_HASH = 'rack.request.form_hash' + RACK_REQUEST_FORM_PAIRS = 'rack.request.form_pairs' + RACK_REQUEST_FORM_VARS = 'rack.request.form_vars' + RACK_REQUEST_FORM_ERROR = 'rack.request.form_error' + RACK_REQUEST_COOKIE_HASH = 'rack.request.cookie_hash' + RACK_REQUEST_COOKIE_STRING = 'rack.request.cookie_string' + RACK_REQUEST_QUERY_HASH = 'rack.request.query_hash' + RACK_REQUEST_QUERY_STRING = 'rack.request.query_string' + RACK_METHODOVERRIDE_ORIGINAL_METHOD = 'rack.methodoverride.original_method' +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_length.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_length.rb new file mode 100644 index 0000000..cbac93a --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_length.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' + +module Rack + + # Sets the content-length header on responses that do not specify + # a content-length or transfer-encoding header. Note that this + # does not fix responses that have an invalid content-length + # header specified. + class ContentLength + include Rack::Utils + + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = response = @app.call(env) + + if !STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) && + !headers[CONTENT_LENGTH] && + !headers[TRANSFER_ENCODING] && + body.respond_to?(:to_ary) + + response[2] = body = body.to_ary + headers[CONTENT_LENGTH] = body.sum(&:bytesize).to_s + end + + response + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_type.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_type.rb new file mode 100644 index 0000000..19f0782 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_type.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' + +module Rack + + # Sets the content-type header on responses which don't have one. + # + # Builder Usage: + # use Rack::ContentType, "text/plain" + # + # When no content type argument is provided, "text/html" is the + # default. + class ContentType + include Rack::Utils + + def initialize(app, content_type = "text/html") + @app = app + @content_type = content_type + end + + def call(env) + status, headers, _ = response = @app.call(env) + + unless STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) + headers[CONTENT_TYPE] ||= @content_type + end + + response + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/deflater.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/deflater.rb new file mode 100644 index 0000000..cc01c32 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/deflater.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require "zlib" +require "time" # for Time.httpdate + +require_relative 'constants' +require_relative 'utils' +require_relative 'request' +require_relative 'body_proxy' + +module Rack + # This middleware enables content encoding of http responses, + # usually for purposes of compression. + # + # Currently supported encodings: + # + # * gzip + # * identity (no transformation) + # + # This middleware automatically detects when encoding is supported + # and allowed. For example no encoding is made when a cache + # directive of 'no-transform' is present, when the response status + # code is one that doesn't allow an entity body, or when the body + # is empty. + # + # Note that despite the name, Deflater does not support the +deflate+ + # encoding. + class Deflater + # Creates Rack::Deflater middleware. Options: + # + # :if :: a lambda enabling / disabling deflation based on returned boolean value + # (e.g use Rack::Deflater, :if => lambda { |*, body| sum=0; body.each { |i| sum += i.length }; sum > 512 }). + # However, be aware that calling `body.each` inside the block will break cases where `body.each` is not idempotent, + # such as when it is an +IO+ instance. + # :include :: a list of content types that should be compressed. By default, all content types are compressed. + # :sync :: determines if the stream is going to be flushed after every chunk. Flushing after every chunk reduces + # latency for time-sensitive streaming applications, but hurts compression and throughput. + # Defaults to +true+. + def initialize(app, options = {}) + @app = app + @condition = options[:if] + @compressible_types = options[:include] + @sync = options.fetch(:sync, true) + end + + def call(env) + status, headers, body = response = @app.call(env) + + unless should_deflate?(env, status, headers, body) + return response + end + + request = Request.new(env) + + encoding = Utils.select_best_encoding(%w(gzip identity), + request.accept_encoding) + + # Set the Vary HTTP header. + vary = headers["vary"].to_s.split(",").map(&:strip) + unless vary.include?("*") || vary.any?{|v| v.downcase == 'accept-encoding'} + headers["vary"] = vary.push("Accept-Encoding").join(",") + end + + case encoding + when "gzip" + headers['content-encoding'] = "gzip" + headers.delete(CONTENT_LENGTH) + mtime = headers["last-modified"] + mtime = Time.httpdate(mtime).to_i if mtime + response[2] = GzipStream.new(body, mtime, @sync) + response + when "identity" + response + else # when nil + # Only possible encoding values here are 'gzip', 'identity', and nil + message = "An acceptable encoding for the requested resource #{request.fullpath} could not be found." + bp = Rack::BodyProxy.new([message]) { body.close if body.respond_to?(:close) } + [406, { CONTENT_TYPE => "text/plain", CONTENT_LENGTH => message.length.to_s }, bp] + end + end + + # Body class used for gzip encoded responses. + class GzipStream + + BUFFER_LENGTH = 128 * 1_024 + + # Initialize the gzip stream. Arguments: + # body :: Response body to compress with gzip + # mtime :: The modification time of the body, used to set the + # modification time in the gzip header. + # sync :: Whether to flush each gzip chunk as soon as it is ready. + def initialize(body, mtime, sync) + @body = body + @mtime = mtime + @sync = sync + end + + # Yield gzip compressed strings to the given block. + def each(&block) + @writer = block + gzip = ::Zlib::GzipWriter.new(self) + gzip.mtime = @mtime if @mtime + # @body.each is equivalent to @body.gets (slow) + if @body.is_a? ::File # XXX: Should probably be ::IO + while part = @body.read(BUFFER_LENGTH) + gzip.write(part) + gzip.flush if @sync + end + else + @body.each { |part| + # Skip empty strings, as they would result in no output, + # and flushing empty parts would raise Zlib::BufError. + next if part.empty? + gzip.write(part) + gzip.flush if @sync + } + end + ensure + gzip.finish + end + + # Call the block passed to #each with the gzipped data. + def write(data) + @writer.call(data) + end + + # Close the original body if possible. + def close + @body.close if @body.respond_to?(:close) + end + end + + private + + # Whether the body should be compressed. + def should_deflate?(env, status, headers, body) + # Skip compressing empty entity body responses and responses with + # no-transform set. + if Utils::STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) || + /\bno-transform\b/.match?(headers[CACHE_CONTROL].to_s) || + headers['content-encoding']&.!~(/\bidentity\b/) + return false + end + + # Skip if @compressible_types are given and does not include request's content type + return false if @compressible_types && !(headers.has_key?(CONTENT_TYPE) && @compressible_types.include?(headers[CONTENT_TYPE][/[^;]*/])) + + # Skip if @condition lambda is given and evaluates to false + return false if @condition && !@condition.call(env, status, headers, body) + + # No point in compressing empty body, also handles usage with + # Rack::Sendfile. + return false if headers[CONTENT_LENGTH] == '0' + + true + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/directory.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/directory.rb new file mode 100644 index 0000000..089623f --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/directory.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'time' + +require_relative 'constants' +require_relative 'utils' +require_relative 'head' +require_relative 'mime' +require_relative 'files' + +module Rack + # Rack::Directory serves entries below the +root+ given, according to the + # path info of the Rack request. If a directory is found, the file's contents + # will be presented in an html based index. If a file is found, the env will + # be passed to the specified +app+. + # + # If +app+ is not specified, a Rack::Files of the same +root+ will be used. + + class Directory + DIR_FILE = "%s%s%s%s\n" + DIR_PAGE_HEADER = <<-PAGE + + %s + + + +

%s

+
+ + + + + + + + PAGE + DIR_PAGE_FOOTER = <<-PAGE +
NameSizeTypeLast Modified
+
+ + PAGE + + # Body class for directory entries, showing an index page with links + # to each file. + class DirectoryBody < Struct.new(:root, :path, :files) + # Yield strings for each part of the directory entry + def each + show_path = Utils.escape_html(path.sub(/^#{root}/, '')) + yield(DIR_PAGE_HEADER % [ show_path, show_path ]) + + unless path.chomp('/') == root + yield(DIR_FILE % DIR_FILE_escape(files.call('..'))) + end + + Dir.foreach(path) do |basename| + next if basename.start_with?('.') + next unless f = files.call(basename) + yield(DIR_FILE % DIR_FILE_escape(f)) + end + + yield(DIR_PAGE_FOOTER) + end + + private + + # Escape each element in the array of html strings. + def DIR_FILE_escape(htmls) + htmls.map { |e| Utils.escape_html(e) } + end + end + + # The root of the directory hierarchy. Only requests for files and + # directories inside of the root directory are supported. + attr_reader :root + + # Set the root directory and application for serving files. + def initialize(root, app = nil) + @root = ::File.expand_path(root) + @app = app || Files.new(@root) + @head = Head.new(method(:get)) + end + + def call(env) + # strip body if this is a HEAD call + @head.call env + end + + # Internals of request handling. Similar to call but does + # not remove body for HEAD requests. + def get(env) + script_name = env[SCRIPT_NAME] + path_info = Utils.unescape_path(env[PATH_INFO]) + + if client_error_response = check_bad_request(path_info) || check_forbidden(path_info) + client_error_response + else + path = ::File.join(@root, path_info) + list_path(env, path, path_info, script_name) + end + end + + # Rack response to use for requests with invalid paths, or nil if path is valid. + def check_bad_request(path_info) + return if Utils.valid_path?(path_info) + + body = "Bad Request\n" + [400, { CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.bytesize.to_s, + "x-cascade" => "pass" }, [body]] + end + + # Rack response to use for requests with paths outside the root, or nil if path is inside the root. + def check_forbidden(path_info) + return unless path_info.include? ".." + return if ::File.expand_path(::File.join(@root, path_info)).start_with?(@root) + + body = "Forbidden\n" + [403, { CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.bytesize.to_s, + "x-cascade" => "pass" }, [body]] + end + + # Rack response to use for directories under the root. + def list_directory(path_info, path, script_name) + url_head = (script_name.split('/') + path_info.split('/')).map do |part| + Utils.escape_path part + end + + # Globbing not safe as path could contain glob metacharacters + body = DirectoryBody.new(@root, path, ->(basename) do + stat = stat(::File.join(path, basename)) + next unless stat + + url = ::File.join(*url_head + [Utils.escape_path(basename)]) + mtime = stat.mtime.httpdate + if stat.directory? + type = 'directory' + size = '-' + url << '/' + if basename == '..' + basename = 'Parent Directory' + else + basename << '/' + end + else + type = Mime.mime_type(::File.extname(basename)) + size = filesize_format(stat.size) + end + + [ url, basename, size, type, mtime ] + end) + + [ 200, { CONTENT_TYPE => 'text/html; charset=utf-8' }, body ] + end + + # File::Stat for the given path, but return nil for missing/bad entries. + def stat(path) + ::File.stat(path) + rescue Errno::ENOENT, Errno::ELOOP + return nil + end + + # Rack response to use for files and directories under the root. + # Unreadable and non-file, non-directory entries will get a 404 response. + def list_path(env, path, path_info, script_name) + if (stat = stat(path)) && stat.readable? + return @app.call(env) if stat.file? + return list_directory(path_info, path, script_name) if stat.directory? + end + + entity_not_found(path_info) + end + + # Rack response to use for unreadable and non-file, non-directory entries. + def entity_not_found(path_info) + body = "Entity not found: #{path_info}\n" + [404, { CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.bytesize.to_s, + "x-cascade" => "pass" }, [body]] + end + + # Stolen from Ramaze + FILESIZE_FORMAT = [ + ['%.1fT', 1 << 40], + ['%.1fG', 1 << 30], + ['%.1fM', 1 << 20], + ['%.1fK', 1 << 10], + ] + + # Provide human readable file sizes + def filesize_format(int) + FILESIZE_FORMAT.each do |format, size| + return format % (int.to_f / size) if int >= size + end + + "#{int}B" + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/etag.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/etag.rb new file mode 100644 index 0000000..fa78b47 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/etag.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'digest/sha2' + +require_relative 'constants' +require_relative 'utils' + +module Rack + # Automatically sets the etag header on all String bodies. + # + # The etag header is skipped if etag or last-modified headers are sent or if + # a sendfile body (body.responds_to :to_path) is given (since such cases + # should be handled by apache/nginx). + # + # On initialization, you can pass two parameters: a cache-control directive + # used when etag is absent and a directive when it is present. The first + # defaults to nil, while the second defaults to "max-age=0, private, must-revalidate" + class ETag + ETAG_STRING = Rack::ETAG + DEFAULT_CACHE_CONTROL = "max-age=0, private, must-revalidate" + + def initialize(app, no_cache_control = nil, cache_control = DEFAULT_CACHE_CONTROL) + @app = app + @cache_control = cache_control + @no_cache_control = no_cache_control + end + + def call(env) + status, headers, body = response = @app.call(env) + + if etag_status?(status) && body.respond_to?(:to_ary) && !skip_caching?(headers) + body = body.to_ary + digest = digest_body(body) + headers[ETAG_STRING] = %(W/"#{digest}") if digest + end + + unless headers[CACHE_CONTROL] + if digest + headers[CACHE_CONTROL] = @cache_control if @cache_control + else + headers[CACHE_CONTROL] = @no_cache_control if @no_cache_control + end + end + + response + end + + private + + def etag_status?(status) + status == 200 || status == 201 + end + + def skip_caching?(headers) + headers.key?(ETAG_STRING) || headers.key?('last-modified') + end + + def digest_body(body) + digest = nil + + body.each do |part| + (digest ||= Digest::SHA256.new) << part unless part.empty? + end + + digest && digest.hexdigest.byteslice(0,32) + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/events.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/events.rb new file mode 100644 index 0000000..c7bb201 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/events.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require_relative 'body_proxy' +require_relative 'request' +require_relative 'response' + +module Rack + ### This middleware provides hooks to certain places in the request / + # response lifecycle. This is so that middleware that don't need to filter + # the response data can safely leave it alone and not have to send messages + # down the traditional "rack stack". + # + # The events are: + # + # * on_start(request, response) + # + # This event is sent at the start of the request, before the next + # middleware in the chain is called. This method is called with a request + # object, and a response object. Right now, the response object is always + # nil, but in the future it may actually be a real response object. + # + # * on_commit(request, response) + # + # The response has been committed. The application has returned, but the + # response has not been sent to the webserver yet. This method is always + # called with a request object and the response object. The response + # object is constructed from the rack triple that the application returned. + # Changes may still be made to the response object at this point. + # + # * on_send(request, response) + # + # The webserver has started iterating over the response body and presumably + # has started sending data over the wire. This method is always called with + # a request object and the response object. The response object is + # constructed from the rack triple that the application returned. Changes + # SHOULD NOT be made to the response object as the webserver has already + # started sending data. Any mutations will likely result in an exception. + # + # * on_finish(request, response) + # + # The webserver has closed the response, and all data has been written to + # the response socket. The request and response object should both be + # read-only at this point. The body MAY NOT be available on the response + # object as it may have been flushed to the socket. + # + # * on_error(request, response, error) + # + # An exception has occurred in the application or an `on_commit` event. + # This method will get the request, the response (if available) and the + # exception that was raised. + # + # ## Order + # + # `on_start` is called on the handlers in the order that they were passed to + # the constructor. `on_commit`, on_send`, `on_finish`, and `on_error` are + # called in the reverse order. `on_finish` handlers are called inside an + # `ensure` block, so they are guaranteed to be called even if something + # raises an exception. If something raises an exception in a `on_finish` + # method, then nothing is guaranteed. + + class Events + module Abstract + def on_start(req, res) + end + + def on_commit(req, res) + end + + def on_send(req, res) + end + + def on_finish(req, res) + end + + def on_error(req, res, e) + end + end + + class EventedBodyProxy < Rack::BodyProxy # :nodoc: + attr_reader :request, :response + + def initialize(body, request, response, handlers, &block) + super(body, &block) + @request = request + @response = response + @handlers = handlers + end + + def each + @handlers.reverse_each { |handler| handler.on_send request, response } + super + end + end + + class BufferedResponse < Rack::Response::Raw # :nodoc: + attr_reader :body + + def initialize(status, headers, body) + super(status, headers) + @body = body + end + + def to_a; [status, headers, body]; end + end + + def initialize(app, handlers) + @app = app + @handlers = handlers + end + + def call(env) + request = make_request env + on_start request, nil + + begin + status, headers, body = @app.call request.env + response = make_response status, headers, body + on_commit request, response + rescue StandardError => e + on_error request, response, e + on_finish request, response + raise + end + + body = EventedBodyProxy.new(body, request, response, @handlers) do + on_finish request, response + end + [response.status, response.headers, body] + end + + private + + def on_error(request, response, e) + @handlers.reverse_each { |handler| handler.on_error request, response, e } + end + + def on_commit(request, response) + @handlers.reverse_each { |handler| handler.on_commit request, response } + end + + def on_start(request, response) + @handlers.each { |handler| handler.on_start request, nil } + end + + def on_finish(request, response) + @handlers.reverse_each { |handler| handler.on_finish request, response } + end + + def make_request(env) + Rack::Request.new env + end + + def make_response(status, headers, body) + BufferedResponse.new status, headers, body + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/files.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/files.rb new file mode 100644 index 0000000..5b8353f --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/files.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require 'time' + +require_relative 'constants' +require_relative 'head' +require_relative 'utils' +require_relative 'request' +require_relative 'mime' + +module Rack + # Rack::Files serves files below the +root+ directory given, according to the + # path info of the Rack request. + # e.g. when Rack::Files.new("/etc") is used, you can access 'passwd' file + # as http://localhost:9292/passwd + # + # Handlers can detect if bodies are a Rack::Files, and use mechanisms + # like sendfile on the +path+. + + class Files + ALLOWED_VERBS = %w[GET HEAD OPTIONS] + ALLOW_HEADER = ALLOWED_VERBS.join(', ') + MULTIPART_BOUNDARY = 'AaB03x' + + attr_reader :root + + def initialize(root, headers = {}, default_mime = 'text/plain') + @root = (::File.expand_path(root) if root) + @headers = headers + @default_mime = default_mime + @head = Rack::Head.new(lambda { |env| get env }) + end + + def call(env) + # HEAD requests drop the response body, including 4xx error messages. + @head.call env + end + + def get(env) + request = Rack::Request.new env + unless ALLOWED_VERBS.include? request.request_method + return fail(405, "Method Not Allowed", { 'allow' => ALLOW_HEADER }) + end + + path_info = Utils.unescape_path request.path_info + return fail(400, "Bad Request") unless Utils.valid_path?(path_info) + + clean_path_info = Utils.clean_path_info(path_info) + path = ::File.join(@root, clean_path_info) + + available = begin + ::File.file?(path) && ::File.readable?(path) + rescue SystemCallError + # Not sure in what conditions this exception can occur, but this + # is a safe way to handle such an error. + # :nocov: + false + # :nocov: + end + + if available + serving(request, path) + else + fail(404, "File not found: #{path_info}") + end + end + + def serving(request, path) + if request.options? + return [200, { 'allow' => ALLOW_HEADER, CONTENT_LENGTH => '0' }, []] + end + last_modified = ::File.mtime(path).httpdate + return [304, {}, []] if request.get_header('HTTP_IF_MODIFIED_SINCE') == last_modified + + headers = { "last-modified" => last_modified } + mime_type = mime_type path, @default_mime + headers[CONTENT_TYPE] = mime_type if mime_type + + # Set custom headers + headers.merge!(@headers) if @headers + + status = 200 + size = filesize path + + ranges = Rack::Utils.get_byte_ranges(request.get_header('HTTP_RANGE'), size) + if ranges.nil? + # No ranges: + ranges = [0..size - 1] + elsif ranges.empty? + # Unsatisfiable. Return error, and file size: + response = fail(416, "Byte range unsatisfiable") + response[1]["content-range"] = "bytes */#{size}" + return response + else + # Partial content + partial_content = true + + if ranges.size == 1 + range = ranges[0] + headers["content-range"] = "bytes #{range.begin}-#{range.end}/#{size}" + else + headers[CONTENT_TYPE] = "multipart/byteranges; boundary=#{MULTIPART_BOUNDARY}" + end + + status = 206 + body = BaseIterator.new(path, ranges, mime_type: mime_type, size: size) + size = body.bytesize + end + + headers[CONTENT_LENGTH] = size.to_s + + if request.head? + body = [] + elsif !partial_content + body = Iterator.new(path, ranges, mime_type: mime_type, size: size) + end + + [status, headers, body] + end + + class BaseIterator + attr_reader :path, :ranges, :options + + def initialize(path, ranges, options) + @path = path + @ranges = ranges + @options = options + end + + def each + ::File.open(path, "rb") do |file| + ranges.each do |range| + yield multipart_heading(range) if multipart? + + each_range_part(file, range) do |part| + yield part + end + end + + yield "\r\n--#{MULTIPART_BOUNDARY}--\r\n" if multipart? + end + end + + def bytesize + size = ranges.inject(0) do |sum, range| + sum += multipart_heading(range).bytesize if multipart? + sum += range.size + end + size += "\r\n--#{MULTIPART_BOUNDARY}--\r\n".bytesize if multipart? + size + end + + def close; end + + private + + def multipart? + ranges.size > 1 + end + + def multipart_heading(range) +<<-EOF +\r +--#{MULTIPART_BOUNDARY}\r +content-type: #{options[:mime_type]}\r +content-range: bytes #{range.begin}-#{range.end}/#{options[:size]}\r +\r +EOF + end + + def each_range_part(file, range) + file.seek(range.begin) + remaining_len = range.end - range.begin + 1 + while remaining_len > 0 + part = file.read([8192, remaining_len].min) + break unless part + remaining_len -= part.length + + yield part + end + end + end + + class Iterator < BaseIterator + alias :to_path :path + end + + private + + def fail(status, body, headers = {}) + body += "\n" + + [ + status, + { + CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.size.to_s, + "x-cascade" => "pass" + }.merge!(headers), + [body] + ] + end + + # The MIME type for the contents of the file located at @path + def mime_type(path, default_mime) + Mime.mime_type(::File.extname(path), default_mime) + end + + def filesize(path) + # We check via File::size? whether this file provides size info + # via stat (e.g. /proc files often don't), otherwise we have to + # figure it out by reading the whole file into memory. + ::File.size?(path) || ::File.read(path).bytesize + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/head.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/head.rb new file mode 100644 index 0000000..c1c430f --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/head.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'body_proxy' + +module Rack + # Rack::Head returns an empty body for all HEAD requests. It leaves + # all other requests unchanged. + class Head + def initialize(app) + @app = app + end + + def call(env) + _, _, body = response = @app.call(env) + + if env[REQUEST_METHOD] == HEAD + response[2] = Rack::BodyProxy.new([]) do + body.close if body.respond_to? :close + end + end + + response + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/headers.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/headers.rb new file mode 100644 index 0000000..cedf3a8 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/headers.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +module Rack + # Rack::Headers is a Hash subclass that downcases all keys. It's designed + # to be used by rack applications that don't implement the Rack 3 SPEC + # (by using non-lowercase response header keys), automatically handling + # the downcasing of keys. + class Headers < Hash + KNOWN_HEADERS = {} + %w( + Accept-CH + Accept-Patch + Accept-Ranges + Access-Control-Allow-Credentials + Access-Control-Allow-Headers + Access-Control-Allow-Methods + Access-Control-Allow-Origin + Access-Control-Expose-Headers + Access-Control-Max-Age + Age + Allow + Alt-Svc + Cache-Control + Connection + Content-Disposition + Content-Encoding + Content-Language + Content-Length + Content-Location + Content-MD5 + Content-Range + Content-Security-Policy + Content-Security-Policy-Report-Only + Content-Type + Date + Delta-Base + ETag + Expect-CT + Expires + Feature-Policy + IM + Last-Modified + Link + Location + NEL + P3P + Permissions-Policy + Pragma + Preference-Applied + Proxy-Authenticate + Public-Key-Pins + Referrer-Policy + Refresh + Report-To + Retry-After + Server + Set-Cookie + Status + Strict-Transport-Security + Timing-Allow-Origin + Tk + Trailer + Transfer-Encoding + Upgrade + Vary + Via + WWW-Authenticate + Warning + X-Cascade + X-Content-Duration + X-Content-Security-Policy + X-Content-Type-Options + X-Correlation-ID + X-Correlation-Id + X-Download-Options + X-Frame-Options + X-Permitted-Cross-Domain-Policies + X-Powered-By + X-Redirect-By + X-Request-ID + X-Request-Id + X-Runtime + X-UA-Compatible + X-WebKit-CS + X-XSS-Protection + ).each do |str| + downcased = str.downcase.freeze + KNOWN_HEADERS[str] = KNOWN_HEADERS[downcased] = downcased + end + + def self.[](*items) + if items.length % 2 != 0 + if items.length == 1 && items.first.is_a?(Hash) + new.merge!(items.first) + else + raise ArgumentError, "odd number of arguments for Rack::Headers" + end + else + hash = new + loop do + break if items.length == 0 + key = items.shift + value = items.shift + hash[key] = value + end + hash + end + end + + def [](key) + super(downcase_key(key)) + end + + def []=(key, value) + super(KNOWN_HEADERS[key] || key.downcase.freeze, value) + end + alias store []= + + def assoc(key) + super(downcase_key(key)) + end + + def compare_by_identity + raise TypeError, "Rack::Headers cannot compare by identity, use regular Hash" + end + + def delete(key) + super(downcase_key(key)) + end + + def dig(key, *a) + super(downcase_key(key), *a) + end + + def fetch(key, *default, &block) + key = downcase_key(key) + super + end + + def fetch_values(*a) + super(*a.map!{|key| downcase_key(key)}) + end + + def has_key?(key) + super(downcase_key(key)) + end + alias include? has_key? + alias key? has_key? + alias member? has_key? + + def invert + hash = self.class.new + each{|key, value| hash[value] = key} + hash + end + + def merge(hash, &block) + dup.merge!(hash, &block) + end + + def reject(&block) + hash = dup + hash.reject!(&block) + hash + end + + def replace(hash) + clear + update(hash) + end + + def select(&block) + hash = dup + hash.select!(&block) + hash + end + + def to_proc + lambda{|x| self[x]} + end + + def transform_values(&block) + dup.transform_values!(&block) + end + + def update(hash, &block) + hash.each do |key, value| + self[key] = if block_given? && include?(key) + block.call(key, self[key], value) + else + value + end + end + self + end + alias merge! update + + def values_at(*keys) + keys.map{|key| self[key]} + end + + # :nocov: + if RUBY_VERSION >= '2.5' + # :nocov: + def slice(*a) + h = self.class.new + a.each{|k| h[k] = self[k] if has_key?(k)} + h + end + + def transform_keys(&block) + dup.transform_keys!(&block) + end + + def transform_keys! + hash = self.class.new + each do |k, v| + hash[yield k] = v + end + replace(hash) + end + end + + # :nocov: + if RUBY_VERSION >= '3.0' + # :nocov: + def except(*a) + super(*a.map!{|key| downcase_key(key)}) + end + end + + private + + def downcase_key(key) + key.is_a?(String) ? KNOWN_HEADERS[key] || key.downcase : key + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lint.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lint.rb new file mode 100644 index 0000000..4f36c2e --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lint.rb @@ -0,0 +1,991 @@ +# frozen_string_literal: true + +require 'forwardable' +require 'uri' + +require_relative 'constants' +require_relative 'utils' + +module Rack + # Rack::Lint validates your application and the requests and + # responses according to the Rack spec. + + class Lint + REQUEST_PATH_ORIGIN_FORM = /\A\/[^#]*\z/ + REQUEST_PATH_ABSOLUTE_FORM = /\A#{Utils::URI_PARSER.make_regexp}\z/ + REQUEST_PATH_AUTHORITY_FORM = /\A[^\/:]+:\d+\z/ + REQUEST_PATH_ASTERISK_FORM = '*' + + def initialize(app) + @app = app + end + + # :stopdoc: + + class LintError < RuntimeError; end + # AUTHORS: n.b. The trailing whitespace between paragraphs is important and + # should not be removed. The whitespace creates paragraphs in the RDoc + # output. + # + ## This specification aims to formalize the Rack protocol. You + ## can (and should) use Rack::Lint to enforce it. + ## + ## When you develop middleware, be sure to add a Lint before and + ## after to catch all mistakes. + ## + ## = Rack applications + ## + ## A Rack application is a Ruby object (not a class) that + ## responds to +call+. + def call(env = nil) + Wrapper.new(@app, env).response + end + + class Wrapper + def initialize(app, env) + @app = app + @env = env + @response = nil + @head_request = false + + @status = nil + @headers = nil + @body = nil + @invoked = nil + @content_length = nil + @closed = false + @size = 0 + end + + def response + ## It takes exactly one argument, the *environment* + raise LintError, "No env given" unless @env + check_environment(@env) + + ## and returns a non-frozen Array of exactly three values: + @response = @app.call(@env) + raise LintError, "response is not an Array, but #{@response.class}" unless @response.kind_of? Array + raise LintError, "response is frozen" if @response.frozen? + raise LintError, "response array has #{@response.size} elements instead of 3" unless @response.size == 3 + + @status, @headers, @body = @response + ## The *status*, + check_status(@status) + + ## the *headers*, + check_headers(@headers) + + hijack_proc = check_hijack_response(@headers, @env) + if hijack_proc + @headers[RACK_HIJACK] = hijack_proc + end + + ## and the *body*. + check_content_type_header(@status, @headers) + check_content_length_header(@status, @headers) + check_rack_protocol_header(@status, @headers) + @head_request = @env[REQUEST_METHOD] == HEAD + + @lint = (@env['rack.lint'] ||= []) << self + + if (@env['rack.lint.body_iteration'] ||= 0) > 0 + raise LintError, "Middleware must not call #each directly" + end + + return [@status, @headers, self] + end + + ## + ## == The Environment + ## + def check_environment(env) + ## The environment must be an unfrozen instance of Hash that includes + ## CGI-like headers. The Rack application is free to modify the + ## environment. + raise LintError, "env #{env.inspect} is not a Hash, but #{env.class}" unless env.kind_of? Hash + raise LintError, "env should not be frozen, but is" if env.frozen? + + ## + ## The environment is required to include these variables + ## (adopted from {PEP 333}[https://peps.python.org/pep-0333/]), except when they'd be empty, but see + ## below. + + ## REQUEST_METHOD:: The HTTP request method, such as + ## "GET" or "POST". This cannot ever + ## be an empty string, and so is + ## always required. + + ## SCRIPT_NAME:: The initial portion of the request + ## URL's "path" that corresponds to the + ## application object, so that the + ## application knows its virtual + ## "location". This may be an empty + ## string, if the application corresponds + ## to the "root" of the server. + + ## PATH_INFO:: The remainder of the request URL's + ## "path", designating the virtual + ## "location" of the request's target + ## within the application. This may be an + ## empty string, if the request URL targets + ## the application root and does not have a + ## trailing slash. This value may be + ## percent-encoded when originating from + ## a URL. + + ## QUERY_STRING:: The portion of the request URL that + ## follows the ?, if any. May be + ## empty, but is always required! + + ## SERVER_NAME:: When combined with SCRIPT_NAME and + ## PATH_INFO, these variables can be + ## used to complete the URL. Note, however, + ## that HTTP_HOST, if present, + ## should be used in preference to + ## SERVER_NAME for reconstructing + ## the request URL. + ## SERVER_NAME can never be an empty + ## string, and so is always required. + + ## SERVER_PORT:: An optional +Integer+ which is the port the + ## server is running on. Should be specified if + ## the server is running on a non-standard port. + + ## SERVER_PROTOCOL:: A string representing the HTTP version used + ## for the request. + + ## HTTP_ Variables:: Variables corresponding to the + ## client-supplied HTTP request + ## headers (i.e., variables whose + ## names begin with HTTP_). The + ## presence or absence of these + ## variables should correspond with + ## the presence or absence of the + ## appropriate HTTP header in the + ## request. See + ## {RFC3875 section 4.1.18}[https://tools.ietf.org/html/rfc3875#section-4.1.18] + ## for specific behavior. + + ## In addition to this, the Rack environment must include these + ## Rack-specific variables: + + ## rack.url_scheme:: +http+ or +https+, depending on the + ## request URL. + + ## rack.input:: See below, the input stream. + + ## rack.errors:: See below, the error stream. + + ## rack.hijack?:: See below, if present and true, indicates + ## that the server supports partial hijacking. + + ## rack.hijack:: See below, if present, an object responding + ## to +call+ that is used to perform a full + ## hijack. + + ## rack.protocol:: An optional +Array+ of +String+, containing + ## the protocols advertised by the client in + ## the +upgrade+ header (HTTP/1) or the + ## +:protocol+ pseudo-header (HTTP/2). + if protocols = @env['rack.protocol'] + unless protocols.is_a?(Array) && protocols.all?{|protocol| protocol.is_a?(String)} + raise LintError, "rack.protocol must be an Array of Strings" + end + end + + ## Additional environment specifications have approved to + ## standardized middleware APIs. None of these are required to + ## be implemented by the server. + + ## rack.session:: A hash-like interface for storing + ## request session data. + ## The store must implement: + if session = env[RACK_SESSION] + ## store(key, value) (aliased as []=); + unless session.respond_to?(:store) && session.respond_to?(:[]=) + raise LintError, "session #{session.inspect} must respond to store and []=" + end + + ## fetch(key, default = nil) (aliased as []); + unless session.respond_to?(:fetch) && session.respond_to?(:[]) + raise LintError, "session #{session.inspect} must respond to fetch and []" + end + + ## delete(key); + unless session.respond_to?(:delete) + raise LintError, "session #{session.inspect} must respond to delete" + end + + ## clear; + unless session.respond_to?(:clear) + raise LintError, "session #{session.inspect} must respond to clear" + end + + ## to_hash (returning unfrozen Hash instance); + unless session.respond_to?(:to_hash) && session.to_hash.kind_of?(Hash) && !session.to_hash.frozen? + raise LintError, "session #{session.inspect} must respond to to_hash and return unfrozen Hash instance" + end + end + + ## rack.logger:: A common object interface for logging messages. + ## The object must implement: + if logger = env[RACK_LOGGER] + ## info(message, &block) + unless logger.respond_to?(:info) + raise LintError, "logger #{logger.inspect} must respond to info" + end + + ## debug(message, &block) + unless logger.respond_to?(:debug) + raise LintError, "logger #{logger.inspect} must respond to debug" + end + + ## warn(message, &block) + unless logger.respond_to?(:warn) + raise LintError, "logger #{logger.inspect} must respond to warn" + end + + ## error(message, &block) + unless logger.respond_to?(:error) + raise LintError, "logger #{logger.inspect} must respond to error" + end + + ## fatal(message, &block) + unless logger.respond_to?(:fatal) + raise LintError, "logger #{logger.inspect} must respond to fatal" + end + end + + ## rack.multipart.buffer_size:: An Integer hint to the multipart parser as to what chunk size to use for reads and writes. + if bufsize = env[RACK_MULTIPART_BUFFER_SIZE] + unless bufsize.is_a?(Integer) && bufsize > 0 + raise LintError, "rack.multipart.buffer_size must be an Integer > 0 if specified" + end + end + + ## rack.multipart.tempfile_factory:: An object responding to #call with two arguments, the filename and content_type given for the multipart form field, and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate the tempfile for each multipart form file upload field, rather than the default class of Tempfile. + if tempfile_factory = env[RACK_MULTIPART_TEMPFILE_FACTORY] + raise LintError, "rack.multipart.tempfile_factory must respond to #call" unless tempfile_factory.respond_to?(:call) + env[RACK_MULTIPART_TEMPFILE_FACTORY] = lambda do |filename, content_type| + io = tempfile_factory.call(filename, content_type) + raise LintError, "rack.multipart.tempfile_factory return value must respond to #<<" unless io.respond_to?(:<<) + io + end + end + + ## The server or the application can store their own data in the + ## environment, too. The keys must contain at least one dot, + ## and should be prefixed uniquely. The prefix rack. + ## is reserved for use with the Rack core distribution and other + ## accepted specifications and must not be used otherwise. + ## + %w[REQUEST_METHOD SERVER_NAME QUERY_STRING SERVER_PROTOCOL rack.errors].each do |header| + raise LintError, "env missing required key #{header}" unless env.include? header + end + + ## The SERVER_PORT must be an Integer if set. + server_port = env["SERVER_PORT"] + unless server_port.nil? || (Integer(server_port) rescue false) + raise LintError, "env[SERVER_PORT] is not an Integer" + end + + ## The SERVER_NAME must be a valid authority as defined by RFC7540. + unless (URI.parse("http://#{env[SERVER_NAME]}/") rescue false) + raise LintError, "#{env[SERVER_NAME]} must be a valid authority" + end + + ## The HTTP_HOST must be a valid authority as defined by RFC7540. + unless (URI.parse("http://#{env[HTTP_HOST]}/") rescue false) + raise LintError, "#{env[HTTP_HOST]} must be a valid authority" + end + + ## The SERVER_PROTOCOL must match the regexp HTTP/\d(\.\d)?. + server_protocol = env['SERVER_PROTOCOL'] + unless %r{HTTP/\d(\.\d)?}.match?(server_protocol) + raise LintError, "env[SERVER_PROTOCOL] does not match HTTP/\\d(\\.\\d)?" + end + + ## The environment must not contain the keys + ## HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH + ## (use the versions without HTTP_). + %w[HTTP_CONTENT_TYPE HTTP_CONTENT_LENGTH].each { |header| + if env.include? header + raise LintError, "env contains #{header}, must use #{header[5..-1]}" + end + } + + ## The CGI keys (named without a period) must have String values. + ## If the string values for CGI keys contain non-ASCII characters, + ## they should use ASCII-8BIT encoding. + env.each { |key, value| + next if key.include? "." # Skip extensions + unless value.kind_of? String + raise LintError, "env variable #{key} has non-string value #{value.inspect}" + end + next if value.encoding == Encoding::ASCII_8BIT + unless value.b !~ /[\x80-\xff]/n + raise LintError, "env variable #{key} has value containing non-ASCII characters and has non-ASCII-8BIT encoding #{value.inspect} encoding: #{value.encoding}" + end + } + + ## There are the following restrictions: + + ## * rack.url_scheme must either be +http+ or +https+. + unless %w[http https].include?(env[RACK_URL_SCHEME]) + raise LintError, "rack.url_scheme unknown: #{env[RACK_URL_SCHEME].inspect}" + end + + ## * There may be a valid input stream in rack.input. + if rack_input = env[RACK_INPUT] + check_input_stream(rack_input) + @env[RACK_INPUT] = InputWrapper.new(rack_input) + end + + ## * There must be a valid error stream in rack.errors. + rack_errors = env[RACK_ERRORS] + check_error_stream(rack_errors) + @env[RACK_ERRORS] = ErrorWrapper.new(rack_errors) + + ## * There may be a valid hijack callback in rack.hijack + check_hijack env + ## * There may be a valid early hints callback in rack.early_hints + check_early_hints env + + ## * The REQUEST_METHOD must be a valid token. + unless env[REQUEST_METHOD] =~ /\A[0-9A-Za-z!\#$%&'*+.^_`|~-]+\z/ + raise LintError, "REQUEST_METHOD unknown: #{env[REQUEST_METHOD].dump}" + end + + ## * The SCRIPT_NAME, if non-empty, must start with / + if env.include?(SCRIPT_NAME) && env[SCRIPT_NAME] != "" && env[SCRIPT_NAME] !~ /\A\// + raise LintError, "SCRIPT_NAME must start with /" + end + + ## * The PATH_INFO, if provided, must be a valid request target or an empty string. + if env.include?(PATH_INFO) + case env[PATH_INFO] + when REQUEST_PATH_ASTERISK_FORM + ## * Only OPTIONS requests may have PATH_INFO set to * (asterisk-form). + unless env[REQUEST_METHOD] == OPTIONS + raise LintError, "Only OPTIONS requests may have PATH_INFO set to '*' (asterisk-form)" + end + when REQUEST_PATH_AUTHORITY_FORM + ## * Only CONNECT requests may have PATH_INFO set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. + unless env[REQUEST_METHOD] == CONNECT + raise LintError, "Only CONNECT requests may have PATH_INFO set to an authority (authority-form)" + end + when REQUEST_PATH_ABSOLUTE_FORM + ## * CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form). + if env[REQUEST_METHOD] == CONNECT || env[REQUEST_METHOD] == OPTIONS + raise LintError, "CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form)" + end + when REQUEST_PATH_ORIGIN_FORM + ## * Otherwise, PATH_INFO must start with a / and must not include a fragment part starting with '#' (origin-form). + when "" + # Empty string is okay. + else + raise LintError, "PATH_INFO must start with a '/' and must not include a fragment part starting with '#' (origin-form)" + end + end + + ## * The CONTENT_LENGTH, if given, must consist of digits only. + if env.include?("CONTENT_LENGTH") && env["CONTENT_LENGTH"] !~ /\A\d+\z/ + raise LintError, "Invalid CONTENT_LENGTH: #{env["CONTENT_LENGTH"]}" + end + + ## * One of SCRIPT_NAME or PATH_INFO must be + ## set. PATH_INFO should be / if + ## SCRIPT_NAME is empty. + unless env[SCRIPT_NAME] || env[PATH_INFO] + raise LintError, "One of SCRIPT_NAME or PATH_INFO must be set (make PATH_INFO '/' if SCRIPT_NAME is empty)" + end + ## SCRIPT_NAME never should be /, but instead be empty. + unless env[SCRIPT_NAME] != "/" + raise LintError, "SCRIPT_NAME cannot be '/', make it '' and PATH_INFO '/'" + end + + ## rack.response_finished:: An array of callables run by the server after the response has been + ## processed. This would typically be invoked after sending the response to the client, but it could also be + ## invoked if an error occurs while generating the response or sending the response; in that case, the error + ## argument will be a subclass of +Exception+. + ## The callables are invoked with +env, status, headers, error+ arguments and should not raise any + ## exceptions. They should be invoked in reverse order of registration. + if callables = env[RACK_RESPONSE_FINISHED] + raise LintError, "rack.response_finished must be an array of callable objects" unless callables.is_a?(Array) + + callables.each do |callable| + raise LintError, "rack.response_finished values must respond to call(env, status, headers, error)" unless callable.respond_to?(:call) + end + end + end + + ## + ## === The Input Stream + ## + ## The input stream is an IO-like object which contains the raw HTTP + ## POST data. + def check_input_stream(input) + ## When applicable, its external encoding must be "ASCII-8BIT" and it + ## must be opened in binary mode. + if input.respond_to?(:external_encoding) && input.external_encoding != Encoding::ASCII_8BIT + raise LintError, "rack.input #{input} does not have ASCII-8BIT as its external encoding" + end + if input.respond_to?(:binmode?) && !input.binmode? + raise LintError, "rack.input #{input} is not opened in binary mode" + end + + ## The input stream must respond to +gets+, +each+, and +read+. + [:gets, :each, :read].each { |method| + unless input.respond_to? method + raise LintError, "rack.input #{input} does not respond to ##{method}" + end + } + end + + class InputWrapper + def initialize(input) + @input = input + end + + ## * +gets+ must be called without arguments and return a string, + ## or +nil+ on EOF. + def gets(*args) + raise LintError, "rack.input#gets called with arguments" unless args.size == 0 + v = @input.gets + unless v.nil? or v.kind_of? String + raise LintError, "rack.input#gets didn't return a String" + end + v + end + + ## * +read+ behaves like IO#read. + ## Its signature is read([length, [buffer]]). + ## + ## If given, +length+ must be a non-negative Integer (>= 0) or +nil+, + ## and +buffer+ must be a String and may not be nil. + ## + ## If +length+ is given and not nil, then this method reads at most + ## +length+ bytes from the input stream. + ## + ## If +length+ is not given or nil, then this method reads + ## all data until EOF. + ## + ## When EOF is reached, this method returns nil if +length+ is given + ## and not nil, or "" if +length+ is not given or is nil. + ## + ## If +buffer+ is given, then the read data will be placed + ## into +buffer+ instead of a newly created String object. + def read(*args) + unless args.size <= 2 + raise LintError, "rack.input#read called with too many arguments" + end + if args.size >= 1 + unless args.first.kind_of?(Integer) || args.first.nil? + raise LintError, "rack.input#read called with non-integer and non-nil length" + end + unless args.first.nil? || args.first >= 0 + raise LintError, "rack.input#read called with a negative length" + end + end + if args.size >= 2 + unless args[1].kind_of?(String) + raise LintError, "rack.input#read called with non-String buffer" + end + end + + v = @input.read(*args) + + unless v.nil? or v.kind_of? String + raise LintError, "rack.input#read didn't return nil or a String" + end + if args[0].nil? + unless !v.nil? + raise LintError, "rack.input#read(nil) returned nil on EOF" + end + end + + v + end + + ## * +each+ must be called without arguments and only yield Strings. + def each(*args) + raise LintError, "rack.input#each called with arguments" unless args.size == 0 + @input.each { |line| + unless line.kind_of? String + raise LintError, "rack.input#each didn't yield a String" + end + yield line + } + end + + ## * +close+ can be called on the input stream to indicate that + ## any remaining input is not needed. + def close(*args) + @input.close(*args) + end + end + + ## + ## === The Error Stream + ## + def check_error_stream(error) + ## The error stream must respond to +puts+, +write+ and +flush+. + [:puts, :write, :flush].each { |method| + unless error.respond_to? method + raise LintError, "rack.error #{error} does not respond to ##{method}" + end + } + end + + class ErrorWrapper + def initialize(error) + @error = error + end + + ## * +puts+ must be called with a single argument that responds to +to_s+. + def puts(str) + @error.puts str + end + + ## * +write+ must be called with a single argument that is a String. + def write(str) + raise LintError, "rack.errors#write not called with a String" unless str.kind_of? String + @error.write str + end + + ## * +flush+ must be called without arguments and must be called + ## in order to make the error appear for sure. + def flush + @error.flush + end + + ## * +close+ must never be called on the error stream. + def close(*args) + raise LintError, "rack.errors#close must not be called" + end + end + + ## + ## === Hijacking + ## + ## The hijacking interfaces provides a means for an application to take + ## control of the HTTP connection. There are two distinct hijack + ## interfaces: full hijacking where the application takes over the raw + ## connection, and partial hijacking where the application takes over + ## just the response body stream. In both cases, the application is + ## responsible for closing the hijacked stream. + ## + ## Full hijacking only works with HTTP/1. Partial hijacking is functionally + ## equivalent to streaming bodies, and is still optionally supported for + ## backwards compatibility with older Rack versions. + ## + ## ==== Full Hijack + ## + ## Full hijack is used to completely take over an HTTP/1 connection. It + ## occurs before any headers are written and causes the request to + ## ignores any response generated by the application. + ## + ## It is intended to be used when applications need access to raw HTTP/1 + ## connection. + ## + def check_hijack(env) + ## If +rack.hijack+ is present in +env+, it must respond to +call+ + if original_hijack = env[RACK_HIJACK] + raise LintError, "rack.hijack must respond to call" unless original_hijack.respond_to?(:call) + + env[RACK_HIJACK] = proc do + io = original_hijack.call + + ## and return an +IO+ instance which can be used to read and write + ## to the underlying connection using HTTP/1 semantics and + ## formatting. + raise LintError, "rack.hijack must return an IO instance" unless io.is_a?(IO) + + io + end + end + end + + ## + ## ==== Partial Hijack + ## + ## Partial hijack is used for bi-directional streaming of the request and + ## response body. It occurs after the status and headers are written by + ## the server and causes the server to ignore the Body of the response. + ## + ## It is intended to be used when applications need bi-directional + ## streaming. + ## + def check_hijack_response(headers, env) + ## If +rack.hijack?+ is present in +env+ and truthy, + if env[RACK_IS_HIJACK] + ## an application may set the special response header +rack.hijack+ + if original_hijack = headers[RACK_HIJACK] + ## to an object that responds to +call+, + unless original_hijack.respond_to?(:call) + raise LintError, 'rack.hijack header must respond to #call' + end + ## accepting a +stream+ argument. + return proc do |io| + original_hijack.call StreamWrapper.new(io) + end + end + ## + ## After the response status and headers have been sent, this hijack + ## callback will be invoked with a +stream+ argument which follows the + ## same interface as outlined in "Streaming Body". Servers must + ## ignore the +body+ part of the response tuple when the + ## +rack.hijack+ response header is present. Using an empty +Array+ + ## instance is recommended. + else + ## + ## The special response header +rack.hijack+ must only be set + ## if the request +env+ has a truthy +rack.hijack?+. + if headers.key?(RACK_HIJACK) + raise LintError, 'rack.hijack header must not be present if server does not support hijacking' + end + end + + nil + end + + ## + ## === Early Hints + ## + ## The application or any middleware may call the rack.early_hints + ## with an object which would be valid as the headers of a Rack response. + def check_early_hints(env) + if env[RACK_EARLY_HINTS] + ## + ## If rack.early_hints is present, it must respond to #call. + unless env[RACK_EARLY_HINTS].respond_to?(:call) + raise LintError, "rack.early_hints must respond to call" + end + + original_callback = env[RACK_EARLY_HINTS] + env[RACK_EARLY_HINTS] = lambda do |headers| + ## If rack.early_hints is called, it must be called with + ## valid Rack response headers. + check_headers(headers) + original_callback.call(headers) + end + end + end + + ## + ## == The Response + ## + ## === The Status + ## + def check_status(status) + ## This is an HTTP status. It must be an Integer greater than or equal to + ## 100. + unless status.is_a?(Integer) && status >= 100 + raise LintError, "Status must be an Integer >=100" + end + end + + ## + ## === The Headers + ## + def check_headers(headers) + ## The headers must be a unfrozen Hash. + unless headers.kind_of?(Hash) + raise LintError, "headers object should be a hash, but isn't (got #{headers.class} as headers)" + end + + if headers.frozen? + raise LintError, "headers object should not be frozen, but is" + end + + headers.each do |key, value| + ## The header keys must be Strings. + unless key.kind_of? String + raise LintError, "header key must be a string, was #{key.class}" + end + + ## Special headers starting "rack." are for communicating with the + ## server, and must not be sent back to the client. + next if key.start_with?("rack.") + + ## The header must not contain a +Status+ key. + raise LintError, "header must not contain status" if key == "status" + ## Header keys must conform to RFC7230 token specification, i.e. cannot + ## contain non-printable ASCII, DQUOTE or "(),/:;<=>?@[\]{}". + raise LintError, "invalid header name: #{key}" if key =~ /[\(\),\/:;<=>\?@\[\\\]{}[:cntrl:]]/ + ## Header keys must not contain uppercase ASCII characters (A-Z). + raise LintError, "uppercase character in header name: #{key}" if key =~ /[A-Z]/ + + ## Header values must be either a String instance, + if value.kind_of?(String) + check_header_value(key, value) + elsif value.kind_of?(Array) + ## or an Array of String instances, + value.each{|value| check_header_value(key, value)} + else + raise LintError, "a header value must be a String or Array of Strings, but the value of '#{key}' is a #{value.class}" + end + end + end + + def check_header_value(key, value) + ## such that each String instance must not contain characters below 037. + if value =~ /[\000-\037]/ + raise LintError, "invalid header value #{key}: #{value.inspect}" + end + end + + ## + ## ==== The +content-type+ Header + ## + def check_content_type_header(status, headers) + headers.each { |key, value| + ## There must not be a content-type header key when the +Status+ is 1xx, + ## 204, or 304. + if key == "content-type" + if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i + raise LintError, "content-type header found in #{status} response, not allowed" + end + return + end + } + end + + ## + ## ==== The +content-length+ Header + ## + def check_content_length_header(status, headers) + headers.each { |key, value| + if key == 'content-length' + ## There must not be a content-length header key when the + ## +Status+ is 1xx, 204, or 304. + if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i + raise LintError, "content-length header found in #{status} response, not allowed" + end + @content_length = value + end + } + end + + def verify_content_length(size) + if @head_request + unless size == 0 + raise LintError, "Response body was given for HEAD request, but should be empty" + end + elsif @content_length + unless @content_length == size.to_s + raise LintError, "content-length header was #{@content_length}, but should be #{size}" + end + end + end + + ## + ## ==== The +rack.protocol+ Header + ## + def check_rack_protocol_header(status, headers) + ## If the +rack.protocol+ header is present, it must be a +String+, and + ## must be one of the values from the +rack.protocol+ array from the + ## environment. + protocol = headers['rack.protocol'] + + if protocol + request_protocols = @env['rack.protocol'] + + if request_protocols.nil? + raise LintError, "rack.protocol header is #{protocol.inspect}, but rack.protocol was not set in request!" + elsif !request_protocols.include?(protocol) + raise LintError, "rack.protocol header is #{protocol.inspect}, but should be one of #{request_protocols.inspect} from the request!" + end + end + end + ## + ## Setting this value informs the server that it should perform a + ## connection upgrade. In HTTP/1, this is done using the +upgrade+ + ## header. In HTTP/2, this is done by accepting the request. + ## + ## === The Body + ## + ## The Body is typically an +Array+ of +String+ instances, an enumerable + ## that yields +String+ instances, a +Proc+ instance, or a File-like + ## object. + ## + ## The Body must respond to +each+ or +call+. It may optionally respond + ## to +to_path+ or +to_ary+. A Body that responds to +each+ is considered + ## to be an Enumerable Body. A Body that responds to +call+ is considered + ## to be a Streaming Body. + ## + ## A Body that responds to both +each+ and +call+ must be treated as an + ## Enumerable Body, not a Streaming Body. If it responds to +each+, you + ## must call +each+ and not +call+. If the Body doesn't respond to + ## +each+, then you can assume it responds to +call+. + ## + ## The Body must either be consumed or returned. The Body is consumed by + ## optionally calling either +each+ or +call+. + ## Then, if the Body responds to +close+, it must be called to release + ## any resources associated with the generation of the body. + ## In other words, +close+ must always be called at least once; typically + ## after the web server has sent the response to the client, but also in + ## cases where the Rack application makes internal/virtual requests and + ## discards the response. + ## + def close + ## + ## After calling +close+, the Body is considered closed and should not + ## be consumed again. + @closed = true + + ## If the original Body is replaced by a new Body, the new Body must + ## also consume the original Body by calling +close+ if possible. + @body.close if @body.respond_to?(:close) + + index = @lint.index(self) + unless @env['rack.lint'][0..index].all? {|lint| lint.instance_variable_get(:@closed)} + raise LintError, "Body has not been closed" + end + end + + def verify_to_path + ## + ## If the Body responds to +to_path+, it must return a +String+ + ## path for the local file system whose contents are identical + ## to that produced by calling +each+; this may be used by the + ## server as an alternative, possibly more efficient way to + ## transport the response. The +to_path+ method does not consume + ## the body. + if @body.respond_to?(:to_path) + unless ::File.exist? @body.to_path + raise LintError, "The file identified by body.to_path does not exist" + end + end + end + + ## + ## ==== Enumerable Body + ## + def each + ## The Enumerable Body must respond to +each+. + raise LintError, "Enumerable Body must respond to each" unless @body.respond_to?(:each) + + ## It must only be called once. + raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? + + ## It must not be called after being closed, + raise LintError, "Response body is already closed" if @closed + + @invoked = :each + + @body.each do |chunk| + ## and must only yield String values. + unless chunk.kind_of? String + raise LintError, "Body yielded non-string value #{chunk.inspect}" + end + + ## + ## Middleware must not call +each+ directly on the Body. + ## Instead, middleware can return a new Body that calls +each+ on the + ## original Body, yielding at least once per iteration. + if @lint[0] == self + @env['rack.lint.body_iteration'] += 1 + else + if (@env['rack.lint.body_iteration'] -= 1) > 0 + raise LintError, "New body must yield at least once per iteration of old body" + end + end + + @size += chunk.bytesize + yield chunk + end + + verify_content_length(@size) + + verify_to_path + end + + BODY_METHODS = {to_ary: true, each: true, call: true, to_path: true} + + def to_path + @body.to_path + end + + def respond_to?(name, *) + if BODY_METHODS.key?(name) + @body.respond_to?(name) + else + super + end + end + + ## + ## If the Body responds to +to_ary+, it must return an +Array+ whose + ## contents are identical to that produced by calling +each+. + ## Middleware may call +to_ary+ directly on the Body and return a new + ## Body in its place. In other words, middleware can only process the + ## Body directly if it responds to +to_ary+. If the Body responds to both + ## +to_ary+ and +close+, its implementation of +to_ary+ must call + ## +close+. + def to_ary + @body.to_ary.tap do |content| + unless content == @body.enum_for.to_a + raise LintError, "#to_ary not identical to contents produced by calling #each" + end + end + ensure + close + end + + ## + ## ==== Streaming Body + ## + def call(stream) + ## The Streaming Body must respond to +call+. + raise LintError, "Streaming Body must respond to call" unless @body.respond_to?(:call) + + ## It must only be called once. + raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? + + ## It must not be called after being closed. + raise LintError, "Response body is already closed" if @closed + + @invoked = :call + + ## It takes a +stream+ argument. + ## + ## The +stream+ argument must implement: + ## read, write, <<, flush, close, close_read, close_write, closed? + ## + @body.call(StreamWrapper.new(stream)) + end + + class StreamWrapper + extend Forwardable + + ## The semantics of these IO methods must be a best effort match to + ## those of a normal Ruby IO or Socket object, using standard arguments + ## and raising standard exceptions. Servers are encouraged to simply + ## pass on real IO objects, although it is recognized that this approach + ## is not directly compatible with HTTP/2. + REQUIRED_METHODS = [ + :read, :write, :<<, :flush, :close, + :close_read, :close_write, :closed? + ] + + def_delegators :@stream, *REQUIRED_METHODS + + def initialize(stream) + @stream = stream + + REQUIRED_METHODS.each do |method_name| + raise LintError, "Stream must respond to #{method_name}" unless stream.respond_to?(method_name) + end + end + end + + # :startdoc: + end + end +end + +## +## == Thanks +## Some parts of this specification are adopted from {PEP 333 – Python Web Server Gateway Interface v1.0}[https://peps.python.org/pep-0333/] +## I'd like to thank everyone involved in that effort. diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lock.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lock.rb new file mode 100644 index 0000000..342123a --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lock.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative 'body_proxy' + +module Rack + # Rack::Lock locks every request inside a mutex, so that every request + # will effectively be executed synchronously. + class Lock + def initialize(app, mutex = Mutex.new) + @app, @mutex = app, mutex + end + + def call(env) + @mutex.lock + begin + response = @app.call(env) + returned = response << BodyProxy.new(response.pop) { unlock } + ensure + unlock unless returned + end + end + + private + + def unlock + @mutex.unlock + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/logger.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/logger.rb new file mode 100644 index 0000000..081212d --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/logger.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'logger' +require_relative 'constants' + +warn "Rack::Logger is deprecated and will be removed in Rack 3.2.", uplevel: 1 + +module Rack + # Sets up rack.logger to write to rack.errors stream + class Logger + def initialize(app, level = ::Logger::INFO) + @app, @level = app, level + end + + def call(env) + logger = ::Logger.new(env[RACK_ERRORS]) + logger.level = @level + + env[RACK_LOGGER] = logger + @app.call(env) + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/media_type.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/media_type.rb new file mode 100644 index 0000000..7fc1e39 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/media_type.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Rack + # Rack::MediaType parse media type and parameters out of content_type string + + class MediaType + SPLIT_PATTERN = /[;,]/ + + class << self + # The media type (type/subtype) portion of the CONTENT_TYPE header + # without any media type parameters. e.g., when CONTENT_TYPE is + # "text/plain;charset=utf-8", the media-type is "text/plain". + # + # For more information on the use of media types in HTTP, see: + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 + def type(content_type) + return nil unless content_type + if type = content_type.split(SPLIT_PATTERN, 2).first + type.rstrip! + type.downcase! + type + end + end + + # The media type parameters provided in CONTENT_TYPE as a Hash, or + # an empty Hash if no CONTENT_TYPE or media-type parameters were + # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", + # this method responds with the following Hash: + # { 'charset' => 'utf-8' } + def params(content_type) + return {} if content_type.nil? + + content_type.split(SPLIT_PATTERN)[1..-1].each_with_object({}) do |s, hsh| + s.strip! + k, v = s.split('=', 2) + k.downcase! + hsh[k] = strip_doublequotes(v) + end + end + + private + + def strip_doublequotes(str) + (str.start_with?('"') && str.end_with?('"')) ? str[1..-2] : str + end + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/method_override.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/method_override.rb new file mode 100644 index 0000000..6125b19 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/method_override.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'request' +require_relative 'utils' + +module Rack + class MethodOverride + HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK] + + METHOD_OVERRIDE_PARAM_KEY = "_method" + HTTP_METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE" + ALLOWED_METHODS = %w[POST] + + def initialize(app) + @app = app + end + + def call(env) + if allowed_methods.include?(env[REQUEST_METHOD]) + method = method_override(env) + if HTTP_METHODS.include?(method) + env[RACK_METHODOVERRIDE_ORIGINAL_METHOD] = env[REQUEST_METHOD] + env[REQUEST_METHOD] = method + end + end + + @app.call(env) + end + + def method_override(env) + req = Request.new(env) + method = method_override_param(req) || + env[HTTP_METHOD_OVERRIDE_HEADER] + begin + method.to_s.upcase + rescue ArgumentError + env[RACK_ERRORS].puts "Invalid string for method" + end + end + + private + + def allowed_methods + ALLOWED_METHODS + end + + def method_override_param(req) + req.POST[METHOD_OVERRIDE_PARAM_KEY] if req.form_data? || req.parseable_data? + rescue Utils::InvalidParameterError, Utils::ParameterTypeError, QueryParser::ParamsTooDeepError + req.get_header(RACK_ERRORS).puts "Invalid or incomplete POST params" + rescue EOFError + req.get_header(RACK_ERRORS).puts "Bad request content body" + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mime.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mime.rb new file mode 100644 index 0000000..0272968 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mime.rb @@ -0,0 +1,694 @@ +# frozen_string_literal: true + +module Rack + module Mime + # Returns String with mime type if found, otherwise use +fallback+. + # +ext+ should be filename extension in the '.ext' format that + # File.extname(file) returns. + # +fallback+ may be any object + # + # Also see the documentation for MIME_TYPES + # + # Usage: + # Rack::Mime.mime_type('.foo') + # + # This is a shortcut for: + # Rack::Mime::MIME_TYPES.fetch('.foo', 'application/octet-stream') + + def mime_type(ext, fallback = 'application/octet-stream') + MIME_TYPES.fetch(ext.to_s.downcase, fallback) + end + module_function :mime_type + + # Returns true if the given value is a mime match for the given mime match + # specification, false otherwise. + # + # Rack::Mime.match?('text/html', 'text/*') => true + # Rack::Mime.match?('text/plain', '*') => true + # Rack::Mime.match?('text/html', 'application/json') => false + + def match?(value, matcher) + v1, v2 = value.split('/', 2) + m1, m2 = matcher.split('/', 2) + + (m1 == '*' || v1 == m1) && (m2.nil? || m2 == '*' || m2 == v2) + end + module_function :match? + + # List of most common mime-types, selected various sources + # according to their usefulness in a webserving scope for Ruby + # users. + # + # To amend this list with your local mime.types list you can use: + # + # require 'webrick/httputils' + # list = WEBrick::HTTPUtils.load_mime_types('/etc/mime.types') + # Rack::Mime::MIME_TYPES.merge!(list) + # + # N.B. On Ubuntu the mime.types file does not include the leading period, so + # users may need to modify the data before merging into the hash. + + MIME_TYPES = { + ".123" => "application/vnd.lotus-1-2-3", + ".3dml" => "text/vnd.in3d.3dml", + ".3g2" => "video/3gpp2", + ".3gp" => "video/3gpp", + ".a" => "application/octet-stream", + ".acc" => "application/vnd.americandynamics.acc", + ".ace" => "application/x-ace-compressed", + ".acu" => "application/vnd.acucobol", + ".aep" => "application/vnd.audiograph", + ".afp" => "application/vnd.ibm.modcap", + ".ai" => "application/postscript", + ".aif" => "audio/x-aiff", + ".aiff" => "audio/x-aiff", + ".ami" => "application/vnd.amiga.ami", + ".apng" => "image/apng", + ".appcache" => "text/cache-manifest", + ".apr" => "application/vnd.lotus-approach", + ".asc" => "application/pgp-signature", + ".asf" => "video/x-ms-asf", + ".asm" => "text/x-asm", + ".aso" => "application/vnd.accpac.simply.aso", + ".asx" => "video/x-ms-asf", + ".atc" => "application/vnd.acucorp", + ".atom" => "application/atom+xml", + ".atomcat" => "application/atomcat+xml", + ".atomsvc" => "application/atomsvc+xml", + ".atx" => "application/vnd.antix.game-component", + ".au" => "audio/basic", + ".avi" => "video/x-msvideo", + ".avif" => "image/avif", + ".bat" => "application/x-msdownload", + ".bcpio" => "application/x-bcpio", + ".bdm" => "application/vnd.syncml.dm+wbxml", + ".bh2" => "application/vnd.fujitsu.oasysprs", + ".bin" => "application/octet-stream", + ".bmi" => "application/vnd.bmi", + ".bmp" => "image/bmp", + ".box" => "application/vnd.previewsystems.box", + ".btif" => "image/prs.btif", + ".bz" => "application/x-bzip", + ".bz2" => "application/x-bzip2", + ".c" => "text/x-c", + ".c4g" => "application/vnd.clonk.c4group", + ".cab" => "application/vnd.ms-cab-compressed", + ".cc" => "text/x-c", + ".ccxml" => "application/ccxml+xml", + ".cdbcmsg" => "application/vnd.contact.cmsg", + ".cdkey" => "application/vnd.mediastation.cdkey", + ".cdx" => "chemical/x-cdx", + ".cdxml" => "application/vnd.chemdraw+xml", + ".cdy" => "application/vnd.cinderella", + ".cer" => "application/pkix-cert", + ".cgm" => "image/cgm", + ".chat" => "application/x-chat", + ".chm" => "application/vnd.ms-htmlhelp", + ".chrt" => "application/vnd.kde.kchart", + ".cif" => "chemical/x-cif", + ".cii" => "application/vnd.anser-web-certificate-issue-initiation", + ".cil" => "application/vnd.ms-artgalry", + ".cla" => "application/vnd.claymore", + ".class" => "application/octet-stream", + ".clkk" => "application/vnd.crick.clicker.keyboard", + ".clkp" => "application/vnd.crick.clicker.palette", + ".clkt" => "application/vnd.crick.clicker.template", + ".clkw" => "application/vnd.crick.clicker.wordbank", + ".clkx" => "application/vnd.crick.clicker", + ".clp" => "application/x-msclip", + ".cmc" => "application/vnd.cosmocaller", + ".cmdf" => "chemical/x-cmdf", + ".cml" => "chemical/x-cml", + ".cmp" => "application/vnd.yellowriver-custom-menu", + ".cmx" => "image/x-cmx", + ".com" => "application/x-msdownload", + ".conf" => "text/plain", + ".cpio" => "application/x-cpio", + ".cpp" => "text/x-c", + ".cpt" => "application/mac-compactpro", + ".crd" => "application/x-mscardfile", + ".crl" => "application/pkix-crl", + ".crt" => "application/x-x509-ca-cert", + ".csh" => "application/x-csh", + ".csml" => "chemical/x-csml", + ".csp" => "application/vnd.commonspace", + ".css" => "text/css", + ".csv" => "text/csv", + ".curl" => "application/vnd.curl", + ".cww" => "application/prs.cww", + ".cxx" => "text/x-c", + ".daf" => "application/vnd.mobius.daf", + ".davmount" => "application/davmount+xml", + ".dcr" => "application/x-director", + ".dd2" => "application/vnd.oma.dd2+xml", + ".ddd" => "application/vnd.fujixerox.ddd", + ".deb" => "application/x-debian-package", + ".der" => "application/x-x509-ca-cert", + ".dfac" => "application/vnd.dreamfactory", + ".diff" => "text/x-diff", + ".dis" => "application/vnd.mobius.dis", + ".djv" => "image/vnd.djvu", + ".djvu" => "image/vnd.djvu", + ".dll" => "application/x-msdownload", + ".dmg" => "application/octet-stream", + ".dna" => "application/vnd.dna", + ".doc" => "application/msword", + ".docm" => "application/vnd.ms-word.document.macroEnabled.12", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".dot" => "application/msword", + ".dotm" => "application/vnd.ms-word.template.macroEnabled.12", + ".dotx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + ".dp" => "application/vnd.osgi.dp", + ".dpg" => "application/vnd.dpgraph", + ".dsc" => "text/prs.lines.tag", + ".dtd" => "application/xml-dtd", + ".dts" => "audio/vnd.dts", + ".dtshd" => "audio/vnd.dts.hd", + ".dv" => "video/x-dv", + ".dvi" => "application/x-dvi", + ".dwf" => "model/vnd.dwf", + ".dwg" => "image/vnd.dwg", + ".dxf" => "image/vnd.dxf", + ".dxp" => "application/vnd.spotfire.dxp", + ".ear" => "application/java-archive", + ".ecelp4800" => "audio/vnd.nuera.ecelp4800", + ".ecelp7470" => "audio/vnd.nuera.ecelp7470", + ".ecelp9600" => "audio/vnd.nuera.ecelp9600", + ".ecma" => "application/ecmascript", + ".edm" => "application/vnd.novadigm.edm", + ".edx" => "application/vnd.novadigm.edx", + ".efif" => "application/vnd.picsel", + ".ei6" => "application/vnd.pg.osasli", + ".eml" => "message/rfc822", + ".eol" => "audio/vnd.digital-winds", + ".eot" => "application/vnd.ms-fontobject", + ".eps" => "application/postscript", + ".es3" => "application/vnd.eszigno3+xml", + ".esf" => "application/vnd.epson.esf", + ".etx" => "text/x-setext", + ".exe" => "application/x-msdownload", + ".ext" => "application/vnd.novadigm.ext", + ".ez" => "application/andrew-inset", + ".ez2" => "application/vnd.ezpix-album", + ".ez3" => "application/vnd.ezpix-package", + ".f" => "text/x-fortran", + ".f77" => "text/x-fortran", + ".f90" => "text/x-fortran", + ".fbs" => "image/vnd.fastbidsheet", + ".fdf" => "application/vnd.fdf", + ".fe_launch" => "application/vnd.denovo.fcselayout-link", + ".fg5" => "application/vnd.fujitsu.oasysgp", + ".fli" => "video/x-fli", + ".flif" => "image/flif", + ".flo" => "application/vnd.micrografx.flo", + ".flv" => "video/x-flv", + ".flw" => "application/vnd.kde.kivio", + ".flx" => "text/vnd.fmi.flexstor", + ".fly" => "text/vnd.fly", + ".fm" => "application/vnd.framemaker", + ".fnc" => "application/vnd.frogans.fnc", + ".for" => "text/x-fortran", + ".fpx" => "image/vnd.fpx", + ".fsc" => "application/vnd.fsc.weblaunch", + ".fst" => "image/vnd.fst", + ".ftc" => "application/vnd.fluxtime.clip", + ".fti" => "application/vnd.anser-web-funds-transfer-initiation", + ".fvt" => "video/vnd.fvt", + ".fzs" => "application/vnd.fuzzysheet", + ".g3" => "image/g3fax", + ".gac" => "application/vnd.groove-account", + ".gdl" => "model/vnd.gdl", + ".gem" => "application/octet-stream", + ".gemspec" => "text/x-script.ruby", + ".ghf" => "application/vnd.groove-help", + ".gif" => "image/gif", + ".gim" => "application/vnd.groove-identity-message", + ".gmx" => "application/vnd.gmx", + ".gph" => "application/vnd.flographit", + ".gqf" => "application/vnd.grafeq", + ".gram" => "application/srgs", + ".grv" => "application/vnd.groove-injector", + ".grxml" => "application/srgs+xml", + ".gtar" => "application/x-gtar", + ".gtm" => "application/vnd.groove-tool-message", + ".gtw" => "model/vnd.gtw", + ".gv" => "text/vnd.graphviz", + ".gz" => "application/x-gzip", + ".h" => "text/x-c", + ".h261" => "video/h261", + ".h263" => "video/h263", + ".h264" => "video/h264", + ".hbci" => "application/vnd.hbci", + ".hdf" => "application/x-hdf", + ".heic" => "image/heic", + ".heics" => "image/heic-sequence", + ".heif" => "image/heif", + ".heifs" => "image/heif-sequence", + ".hh" => "text/x-c", + ".hlp" => "application/winhlp", + ".hpgl" => "application/vnd.hp-hpgl", + ".hpid" => "application/vnd.hp-hpid", + ".hps" => "application/vnd.hp-hps", + ".hqx" => "application/mac-binhex40", + ".htc" => "text/x-component", + ".htke" => "application/vnd.kenameaapp", + ".htm" => "text/html", + ".html" => "text/html", + ".hvd" => "application/vnd.yamaha.hv-dic", + ".hvp" => "application/vnd.yamaha.hv-voice", + ".hvs" => "application/vnd.yamaha.hv-script", + ".icc" => "application/vnd.iccprofile", + ".ice" => "x-conference/x-cooltalk", + ".ico" => "image/vnd.microsoft.icon", + ".ics" => "text/calendar", + ".ief" => "image/ief", + ".ifb" => "text/calendar", + ".ifm" => "application/vnd.shana.informed.formdata", + ".igl" => "application/vnd.igloader", + ".igs" => "model/iges", + ".igx" => "application/vnd.micrografx.igx", + ".iif" => "application/vnd.shana.informed.interchange", + ".imp" => "application/vnd.accpac.simply.imp", + ".ims" => "application/vnd.ms-ims", + ".ipk" => "application/vnd.shana.informed.package", + ".irm" => "application/vnd.ibm.rights-management", + ".irp" => "application/vnd.irepository.package+xml", + ".iso" => "application/octet-stream", + ".itp" => "application/vnd.shana.informed.formtemplate", + ".ivp" => "application/vnd.immervision-ivp", + ".ivu" => "application/vnd.immervision-ivu", + ".jad" => "text/vnd.sun.j2me.app-descriptor", + ".jam" => "application/vnd.jam", + ".jar" => "application/java-archive", + ".java" => "text/x-java-source", + ".jisp" => "application/vnd.jisp", + ".jlt" => "application/vnd.hp-jlyt", + ".jnlp" => "application/x-java-jnlp-file", + ".joda" => "application/vnd.joost.joda-archive", + ".jp2" => "image/jp2", + ".jpeg" => "image/jpeg", + ".jpg" => "image/jpeg", + ".jpgv" => "video/jpeg", + ".jpm" => "video/jpm", + ".js" => "text/javascript", + ".json" => "application/json", + ".karbon" => "application/vnd.kde.karbon", + ".kfo" => "application/vnd.kde.kformula", + ".kia" => "application/vnd.kidspiration", + ".kml" => "application/vnd.google-earth.kml+xml", + ".kmz" => "application/vnd.google-earth.kmz", + ".kne" => "application/vnd.kinar", + ".kon" => "application/vnd.kde.kontour", + ".kpr" => "application/vnd.kde.kpresenter", + ".ksp" => "application/vnd.kde.kspread", + ".ktz" => "application/vnd.kahootz", + ".kwd" => "application/vnd.kde.kword", + ".latex" => "application/x-latex", + ".lbd" => "application/vnd.llamagraphics.life-balance.desktop", + ".lbe" => "application/vnd.llamagraphics.life-balance.exchange+xml", + ".les" => "application/vnd.hhe.lesson-player", + ".link66" => "application/vnd.route66.link66+xml", + ".log" => "text/plain", + ".lostxml" => "application/lost+xml", + ".lrm" => "application/vnd.ms-lrm", + ".ltf" => "application/vnd.frogans.ltf", + ".lvp" => "audio/vnd.lucent.voice", + ".lwp" => "application/vnd.lotus-wordpro", + ".m3u" => "audio/x-mpegurl", + ".m3u8" => "application/x-mpegurl", + ".m4a" => "audio/mp4a-latm", + ".m4v" => "video/mp4", + ".ma" => "application/mathematica", + ".mag" => "application/vnd.ecowin.chart", + ".man" => "text/troff", + ".manifest" => "text/cache-manifest", + ".mathml" => "application/mathml+xml", + ".mbk" => "application/vnd.mobius.mbk", + ".mbox" => "application/mbox", + ".mc1" => "application/vnd.medcalcdata", + ".mcd" => "application/vnd.mcd", + ".mdb" => "application/x-msaccess", + ".mdi" => "image/vnd.ms-modi", + ".mdoc" => "text/troff", + ".me" => "text/troff", + ".mfm" => "application/vnd.mfmp", + ".mgz" => "application/vnd.proteus.magazine", + ".mid" => "audio/midi", + ".midi" => "audio/midi", + ".mif" => "application/vnd.mif", + ".mime" => "message/rfc822", + ".mj2" => "video/mj2", + ".mjs" => "text/javascript", + ".mlp" => "application/vnd.dolby.mlp", + ".mmd" => "application/vnd.chipnuts.karaoke-mmd", + ".mmf" => "application/vnd.smaf", + ".mml" => "application/mathml+xml", + ".mmr" => "image/vnd.fujixerox.edmics-mmr", + ".mng" => "video/x-mng", + ".mny" => "application/x-msmoney", + ".mov" => "video/quicktime", + ".movie" => "video/x-sgi-movie", + ".mp3" => "audio/mpeg", + ".mp4" => "video/mp4", + ".mp4a" => "audio/mp4", + ".mp4s" => "application/mp4", + ".mp4v" => "video/mp4", + ".mpc" => "application/vnd.mophun.certificate", + ".mpd" => "application/dash+xml", + ".mpeg" => "video/mpeg", + ".mpg" => "video/mpeg", + ".mpga" => "audio/mpeg", + ".mpkg" => "application/vnd.apple.installer+xml", + ".mpm" => "application/vnd.blueice.multipass", + ".mpn" => "application/vnd.mophun.application", + ".mpp" => "application/vnd.ms-project", + ".mpy" => "application/vnd.ibm.minipay", + ".mqy" => "application/vnd.mobius.mqy", + ".mrc" => "application/marc", + ".ms" => "text/troff", + ".mscml" => "application/mediaservercontrol+xml", + ".mseq" => "application/vnd.mseq", + ".msf" => "application/vnd.epson.msf", + ".msh" => "model/mesh", + ".msi" => "application/x-msdownload", + ".msl" => "application/vnd.mobius.msl", + ".msty" => "application/vnd.muvee.style", + ".mts" => "model/vnd.mts", + ".mus" => "application/vnd.musician", + ".mvb" => "application/x-msmediaview", + ".mwf" => "application/vnd.mfer", + ".mxf" => "application/mxf", + ".mxl" => "application/vnd.recordare.musicxml", + ".mxml" => "application/xv+xml", + ".mxs" => "application/vnd.triscape.mxs", + ".mxu" => "video/vnd.mpegurl", + ".n" => "application/vnd.nokia.n-gage.symbian.install", + ".nc" => "application/x-netcdf", + ".ngdat" => "application/vnd.nokia.n-gage.data", + ".nlu" => "application/vnd.neurolanguage.nlu", + ".nml" => "application/vnd.enliven", + ".nnd" => "application/vnd.noblenet-directory", + ".nns" => "application/vnd.noblenet-sealer", + ".nnw" => "application/vnd.noblenet-web", + ".npx" => "image/vnd.net-fpx", + ".nsf" => "application/vnd.lotus-notes", + ".oa2" => "application/vnd.fujitsu.oasys2", + ".oa3" => "application/vnd.fujitsu.oasys3", + ".oas" => "application/vnd.fujitsu.oasys", + ".obd" => "application/x-msbinder", + ".oda" => "application/oda", + ".odc" => "application/vnd.oasis.opendocument.chart", + ".odf" => "application/vnd.oasis.opendocument.formula", + ".odg" => "application/vnd.oasis.opendocument.graphics", + ".odi" => "application/vnd.oasis.opendocument.image", + ".odp" => "application/vnd.oasis.opendocument.presentation", + ".ods" => "application/vnd.oasis.opendocument.spreadsheet", + ".odt" => "application/vnd.oasis.opendocument.text", + ".oga" => "audio/ogg", + ".ogg" => "application/ogg", + ".ogv" => "video/ogg", + ".ogx" => "application/ogg", + ".org" => "application/vnd.lotus-organizer", + ".otc" => "application/vnd.oasis.opendocument.chart-template", + ".otf" => "font/otf", + ".otg" => "application/vnd.oasis.opendocument.graphics-template", + ".oth" => "application/vnd.oasis.opendocument.text-web", + ".oti" => "application/vnd.oasis.opendocument.image-template", + ".otm" => "application/vnd.oasis.opendocument.text-master", + ".ots" => "application/vnd.oasis.opendocument.spreadsheet-template", + ".ott" => "application/vnd.oasis.opendocument.text-template", + ".oxt" => "application/vnd.openofficeorg.extension", + ".p" => "text/x-pascal", + ".p10" => "application/pkcs10", + ".p12" => "application/x-pkcs12", + ".p7b" => "application/x-pkcs7-certificates", + ".p7m" => "application/pkcs7-mime", + ".p7r" => "application/x-pkcs7-certreqresp", + ".p7s" => "application/pkcs7-signature", + ".pas" => "text/x-pascal", + ".pbd" => "application/vnd.powerbuilder6", + ".pbm" => "image/x-portable-bitmap", + ".pcl" => "application/vnd.hp-pcl", + ".pclxl" => "application/vnd.hp-pclxl", + ".pcx" => "image/x-pcx", + ".pdb" => "chemical/x-pdb", + ".pdf" => "application/pdf", + ".pem" => "application/x-x509-ca-cert", + ".pfr" => "application/font-tdpfr", + ".pgm" => "image/x-portable-graymap", + ".pgn" => "application/x-chess-pgn", + ".pgp" => "application/pgp-encrypted", + ".pic" => "image/x-pict", + ".pict" => "image/pict", + ".pkg" => "application/octet-stream", + ".pki" => "application/pkixcmp", + ".pkipath" => "application/pkix-pkipath", + ".pl" => "text/x-script.perl", + ".plb" => "application/vnd.3gpp.pic-bw-large", + ".plc" => "application/vnd.mobius.plc", + ".plf" => "application/vnd.pocketlearn", + ".pls" => "application/pls+xml", + ".pm" => "text/x-script.perl-module", + ".pml" => "application/vnd.ctc-posml", + ".png" => "image/png", + ".pnm" => "image/x-portable-anymap", + ".pntg" => "image/x-macpaint", + ".portpkg" => "application/vnd.macports.portpkg", + ".pot" => "application/vnd.ms-powerpoint", + ".potm" => "application/vnd.ms-powerpoint.template.macroEnabled.12", + ".potx" => "application/vnd.openxmlformats-officedocument.presentationml.template", + ".ppa" => "application/vnd.ms-powerpoint", + ".ppam" => "application/vnd.ms-powerpoint.addin.macroEnabled.12", + ".ppd" => "application/vnd.cups-ppd", + ".ppm" => "image/x-portable-pixmap", + ".pps" => "application/vnd.ms-powerpoint", + ".ppsm" => "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", + ".ppsx" => "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + ".ppt" => "application/vnd.ms-powerpoint", + ".pptm" => "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".prc" => "application/vnd.palm", + ".pre" => "application/vnd.lotus-freelance", + ".prf" => "application/pics-rules", + ".ps" => "application/postscript", + ".psb" => "application/vnd.3gpp.pic-bw-small", + ".psd" => "image/vnd.adobe.photoshop", + ".ptid" => "application/vnd.pvi.ptid1", + ".pub" => "application/x-mspublisher", + ".pvb" => "application/vnd.3gpp.pic-bw-var", + ".pwn" => "application/vnd.3m.post-it-notes", + ".py" => "text/x-script.python", + ".pya" => "audio/vnd.ms-playready.media.pya", + ".pyv" => "video/vnd.ms-playready.media.pyv", + ".qam" => "application/vnd.epson.quickanime", + ".qbo" => "application/vnd.intu.qbo", + ".qfx" => "application/vnd.intu.qfx", + ".qps" => "application/vnd.publishare-delta-tree", + ".qt" => "video/quicktime", + ".qtif" => "image/x-quicktime", + ".qxd" => "application/vnd.quark.quarkxpress", + ".ra" => "audio/x-pn-realaudio", + ".rake" => "text/x-script.ruby", + ".ram" => "audio/x-pn-realaudio", + ".rar" => "application/x-rar-compressed", + ".ras" => "image/x-cmu-raster", + ".rb" => "text/x-script.ruby", + ".rcprofile" => "application/vnd.ipunplugged.rcprofile", + ".rdf" => "application/rdf+xml", + ".rdz" => "application/vnd.data-vision.rdz", + ".rep" => "application/vnd.businessobjects", + ".rgb" => "image/x-rgb", + ".rif" => "application/reginfo+xml", + ".rl" => "application/resource-lists+xml", + ".rlc" => "image/vnd.fujixerox.edmics-rlc", + ".rld" => "application/resource-lists-diff+xml", + ".rm" => "application/vnd.rn-realmedia", + ".rmp" => "audio/x-pn-realaudio-plugin", + ".rms" => "application/vnd.jcp.javame.midlet-rms", + ".rnc" => "application/relax-ng-compact-syntax", + ".roff" => "text/troff", + ".rpm" => "application/x-redhat-package-manager", + ".rpss" => "application/vnd.nokia.radio-presets", + ".rpst" => "application/vnd.nokia.radio-preset", + ".rq" => "application/sparql-query", + ".rs" => "application/rls-services+xml", + ".rsd" => "application/rsd+xml", + ".rss" => "application/rss+xml", + ".rtf" => "application/rtf", + ".rtx" => "text/richtext", + ".ru" => "text/x-script.ruby", + ".s" => "text/x-asm", + ".saf" => "application/vnd.yamaha.smaf-audio", + ".sbml" => "application/sbml+xml", + ".sc" => "application/vnd.ibm.secure-container", + ".scd" => "application/x-msschedule", + ".scm" => "application/vnd.lotus-screencam", + ".scq" => "application/scvp-cv-request", + ".scs" => "application/scvp-cv-response", + ".sdkm" => "application/vnd.solent.sdkm+xml", + ".sdp" => "application/sdp", + ".see" => "application/vnd.seemail", + ".sema" => "application/vnd.sema", + ".semd" => "application/vnd.semd", + ".semf" => "application/vnd.semf", + ".setpay" => "application/set-payment-initiation", + ".setreg" => "application/set-registration-initiation", + ".sfd" => "application/vnd.hydrostatix.sof-data", + ".sfs" => "application/vnd.spotfire.sfs", + ".sgm" => "text/sgml", + ".sgml" => "text/sgml", + ".sh" => "application/x-sh", + ".shar" => "application/x-shar", + ".shf" => "application/shf+xml", + ".sig" => "application/pgp-signature", + ".sit" => "application/x-stuffit", + ".sitx" => "application/x-stuffitx", + ".skp" => "application/vnd.koan", + ".slt" => "application/vnd.epson.salt", + ".smi" => "application/smil+xml", + ".snd" => "audio/basic", + ".so" => "application/octet-stream", + ".spf" => "application/vnd.yamaha.smaf-phrase", + ".spl" => "application/x-futuresplash", + ".spot" => "text/vnd.in3d.spot", + ".spp" => "application/scvp-vp-response", + ".spq" => "application/scvp-vp-request", + ".src" => "application/x-wais-source", + ".srt" => "text/srt", + ".srx" => "application/sparql-results+xml", + ".sse" => "application/vnd.kodak-descriptor", + ".ssf" => "application/vnd.epson.ssf", + ".ssml" => "application/ssml+xml", + ".stf" => "application/vnd.wt.stf", + ".stk" => "application/hyperstudio", + ".str" => "application/vnd.pg.format", + ".sus" => "application/vnd.sus-calendar", + ".sv4cpio" => "application/x-sv4cpio", + ".sv4crc" => "application/x-sv4crc", + ".svd" => "application/vnd.svd", + ".svg" => "image/svg+xml", + ".svgz" => "image/svg+xml", + ".swf" => "application/x-shockwave-flash", + ".swi" => "application/vnd.arastra.swi", + ".t" => "text/troff", + ".tao" => "application/vnd.tao.intent-module-archive", + ".tar" => "application/x-tar", + ".tbz" => "application/x-bzip-compressed-tar", + ".tcap" => "application/vnd.3gpp2.tcap", + ".tcl" => "application/x-tcl", + ".tex" => "application/x-tex", + ".texi" => "application/x-texinfo", + ".texinfo" => "application/x-texinfo", + ".text" => "text/plain", + ".tif" => "image/tiff", + ".tiff" => "image/tiff", + ".tmo" => "application/vnd.tmobile-livetv", + ".torrent" => "application/x-bittorrent", + ".tpl" => "application/vnd.groove-tool-template", + ".tpt" => "application/vnd.trid.tpt", + ".tr" => "text/troff", + ".tra" => "application/vnd.trueapp", + ".trm" => "application/x-msterminal", + ".ts" => "video/mp2t", + ".tsv" => "text/tab-separated-values", + ".ttf" => "font/ttf", + ".twd" => "application/vnd.simtech-mindmapper", + ".txd" => "application/vnd.genomatix.tuxedo", + ".txf" => "application/vnd.mobius.txf", + ".txt" => "text/plain", + ".ufd" => "application/vnd.ufdl", + ".umj" => "application/vnd.umajin", + ".unityweb" => "application/vnd.unity", + ".uoml" => "application/vnd.uoml+xml", + ".uri" => "text/uri-list", + ".ustar" => "application/x-ustar", + ".utz" => "application/vnd.uiq.theme", + ".uu" => "text/x-uuencode", + ".vcd" => "application/x-cdlink", + ".vcf" => "text/x-vcard", + ".vcg" => "application/vnd.groove-vcard", + ".vcs" => "text/x-vcalendar", + ".vcx" => "application/vnd.vcx", + ".vis" => "application/vnd.visionary", + ".viv" => "video/vnd.vivo", + ".vrml" => "model/vrml", + ".vsd" => "application/vnd.visio", + ".vsf" => "application/vnd.vsf", + ".vtt" => "text/vtt", + ".vtu" => "model/vnd.vtu", + ".vxml" => "application/voicexml+xml", + ".war" => "application/java-archive", + ".wasm" => "application/wasm", + ".wav" => "audio/x-wav", + ".wax" => "audio/x-ms-wax", + ".wbmp" => "image/vnd.wap.wbmp", + ".wbs" => "application/vnd.criticaltools.wbs+xml", + ".wbxml" => "application/vnd.wap.wbxml", + ".webm" => "video/webm", + ".webp" => "image/webp", + ".wm" => "video/x-ms-wm", + ".wma" => "audio/x-ms-wma", + ".wmd" => "application/x-ms-wmd", + ".wmf" => "application/x-msmetafile", + ".wml" => "text/vnd.wap.wml", + ".wmlc" => "application/vnd.wap.wmlc", + ".wmls" => "text/vnd.wap.wmlscript", + ".wmlsc" => "application/vnd.wap.wmlscriptc", + ".wmv" => "video/x-ms-wmv", + ".wmx" => "video/x-ms-wmx", + ".wmz" => "application/x-ms-wmz", + ".woff" => "font/woff", + ".woff2" => "font/woff2", + ".wpd" => "application/vnd.wordperfect", + ".wpl" => "application/vnd.ms-wpl", + ".wps" => "application/vnd.ms-works", + ".wqd" => "application/vnd.wqd", + ".wri" => "application/x-mswrite", + ".wrl" => "model/vrml", + ".wsdl" => "application/wsdl+xml", + ".wspolicy" => "application/wspolicy+xml", + ".wtb" => "application/vnd.webturbo", + ".wvx" => "video/x-ms-wvx", + ".x3d" => "application/vnd.hzn-3d-crossword", + ".xar" => "application/vnd.xara", + ".xbd" => "application/vnd.fujixerox.docuworks.binder", + ".xbm" => "image/x-xbitmap", + ".xdm" => "application/vnd.syncml.dm+xml", + ".xdp" => "application/vnd.adobe.xdp+xml", + ".xdw" => "application/vnd.fujixerox.docuworks", + ".xenc" => "application/xenc+xml", + ".xer" => "application/patch-ops-error+xml", + ".xfdf" => "application/vnd.adobe.xfdf", + ".xfdl" => "application/vnd.xfdl", + ".xhtml" => "application/xhtml+xml", + ".xif" => "image/vnd.xiff", + ".xla" => "application/vnd.ms-excel", + ".xlam" => "application/vnd.ms-excel.addin.macroEnabled.12", + ".xls" => "application/vnd.ms-excel", + ".xlsb" => "application/vnd.ms-excel.sheet.binary.macroEnabled.12", + ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xlsm" => "application/vnd.ms-excel.sheet.macroEnabled.12", + ".xlt" => "application/vnd.ms-excel", + ".xltx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + ".xml" => "application/xml", + ".xo" => "application/vnd.olpc-sugar", + ".xop" => "application/xop+xml", + ".xpm" => "image/x-xpixmap", + ".xpr" => "application/vnd.is-xpr", + ".xps" => "application/vnd.ms-xpsdocument", + ".xpw" => "application/vnd.intercon.formnet", + ".xsl" => "application/xml", + ".xslt" => "application/xslt+xml", + ".xsm" => "application/vnd.syncml+xml", + ".xspf" => "application/xspf+xml", + ".xul" => "application/vnd.mozilla.xul+xml", + ".xwd" => "image/x-xwindowdump", + ".xyz" => "chemical/x-xyz", + ".yaml" => "text/yaml", + ".yml" => "text/yaml", + ".zaz" => "application/vnd.zzazz.deck+xml", + ".zip" => "application/zip", + ".zmm" => "application/vnd.handheld-entertainment+xml", + } + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock.rb new file mode 100644 index 0000000..5e5c457 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative 'mock_request' diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_request.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_request.rb new file mode 100644 index 0000000..7c87bea --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_request.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'uri' +require 'stringio' + +require_relative 'constants' +require_relative 'mock_response' + +module Rack + # Rack::MockRequest helps testing your Rack application without + # actually using HTTP. + # + # After performing a request on a URL with get/post/put/patch/delete, it + # returns a MockResponse with useful helper methods for effective + # testing. + # + # You can pass a hash with additional configuration to the + # get/post/put/patch/delete. + # :input:: A String or IO-like to be used as rack.input. + # :fatal:: Raise a FatalWarning if the app writes to rack.errors. + # :lint:: If true, wrap the application in a Rack::Lint. + + class MockRequest + class FatalWarning < RuntimeError + end + + class FatalWarner + def puts(warning) + raise FatalWarning, warning + end + + def write(warning) + raise FatalWarning, warning + end + + def flush + end + + def string + "" + end + end + + def initialize(app) + @app = app + end + + # Make a GET request and return a MockResponse. See #request. + def get(uri, opts = {}) request(GET, uri, opts) end + # Make a POST request and return a MockResponse. See #request. + def post(uri, opts = {}) request(POST, uri, opts) end + # Make a PUT request and return a MockResponse. See #request. + def put(uri, opts = {}) request(PUT, uri, opts) end + # Make a PATCH request and return a MockResponse. See #request. + def patch(uri, opts = {}) request(PATCH, uri, opts) end + # Make a DELETE request and return a MockResponse. See #request. + def delete(uri, opts = {}) request(DELETE, uri, opts) end + # Make a HEAD request and return a MockResponse. See #request. + def head(uri, opts = {}) request(HEAD, uri, opts) end + # Make an OPTIONS request and return a MockResponse. See #request. + def options(uri, opts = {}) request(OPTIONS, uri, opts) end + + # Make a request using the given request method for the given + # uri to the rack application and return a MockResponse. + # Options given are passed to MockRequest.env_for. + def request(method = GET, uri = "", opts = {}) + env = self.class.env_for(uri, opts.merge(method: method)) + + if opts[:lint] + app = Rack::Lint.new(@app) + else + app = @app + end + + errors = env[RACK_ERRORS] + status, headers, body = app.call(env) + MockResponse.new(status, headers, body, errors) + ensure + body.close if body.respond_to?(:close) + end + + # For historical reasons, we're pinning to RFC 2396. + # URI::Parser = URI::RFC2396_Parser + def self.parse_uri_rfc2396(uri) + @parser ||= URI::Parser.new + @parser.parse(uri) + end + + # Return the Rack environment used for a request to +uri+. + # All options that are strings are added to the returned environment. + # Options: + # :fatal :: Whether to raise an exception if request outputs to rack.errors + # :input :: The rack.input to set + # :http_version :: The SERVER_PROTOCOL to set + # :method :: The HTTP request method to use + # :params :: The params to use + # :script_name :: The SCRIPT_NAME to set + def self.env_for(uri = "", opts = {}) + uri = parse_uri_rfc2396(uri) + uri.path = "/#{uri.path}" unless uri.path[0] == ?/ + + env = {} + + env[REQUEST_METHOD] = (opts[:method] ? opts[:method].to_s.upcase : GET).b + env[SERVER_NAME] = (uri.host || "example.org").b + env[SERVER_PORT] = (uri.port ? uri.port.to_s : "80").b + env[SERVER_PROTOCOL] = opts[:http_version] || 'HTTP/1.1' + env[QUERY_STRING] = (uri.query.to_s).b + env[PATH_INFO] = (uri.path).b + env[RACK_URL_SCHEME] = (uri.scheme || "http").b + env[HTTPS] = (env[RACK_URL_SCHEME] == "https" ? "on" : "off").b + + env[SCRIPT_NAME] = opts[:script_name] || "" + + if opts[:fatal] + env[RACK_ERRORS] = FatalWarner.new + else + env[RACK_ERRORS] = StringIO.new + end + + if params = opts[:params] + if env[REQUEST_METHOD] == GET + params = Utils.parse_nested_query(params) if params.is_a?(String) + params.update(Utils.parse_nested_query(env[QUERY_STRING])) + env[QUERY_STRING] = Utils.build_nested_query(params) + elsif !opts.has_key?(:input) + opts["CONTENT_TYPE"] = "application/x-www-form-urlencoded" + if params.is_a?(Hash) + if data = Rack::Multipart.build_multipart(params) + opts[:input] = data + opts["CONTENT_LENGTH"] ||= data.length.to_s + opts["CONTENT_TYPE"] = "multipart/form-data; boundary=#{Rack::Multipart::MULTIPART_BOUNDARY}" + else + opts[:input] = Utils.build_nested_query(params) + end + else + opts[:input] = params + end + end + end + + rack_input = opts[:input] + if String === rack_input + rack_input = StringIO.new(rack_input) + end + + if rack_input + rack_input.set_encoding(Encoding::BINARY) if rack_input.respond_to?(:set_encoding) + env[RACK_INPUT] = rack_input + + env["CONTENT_LENGTH"] ||= env[RACK_INPUT].size.to_s if env[RACK_INPUT].respond_to?(:size) + end + + opts.each { |field, value| + env[field] = value if String === field + } + + env + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_response.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_response.rb new file mode 100644 index 0000000..9af8079 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_response.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'cgi/cookie' +require 'time' + +require_relative 'response' + +module Rack + # Rack::MockResponse provides useful helpers for testing your apps. + # Usually, you don't create the MockResponse on your own, but use + # MockRequest. + + class MockResponse < Rack::Response + class << self + alias [] new + end + + # Headers + attr_reader :original_headers, :cookies + + # Errors + attr_accessor :errors + + def initialize(status, headers, body, errors = nil) + @original_headers = headers + + if errors + @errors = errors.string if errors.respond_to?(:string) + else + @errors = "" + end + + super(body, status, headers) + + @cookies = parse_cookies_from_header + buffered_body! + end + + def =~(other) + body =~ other + end + + def match(other) + body.match other + end + + def body + return @buffered_body if defined?(@buffered_body) + + # FIXME: apparently users of MockResponse expect the return value of + # MockResponse#body to be a string. However, the real response object + # returns the body as a list. + # + # See spec_showstatus.rb: + # + # should "not replace existing messages" do + # ... + # res.body.should == "foo!" + # end + buffer = @buffered_body = String.new + + @body.each do |chunk| + buffer << chunk + end + + return buffer + end + + def empty? + [201, 204, 304].include? status + end + + def cookie(name) + cookies.fetch(name, nil) + end + + private + + def parse_cookies_from_header + cookies = Hash.new + set_cookie_header = headers['set-cookie'] + if set_cookie_header && !set_cookie_header.empty? + Array(set_cookie_header).each do |cookie| + cookie_name, cookie_filling = cookie.split('=', 2) + cookie_attributes = identify_cookie_attributes cookie_filling + parsed_cookie = CGI::Cookie.new( + 'name' => cookie_name.strip, + 'value' => cookie_attributes.fetch('value'), + 'path' => cookie_attributes.fetch('path', nil), + 'domain' => cookie_attributes.fetch('domain', nil), + 'expires' => cookie_attributes.fetch('expires', nil), + 'secure' => cookie_attributes.fetch('secure', false) + ) + cookies.store(cookie_name, parsed_cookie) + end + end + cookies + end + + def identify_cookie_attributes(cookie_filling) + cookie_bits = cookie_filling.split(';') + cookie_attributes = Hash.new + cookie_attributes.store('value', cookie_bits[0].strip) + cookie_bits.drop(1).each do |bit| + if bit.include? '=' + cookie_attribute, attribute_value = bit.split('=', 2) + cookie_attributes.store(cookie_attribute.strip.downcase, attribute_value.strip) + end + if bit.include? 'secure' + cookie_attributes.store('secure', true) + end + end + + if cookie_attributes.key? 'max-age' + cookie_attributes.store('expires', Time.now + cookie_attributes['max-age'].to_i) + elsif cookie_attributes.key? 'expires' + cookie_attributes.store('expires', Time.httpdate(cookie_attributes['expires'])) + end + + cookie_attributes + end + + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart.rb new file mode 100644 index 0000000..4b02fb3 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' + +require_relative 'multipart/parser' +require_relative 'multipart/generator' + +require_relative 'bad_request' + +module Rack + # A multipart form data parser, adapted from IOWA. + # + # Usually, Rack::Request#POST takes care of calling this. + module Multipart + MULTIPART_BOUNDARY = "AaB03x" + + class MissingInputError < StandardError + include BadRequest + end + + # Accumulator for multipart form data, conforming to the QueryParser API. + # In future, the Parser could return the pair list directly, but that would + # change its API. + class ParamList # :nodoc: + def self.make_params + new + end + + def self.normalize_params(params, key, value) + params << [key, value] + end + + def initialize + @pairs = [] + end + + def <<(pair) + @pairs << pair + end + + def to_params_hash + @pairs + end + end + + class << self + def parse_multipart(env, params = Rack::Utils.default_query_parser) + unless io = env[RACK_INPUT] + raise MissingInputError, "Missing input stream!" + end + + if content_length = env['CONTENT_LENGTH'] + content_length = content_length.to_i + end + + content_type = env['CONTENT_TYPE'] + + tempfile = env[RACK_MULTIPART_TEMPFILE_FACTORY] || Parser::TEMPFILE_FACTORY + bufsize = env[RACK_MULTIPART_BUFFER_SIZE] || Parser::BUFSIZE + + info = Parser.parse(io, content_length, content_type, tempfile, bufsize, params) + env[RACK_TEMPFILES] = info.tmp_files + + return info.params + end + + def extract_multipart(request, params = Rack::Utils.default_query_parser) + parse_multipart(request.env) + end + + def build_multipart(params, first = true) + Generator.new(params, first).dump + end + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb new file mode 100644 index 0000000..30d7f51 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require_relative 'uploaded_file' + +module Rack + module Multipart + class Generator + def initialize(params, first = true) + @params, @first = params, first + + if @first && !@params.is_a?(Hash) + raise ArgumentError, "value must be a Hash" + end + end + + def dump + return nil if @first && !multipart? + return flattened_params unless @first + + flattened_params.map do |name, file| + if file.respond_to?(:original_filename) + if file.path + ::File.open(file.path, 'rb') do |f| + f.set_encoding(Encoding::BINARY) + content_for_tempfile(f, file, name) + end + else + content_for_tempfile(file, file, name) + end + else + content_for_other(file, name) + end + end.join << "--#{MULTIPART_BOUNDARY}--\r" + end + + private + def multipart? + query = lambda { |value| + case value + when Array + value.any?(&query) + when Hash + value.values.any?(&query) + when Rack::Multipart::UploadedFile + true + end + } + + @params.values.any?(&query) + end + + def flattened_params + @flattened_params ||= begin + h = Hash.new + @params.each do |key, value| + k = @first ? key.to_s : "[#{key}]" + + case value + when Array + value.map { |v| + Multipart.build_multipart(v, false).each { |subkey, subvalue| + h["#{k}[]#{subkey}"] = subvalue + } + } + when Hash + Multipart.build_multipart(value, false).each { |subkey, subvalue| + h[k + subkey] = subvalue + } + else + h[k] = value + end + end + h + end + end + + def content_for_tempfile(io, file, name) + length = ::File.stat(file.path).size if file.path + filename = "; filename=\"#{Utils.escape_path(file.original_filename)}\"" +<<-EOF +--#{MULTIPART_BOUNDARY}\r +content-disposition: form-data; name="#{name}"#{filename}\r +content-type: #{file.content_type}\r +#{"content-length: #{length}\r\n" if length}\r +#{io.read}\r +EOF + end + + def content_for_other(file, name) +<<-EOF +--#{MULTIPART_BOUNDARY}\r +content-disposition: form-data; name="#{name}"\r +\r +#{file}\r +EOF + end + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb new file mode 100644 index 0000000..3960b37 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb @@ -0,0 +1,502 @@ +# frozen_string_literal: true + +require 'strscan' + +require_relative '../utils' +require_relative '../bad_request' + +module Rack + module Multipart + class MultipartPartLimitError < Errno::EMFILE + include BadRequest + end + + class MultipartTotalPartLimitError < StandardError + include BadRequest + end + + # Use specific error class when parsing multipart request + # that ends early. + class EmptyContentError < ::EOFError + include BadRequest + end + + # Base class for multipart exceptions that do not subclass from + # other exception classes for backwards compatibility. + class BoundaryTooLongError < StandardError + include BadRequest + end + + # Prefer to use the BoundaryTooLongError class or Rack::BadRequest. + Error = BoundaryTooLongError + + EOL = "\r\n" + MULTIPART = %r|\Amultipart/.*boundary=\"?([^\";,]+)\"?|ni + MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{EOL}/ni + MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:(.*)(?=#{EOL}(\S|\z))/ni + MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{EOL}]*)/ni + + class Parser + BUFSIZE = 1_048_576 + TEXT_PLAIN = "text/plain" + TEMPFILE_FACTORY = lambda { |filename, content_type| + extension = ::File.extname(filename.gsub("\0", '%00'))[0, 129] + + Tempfile.new(["RackMultipart", extension]) + } + + class BoundedIO # :nodoc: + def initialize(io, content_length) + @io = io + @content_length = content_length + @cursor = 0 + end + + def read(size, outbuf = nil) + return if @cursor >= @content_length + + left = @content_length - @cursor + + str = if left < size + @io.read left, outbuf + else + @io.read size, outbuf + end + + if str + @cursor += str.bytesize + else + # Raise an error for mismatching content-length and actual contents + raise EOFError, "bad content body" + end + + str + end + end + + MultipartInfo = Struct.new :params, :tmp_files + EMPTY = MultipartInfo.new(nil, []) + + def self.parse_boundary(content_type) + return unless content_type + data = content_type.match(MULTIPART) + return unless data + data[1] + end + + def self.parse(io, content_length, content_type, tmpfile, bufsize, qp) + return EMPTY if 0 == content_length + + boundary = parse_boundary content_type + return EMPTY unless boundary + + if boundary.length > 70 + # RFC 1521 Section 7.2.1 imposes a 70 character maximum for the boundary. + # Most clients use no more than 55 characters. + raise BoundaryTooLongError, "multipart boundary size too large (#{boundary.length} characters)" + end + + io = BoundedIO.new(io, content_length) if content_length + + parser = new(boundary, tmpfile, bufsize, qp) + parser.parse(io) + + parser.result + end + + class Collector + class MimePart < Struct.new(:body, :head, :filename, :content_type, :name) + def get_data + data = body + if filename == "" + # filename is blank which means no file has been selected + return + elsif filename + body.rewind if body.respond_to?(:rewind) + + # Take the basename of the upload's original filename. + # This handles the full Windows paths given by Internet Explorer + # (and perhaps other broken user agents) without affecting + # those which give the lone filename. + fn = filename.split(/[\/\\]/).last + + data = { filename: fn, type: content_type, + name: name, tempfile: body, head: head } + end + + yield data + end + end + + class BufferPart < MimePart + def file?; false; end + def close; end + end + + class TempfilePart < MimePart + def file?; true; end + def close; body.close; end + end + + include Enumerable + + def initialize(tempfile) + @tempfile = tempfile + @mime_parts = [] + @open_files = 0 + end + + def each + @mime_parts.each { |part| yield part } + end + + def on_mime_head(mime_index, head, filename, content_type, name) + if filename + body = @tempfile.call(filename, content_type) + body.binmode if body.respond_to?(:binmode) + klass = TempfilePart + @open_files += 1 + else + body = String.new + klass = BufferPart + end + + @mime_parts[mime_index] = klass.new(body, head, filename, content_type, name) + + check_part_limits + end + + def on_mime_body(mime_index, content) + @mime_parts[mime_index].body << content + end + + def on_mime_finish(mime_index) + end + + private + + def check_part_limits + file_limit = Utils.multipart_file_limit + part_limit = Utils.multipart_total_part_limit + + if file_limit && file_limit > 0 + if @open_files >= file_limit + @mime_parts.each(&:close) + raise MultipartPartLimitError, 'Maximum file multiparts in content reached' + end + end + + if part_limit && part_limit > 0 + if @mime_parts.size >= part_limit + @mime_parts.each(&:close) + raise MultipartTotalPartLimitError, 'Maximum total multiparts in content reached' + end + end + end + end + + attr_reader :state + + def initialize(boundary, tempfile, bufsize, query_parser) + @query_parser = query_parser + @params = query_parser.make_params + @bufsize = bufsize + + @state = :FAST_FORWARD + @mime_index = 0 + @collector = Collector.new tempfile + + @sbuf = StringScanner.new("".dup) + @body_regex = /(?:#{EOL}|\A)--#{Regexp.quote(boundary)}(?:#{EOL}|--)/m + @body_regex_at_end = /#{@body_regex}\z/m + @end_boundary_size = boundary.bytesize + 4 # (-- at start, -- at finish) + @rx_max_size = boundary.bytesize + 6 # (\r\n-- at start, either \r\n or -- at finish) + @head_regex = /(.*?#{EOL})#{EOL}/m + end + + def parse(io) + outbuf = String.new + read_data(io, outbuf) + + loop do + status = + case @state + when :FAST_FORWARD + handle_fast_forward + when :CONSUME_TOKEN + handle_consume_token + when :MIME_HEAD + handle_mime_head + when :MIME_BODY + handle_mime_body + else # when :DONE + return + end + + read_data(io, outbuf) if status == :want_read + end + end + + def result + @collector.each do |part| + part.get_data do |data| + tag_multipart_encoding(part.filename, part.content_type, part.name, data) + @query_parser.normalize_params(@params, part.name, data) + end + end + MultipartInfo.new @params.to_params_hash, @collector.find_all(&:file?).map(&:body) + end + + private + + def dequote(str) # From WEBrick::HTTPUtils + ret = (/\A"(.*)"\Z/ =~ str) ? $1 : str.dup + ret.gsub!(/\\(.)/, "\\1") + ret + end + + def read_data(io, outbuf) + content = io.read(@bufsize, outbuf) + handle_empty_content!(content) + @sbuf.concat(content) + end + + # This handles the initial parser state. We read until we find the starting + # boundary, then we can transition to the next state. If we find the ending + # boundary, this is an invalid multipart upload, but keep scanning for opening + # boundary in that case. If no boundary found, we need to keep reading data + # and retry. It's highly unlikely the initial read will not consume the + # boundary. The client would have to deliberately craft a response + # with the opening boundary beyond the buffer size for that to happen. + def handle_fast_forward + while true + case consume_boundary + when :BOUNDARY + # found opening boundary, transition to next state + @state = :MIME_HEAD + return + when :END_BOUNDARY + # invalid multipart upload + if @sbuf.pos == @end_boundary_size && @sbuf.rest == EOL + # stop parsing a buffer if a buffer is only an end boundary. + @state = :DONE + return + end + + # retry for opening boundary + else + # no boundary found, keep reading data + return :want_read + end + end + end + + def handle_consume_token + tok = consume_boundary + # break if we're at the end of a buffer, but not if it is the end of a field + @state = if tok == :END_BOUNDARY || (@sbuf.eos? && tok != :BOUNDARY) + :DONE + else + :MIME_HEAD + end + end + + CONTENT_DISPOSITION_MAX_PARAMS = 16 + CONTENT_DISPOSITION_MAX_BYTES = 1536 + def handle_mime_head + if @sbuf.scan_until(@head_regex) + head = @sbuf[1] + content_type = head[MULTIPART_CONTENT_TYPE, 1] + if (disposition = head[MULTIPART_CONTENT_DISPOSITION, 1]) && + disposition.bytesize <= CONTENT_DISPOSITION_MAX_BYTES + + # ignore actual content-disposition value (should always be form-data) + i = disposition.index(';') + disposition.slice!(0, i+1) + param = nil + num_params = 0 + + # Parse parameter list + while i = disposition.index('=') + # Only parse up to max parameters, to avoid potential denial of service + num_params += 1 + break if num_params > CONTENT_DISPOSITION_MAX_PARAMS + + # Found end of parameter name, ensure forward progress in loop + param = disposition.slice!(0, i+1) + + # Remove ending equals and preceding whitespace from parameter name + param.chomp!('=') + param.lstrip! + + if disposition[0] == '"' + # Parameter value is quoted, parse it, handling backslash escapes + disposition.slice!(0, 1) + value = String.new + + while i = disposition.index(/(["\\])/) + c = $1 + + # Append all content until ending quote or escape + value << disposition.slice!(0, i) + + # Remove either backslash or ending quote, + # ensures forward progress in loop + disposition.slice!(0, 1) + + # stop parsing parameter value if found ending quote + break if c == '"' + + escaped_char = disposition.slice!(0, 1) + if param == 'filename' && escaped_char != '"' + # Possible IE uploaded filename, append both escape backslash and value + value << c << escaped_char + else + # Other only append escaped value + value << escaped_char + end + end + else + if i = disposition.index(';') + # Parameter value unquoted (which may be invalid), value ends at semicolon + value = disposition.slice!(0, i) + else + # If no ending semicolon, assume remainder of line is value and stop + # parsing + disposition.strip! + value = disposition + disposition = '' + end + end + + case param + when 'name' + name = value + when 'filename' + filename = value + when 'filename*' + filename_star = value + # else + # ignore other parameters + end + + # skip trailing semicolon, to proceed to next parameter + if i = disposition.index(';') + disposition.slice!(0, i+1) + end + end + else + name = head[MULTIPART_CONTENT_ID, 1] + end + + if filename_star + encoding, _, filename = filename_star.split("'", 3) + filename = normalize_filename(filename || '') + filename.force_encoding(find_encoding(encoding)) + elsif filename + filename = normalize_filename(filename) + end + + if name.nil? || name.empty? + name = filename || "#{content_type || TEXT_PLAIN}[]".dup + end + + @collector.on_mime_head @mime_index, head, filename, content_type, name + @state = :MIME_BODY + else + :want_read + end + end + + def handle_mime_body + if (body_with_boundary = @sbuf.check_until(@body_regex)) # check but do not advance the pointer yet + body = body_with_boundary.sub(@body_regex_at_end, '') # remove the boundary from the string + @collector.on_mime_body @mime_index, body + @sbuf.pos += body.length + 2 # skip \r\n after the content + @state = :CONSUME_TOKEN + @mime_index += 1 + else + # Save what we have so far + if @rx_max_size < @sbuf.rest_size + delta = @sbuf.rest_size - @rx_max_size + @collector.on_mime_body @mime_index, @sbuf.peek(delta) + @sbuf.pos += delta + @sbuf.string = @sbuf.rest + end + :want_read + end + end + + # Scan until the we find the start or end of the boundary. + # If we find it, return the appropriate symbol for the start or + # end of the boundary. If we don't find the start or end of the + # boundary, clear the buffer and return nil. + def consume_boundary + if read_buffer = @sbuf.scan_until(@body_regex) + read_buffer.end_with?(EOL) ? :BOUNDARY : :END_BOUNDARY + else + @sbuf.terminate + nil + end + end + + def normalize_filename(filename) + if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) } + filename = Utils.unescape_path(filename) + end + + filename.scrub! + + filename.split(/[\/\\]/).last || String.new + end + + CHARSET = "charset" + deprecate_constant :CHARSET + + def tag_multipart_encoding(filename, content_type, name, body) + name = name.to_s + encoding = Encoding::UTF_8 + + name.force_encoding(encoding) + + return if filename + + if content_type + list = content_type.split(';') + type_subtype = list.first + type_subtype.strip! + if TEXT_PLAIN == type_subtype + rest = list.drop 1 + rest.each do |param| + k, v = param.split('=', 2) + k.strip! + v.strip! + v = v[1..-2] if v.start_with?('"') && v.end_with?('"') + if k == "charset" + encoding = find_encoding(v) + end + end + end + end + + name.force_encoding(encoding) + body.force_encoding(encoding) + end + + # Return the related Encoding object. However, because + # enc is submitted by the user, it may be invalid, so + # use a binary encoding in that case. + def find_encoding(enc) + Encoding.find enc + rescue ArgumentError + Encoding::BINARY + end + + def handle_empty_content!(content) + if content.nil? || content.empty? + raise EmptyContentError + end + end + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb new file mode 100644 index 0000000..2782e44 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'tempfile' +require 'fileutils' + +module Rack + module Multipart + class UploadedFile + + # The filename, *not* including the path, of the "uploaded" file + attr_reader :original_filename + + # The content type of the "uploaded" file + attr_accessor :content_type + + def initialize(filepath = nil, ct = "text/plain", bin = false, + path: filepath, content_type: ct, binary: bin, filename: nil, io: nil) + if io + @tempfile = io + @original_filename = filename + else + raise "#{path} file does not exist" unless ::File.exist?(path) + @original_filename = filename || ::File.basename(path) + @tempfile = Tempfile.new([@original_filename, ::File.extname(path)], encoding: Encoding::BINARY) + @tempfile.binmode if binary + FileUtils.copy_file(path, @tempfile.path) + end + @content_type = content_type + end + + def path + @tempfile.path if @tempfile.respond_to?(:path) + end + alias_method :local_path, :path + + def respond_to?(*args) + super or @tempfile.respond_to?(*args) + end + + def method_missing(method_name, *args, &block) #:nodoc: + @tempfile.__send__(method_name, *args, &block) + end + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/null_logger.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/null_logger.rb new file mode 100644 index 0000000..52fc125 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/null_logger.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative 'constants' + +module Rack + class NullLogger + def initialize(app) + @app = app + end + + def call(env) + env[RACK_LOGGER] = self + @app.call(env) + end + + def info(progname = nil, &block); end + def debug(progname = nil, &block); end + def warn(progname = nil, &block); end + def error(progname = nil, &block); end + def fatal(progname = nil, &block); end + def unknown(progname = nil, &block); end + def info? ; end + def debug? ; end + def warn? ; end + def error? ; end + def fatal? ; end + def debug! ; end + def error! ; end + def fatal! ; end + def info! ; end + def warn! ; end + def level ; end + def progname ; end + def datetime_format ; end + def formatter ; end + def sev_threshold ; end + def level=(level); end + def progname=(progname); end + def datetime_format=(datetime_format); end + def formatter=(formatter); end + def sev_threshold=(sev_threshold); end + def close ; end + def add(severity, message = nil, progname = nil, &block); end + def log(severity, message = nil, progname = nil, &block); end + def <<(msg); end + def reopen(logdev = nil); end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/query_parser.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/query_parser.rb new file mode 100644 index 0000000..28cbce1 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/query_parser.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require_relative 'bad_request' +require 'uri' + +module Rack + class QueryParser + DEFAULT_SEP = /& */n + COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n } + + # ParameterTypeError is the error that is raised when incoming structural + # parameters (parsed by parse_nested_query) contain conflicting types. + class ParameterTypeError < TypeError + include BadRequest + end + + # InvalidParameterError is the error that is raised when incoming structural + # parameters (parsed by parse_nested_query) contain invalid format or byte + # sequence. + class InvalidParameterError < ArgumentError + include BadRequest + end + + # ParamsTooDeepError is the error that is raised when params are recursively + # nested over the specified limit. + class ParamsTooDeepError < RangeError + include BadRequest + end + + def self.make_default(param_depth_limit) + new Params, param_depth_limit + end + + attr_reader :param_depth_limit + + def initialize(params_class, param_depth_limit) + @params_class = params_class + @param_depth_limit = param_depth_limit + end + + # Stolen from Mongrel, with some small modifications: + # Parses a query string by breaking it up at the '&'. You can also use this + # to parse cookies by changing the characters used in the second parameter + # (which defaults to '&'). + def parse_query(qs, separator = nil, &unescaper) + unescaper ||= method(:unescape) + + params = make_params + + (qs || '').split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p| + next if p.empty? + k, v = p.split('=', 2).map!(&unescaper) + + if cur = params[k] + if cur.class == Array + params[k] << v + else + params[k] = [cur, v] + end + else + params[k] = v + end + end + + return params.to_h + end + + # parse_nested_query expands a query string into structural types. Supported + # types are Arrays, Hashes and basic value types. It is possible to supply + # query strings with parameters of conflicting types, in this case a + # ParameterTypeError is raised. Users are encouraged to return a 400 in this + # case. + def parse_nested_query(qs, separator = nil) + params = make_params + + unless qs.nil? || qs.empty? + (qs || '').split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p| + k, v = p.split('=', 2).map! { |s| unescape(s) } + + _normalize_params(params, k, v, 0) + end + end + + return params.to_h + rescue ArgumentError => e + raise InvalidParameterError, e.message, e.backtrace + end + + # normalize_params recursively expands parameters into structural types. If + # the structural types represented by two different parameter names are in + # conflict, a ParameterTypeError is raised. The depth argument is deprecated + # and should no longer be used, it is kept for backwards compatibility with + # earlier versions of rack. + def normalize_params(params, name, v, _depth=nil) + _normalize_params(params, name, v, 0) + end + + private def _normalize_params(params, name, v, depth) + raise ParamsTooDeepError if depth >= param_depth_limit + + if !name + # nil name, treat same as empty string (required by tests) + k = after = '' + elsif depth == 0 + # Start of parsing, don't treat [] or [ at start of string specially + if start = name.index('[', 1) + # Start of parameter nesting, use part before brackets as key + k = name[0, start] + after = name[start, name.length] + else + # Plain parameter with no nesting + k = name + after = '' + end + elsif name.start_with?('[]') + # Array nesting + k = '[]' + after = name[2, name.length] + elsif name.start_with?('[') && (start = name.index(']', 1)) + # Hash nesting, use the part inside brackets as the key + k = name[1, start-1] + after = name[start+1, name.length] + else + # Probably malformed input, nested but not starting with [ + # treat full name as key for backwards compatibility. + k = name + after = '' + end + + return if k.empty? + + if after == '' + if k == '[]' && depth != 0 + return [v] + else + params[k] = v + end + elsif after == "[" + params[name] = v + elsif after == "[]" + params[k] ||= [] + raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) + params[k] << v + elsif after.start_with?('[]') + # Recognize x[][y] (hash inside array) parameters + unless after[2] == '[' && after.end_with?(']') && (child_key = after[3, after.length-4]) && !child_key.empty? && !child_key.index('[') && !child_key.index(']') + # Handle other nested array parameters + child_key = after[2, after.length] + end + params[k] ||= [] + raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) + if params_hash_type?(params[k].last) && !params_hash_has_key?(params[k].last, child_key) + _normalize_params(params[k].last, child_key, v, depth + 1) + else + params[k] << _normalize_params(make_params, child_key, v, depth + 1) + end + else + params[k] ||= make_params + raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k]) + params[k] = _normalize_params(params[k], after, v, depth + 1) + end + + params + end + + def make_params + @params_class.new + end + + def new_depth_limit(param_depth_limit) + self.class.new @params_class, param_depth_limit + end + + private + + def params_hash_type?(obj) + obj.kind_of?(@params_class) + end + + def params_hash_has_key?(hash, key) + return false if /\[\]/.match?(key) + + key.split(/[\[\]]+/).inject(hash) do |h, part| + next h if part == '' + return false unless params_hash_type?(h) && h.key?(part) + h[part] + end + + true + end + + def unescape(string, encoding = Encoding::UTF_8) + URI.decode_www_form_component(string, encoding) + end + + class Params < Hash + alias_method :to_params_hash, :to_h + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/recursive.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/recursive.rb new file mode 100644 index 0000000..0945d32 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/recursive.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'uri' + +require_relative 'constants' + +module Rack + # Rack::ForwardRequest gets caught by Rack::Recursive and redirects + # the current request to the app at +url+. + # + # raise ForwardRequest.new("/not-found") + # + + class ForwardRequest < Exception + attr_reader :url, :env + + def initialize(url, env = {}) + @url = URI(url) + @env = env + + @env[PATH_INFO] = @url.path + @env[QUERY_STRING] = @url.query if @url.query + @env[HTTP_HOST] = @url.host if @url.host + @env[HTTP_PORT] = @url.port if @url.port + @env[RACK_URL_SCHEME] = @url.scheme if @url.scheme + + super "forwarding to #{url}" + end + end + + # Rack::Recursive allows applications called down the chain to + # include data from other applications (by using + # rack['rack.recursive.include'][...] or raise a + # ForwardRequest to redirect internally. + + class Recursive + def initialize(app) + @app = app + end + + def call(env) + dup._call(env) + end + + def _call(env) + @script_name = env[SCRIPT_NAME] + @app.call(env.merge(RACK_RECURSIVE_INCLUDE => method(:include))) + rescue ForwardRequest => req + call(env.merge(req.env)) + end + + def include(env, path) + unless path.index(@script_name) == 0 && (path[@script_name.size] == ?/ || + path[@script_name.size].nil?) + raise ArgumentError, "can only include below #{@script_name}, not #{path}" + end + + env = env.merge(PATH_INFO => path, + SCRIPT_NAME => @script_name, + REQUEST_METHOD => GET, + "CONTENT_LENGTH" => "0", "CONTENT_TYPE" => "", + RACK_INPUT => StringIO.new("")) + @app.call(env) + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/reloader.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/reloader.rb new file mode 100644 index 0000000..a15064a --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/reloader.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# Copyright (C) 2009-2018 Michael Fellinger +# Rack::Reloader is subject to the terms of an MIT-style license. +# See MIT-LICENSE or https://opensource.org/licenses/MIT. + +require 'pathname' + +module Rack + + # High performant source reloader + # + # This class acts as Rack middleware. + # + # What makes it especially suited for use in a production environment is that + # any file will only be checked once and there will only be made one system + # call stat(2). + # + # Please note that this will not reload files in the background, it does so + # only when actively called. + # + # It is performing a check/reload cycle at the start of every request, but + # also respects a cool down time, during which nothing will be done. + class Reloader + def initialize(app, cooldown = 10, backend = Stat) + @app = app + @cooldown = cooldown + @last = (Time.now - cooldown) + @cache = {} + @mtimes = {} + @reload_mutex = Mutex.new + + extend backend + end + + def call(env) + if @cooldown and Time.now > @last + @cooldown + if Thread.list.size > 1 + @reload_mutex.synchronize{ reload! } + else + reload! + end + + @last = Time.now + end + + @app.call(env) + end + + def reload!(stderr = $stderr) + rotation do |file, mtime| + previous_mtime = @mtimes[file] ||= mtime + safe_load(file, mtime, stderr) if mtime > previous_mtime + end + end + + # A safe Kernel::load, issuing the hooks depending on the results + def safe_load(file, mtime, stderr = $stderr) + load(file) + stderr.puts "#{self.class}: reloaded `#{file}'" + file + rescue LoadError, SyntaxError => ex + stderr.puts ex + ensure + @mtimes[file] = mtime + end + + module Stat + def rotation + files = [$0, *$LOADED_FEATURES].uniq + paths = ['./', *$LOAD_PATH].uniq + + files.map{|file| + next if /\.(so|bundle)$/.match?(file) # cannot reload compiled files + + found, stat = figure_path(file, paths) + next unless found && stat && mtime = stat.mtime + + @cache[file] = found + + yield(found, mtime) + }.compact + end + + # Takes a relative or absolute +file+ name, a couple possible +paths+ that + # the +file+ might reside in. Returns the full path and File::Stat for the + # path. + def figure_path(file, paths) + found = @cache[file] + found = file if !found and Pathname.new(file).absolute? + found, stat = safe_stat(found) + return found, stat if found + + paths.find do |possible_path| + path = ::File.join(possible_path, file) + found, stat = safe_stat(path) + return ::File.expand_path(found), stat if found + end + + return false, false + end + + def safe_stat(file) + return unless file + stat = ::File.stat(file) + return file, stat if stat.file? + rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ESRCH + @cache.delete(file) and false + end + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/request.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/request.rb new file mode 100644 index 0000000..93526a0 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/request.rb @@ -0,0 +1,796 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' +require_relative 'media_type' + +module Rack + # Rack::Request provides a convenient interface to a Rack + # environment. It is stateless, the environment +env+ passed to the + # constructor will be directly modified. + # + # req = Rack::Request.new(env) + # req.post? + # req.params["data"] + + class Request + class << self + attr_accessor :ip_filter + + # The priority when checking forwarded headers. The default + # is [:forwarded, :x_forwarded], which means, check the + # +Forwarded+ header first, followed by the appropriate + # X-Forwarded-* header. You can revert the priority by + # reversing the priority, or remove checking of either + # or both headers by removing elements from the array. + # + # This should be set as appropriate in your environment + # based on what reverse proxies are in use. If you are not + # using reverse proxies, you should probably use an empty + # array. + attr_accessor :forwarded_priority + + # The priority when checking either the X-Forwarded-Proto + # or X-Forwarded-Scheme header for the forwarded protocol. + # The default is [:proto, :scheme], to try the + # X-Forwarded-Proto header before the + # X-Forwarded-Scheme header. Rack 2 had behavior + # similar to [:scheme, :proto]. You can remove either or + # both of the entries in array to ignore that respective header. + attr_accessor :x_forwarded_proto_priority + end + + @forwarded_priority = [:forwarded, :x_forwarded] + @x_forwarded_proto_priority = [:proto, :scheme] + + valid_ipv4_octet = /\.(25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])/ + + trusted_proxies = Regexp.union( + /\A127#{valid_ipv4_octet}{3}\z/, # localhost IPv4 range 127.x.x.x, per RFC-3330 + /\A::1\z/, # localhost IPv6 ::1 + /\Af[cd][0-9a-f]{2}(?::[0-9a-f]{0,4}){0,7}\z/i, # private IPv6 range fc00 .. fdff + /\A10#{valid_ipv4_octet}{3}\z/, # private IPv4 range 10.x.x.x + /\A172\.(1[6-9]|2[0-9]|3[01])#{valid_ipv4_octet}{2}\z/, # private IPv4 range 172.16.0.0 .. 172.31.255.255 + /\A192\.168#{valid_ipv4_octet}{2}\z/, # private IPv4 range 192.168.x.x + /\Alocalhost\z|\Aunix(\z|:)/i, # localhost hostname, and unix domain sockets + ) + + self.ip_filter = lambda { |ip| trusted_proxies.match?(ip) } + + ALLOWED_SCHEMES = %w(https http wss ws).freeze + + def initialize(env) + @env = env + @params = nil + end + + def params + @params ||= super + end + + def update_param(k, v) + super + @params = nil + end + + def delete_param(k) + v = super + @params = nil + v + end + + module Env + # The environment of the request. + attr_reader :env + + def initialize(env) + @env = env + # This module is included at least in `ActionDispatch::Request` + # The call to `super()` allows additional mixed-in initializers are called + super() + end + + # Predicate method to test to see if `name` has been set as request + # specific data + def has_header?(name) + @env.key? name + end + + # Get a request specific value for `name`. + def get_header(name) + @env[name] + end + + # If a block is given, it yields to the block if the value hasn't been set + # on the request. + def fetch_header(name, &block) + @env.fetch(name, &block) + end + + # Loops through each key / value pair in the request specific data. + def each_header(&block) + @env.each(&block) + end + + # Set a request specific value for `name` to `v` + def set_header(name, v) + @env[name] = v + end + + # Add a header that may have multiple values. + # + # Example: + # request.add_header 'Accept', 'image/png' + # request.add_header 'Accept', '*/*' + # + # assert_equal 'image/png,*/*', request.get_header('Accept') + # + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 + def add_header(key, v) + if v.nil? + get_header key + elsif has_header? key + set_header key, "#{get_header key},#{v}" + else + set_header key, v + end + end + + # Delete a request specific value for `name`. + def delete_header(name) + @env.delete name + end + + def initialize_copy(other) + @env = other.env.dup + end + end + + module Helpers + # The set of form-data media-types. Requests that do not indicate + # one of the media types present in this list will not be eligible + # for form-data / param parsing. + FORM_DATA_MEDIA_TYPES = [ + 'application/x-www-form-urlencoded', + 'multipart/form-data' + ] + + # The set of media-types. Requests that do not indicate + # one of the media types present in this list will not be eligible + # for param parsing like soap attachments or generic multiparts + PARSEABLE_DATA_MEDIA_TYPES = [ + 'multipart/related', + 'multipart/mixed' + ] + + # Default ports depending on scheme. Used to decide whether or not + # to include the port in a generated URI. + DEFAULT_PORTS = { 'http' => 80, 'https' => 443, 'coffee' => 80 } + + # The address of the client which connected to the proxy. + HTTP_X_FORWARDED_FOR = 'HTTP_X_FORWARDED_FOR' + + # The contents of the host/:authority header sent to the proxy. + HTTP_X_FORWARDED_HOST = 'HTTP_X_FORWARDED_HOST' + + HTTP_FORWARDED = 'HTTP_FORWARDED' + + # The value of the scheme sent to the proxy. + HTTP_X_FORWARDED_SCHEME = 'HTTP_X_FORWARDED_SCHEME' + + # The protocol used to connect to the proxy. + HTTP_X_FORWARDED_PROTO = 'HTTP_X_FORWARDED_PROTO' + + # The port used to connect to the proxy. + HTTP_X_FORWARDED_PORT = 'HTTP_X_FORWARDED_PORT' + + # Another way for specifying https scheme was used. + HTTP_X_FORWARDED_SSL = 'HTTP_X_FORWARDED_SSL' + + def body; get_header(RACK_INPUT) end + def script_name; get_header(SCRIPT_NAME).to_s end + def script_name=(s); set_header(SCRIPT_NAME, s.to_s) end + + def path_info; get_header(PATH_INFO).to_s end + def path_info=(s); set_header(PATH_INFO, s.to_s) end + + def request_method; get_header(REQUEST_METHOD) end + def query_string; get_header(QUERY_STRING).to_s end + def content_length; get_header('CONTENT_LENGTH') end + def logger; get_header(RACK_LOGGER) end + def user_agent; get_header('HTTP_USER_AGENT') end + + # the referer of the client + def referer; get_header('HTTP_REFERER') end + alias referrer referer + + def session + fetch_header(RACK_SESSION) do |k| + set_header RACK_SESSION, default_session + end + end + + def session_options + fetch_header(RACK_SESSION_OPTIONS) do |k| + set_header RACK_SESSION_OPTIONS, {} + end + end + + # Checks the HTTP request method (or verb) to see if it was of type DELETE + def delete?; request_method == DELETE end + + # Checks the HTTP request method (or verb) to see if it was of type GET + def get?; request_method == GET end + + # Checks the HTTP request method (or verb) to see if it was of type HEAD + def head?; request_method == HEAD end + + # Checks the HTTP request method (or verb) to see if it was of type OPTIONS + def options?; request_method == OPTIONS end + + # Checks the HTTP request method (or verb) to see if it was of type LINK + def link?; request_method == LINK end + + # Checks the HTTP request method (or verb) to see if it was of type PATCH + def patch?; request_method == PATCH end + + # Checks the HTTP request method (or verb) to see if it was of type POST + def post?; request_method == POST end + + # Checks the HTTP request method (or verb) to see if it was of type PUT + def put?; request_method == PUT end + + # Checks the HTTP request method (or verb) to see if it was of type TRACE + def trace?; request_method == TRACE end + + # Checks the HTTP request method (or verb) to see if it was of type UNLINK + def unlink?; request_method == UNLINK end + + def scheme + if get_header(HTTPS) == 'on' + 'https' + elsif get_header(HTTP_X_FORWARDED_SSL) == 'on' + 'https' + elsif forwarded_scheme + forwarded_scheme + else + get_header(RACK_URL_SCHEME) + end + end + + # The authority of the incoming request as defined by RFC3976. + # https://tools.ietf.org/html/rfc3986#section-3.2 + # + # In HTTP/1, this is the `host` header. + # In HTTP/2, this is the `:authority` pseudo-header. + def authority + forwarded_authority || host_authority || server_authority + end + + # The authority as defined by the `SERVER_NAME` and `SERVER_PORT` + # variables. + def server_authority + host = self.server_name + port = self.server_port + + if host + if port + "#{host}:#{port}" + else + host + end + end + end + + def server_name + get_header(SERVER_NAME) + end + + def server_port + get_header(SERVER_PORT) + end + + def cookies + hash = fetch_header(RACK_REQUEST_COOKIE_HASH) do |key| + set_header(key, {}) + end + + string = get_header(HTTP_COOKIE) + + unless string == get_header(RACK_REQUEST_COOKIE_STRING) + hash.replace Utils.parse_cookies_header(string) + set_header(RACK_REQUEST_COOKIE_STRING, string) + end + + hash + end + + def content_type + content_type = get_header('CONTENT_TYPE') + content_type.nil? || content_type.empty? ? nil : content_type + end + + def xhr? + get_header("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" + end + + # The `HTTP_HOST` header. + def host_authority + get_header(HTTP_HOST) + end + + def host_with_port(authority = self.authority) + host, _, port = split_authority(authority) + + if port == DEFAULT_PORTS[self.scheme] + host + else + authority + end + end + + # Returns a formatted host, suitable for being used in a URI. + def host + split_authority(self.authority)[0] + end + + # Returns an address suitable for being to resolve to an address. + # In the case of a domain name or IPv4 address, the result is the same + # as +host+. In the case of IPv6 or future address formats, the square + # brackets are removed. + def hostname + split_authority(self.authority)[1] + end + + def port + if authority = self.authority + _, _, port = split_authority(authority) + end + + port || forwarded_port&.last || DEFAULT_PORTS[scheme] || server_port + end + + def forwarded_for + forwarded_priority.each do |type| + case type + when :forwarded + if forwarded_for = get_http_forwarded(:for) + return(forwarded_for.map! do |authority| + split_authority(authority)[1] + end) + end + when :x_forwarded + if value = get_header(HTTP_X_FORWARDED_FOR) + return(split_header(value).map do |authority| + split_authority(wrap_ipv6(authority))[1] + end) + end + end + end + + nil + end + + def forwarded_port + forwarded_priority.each do |type| + case type + when :forwarded + if forwarded = get_http_forwarded(:for) + return(forwarded.map do |authority| + split_authority(authority)[2] + end.compact) + end + when :x_forwarded + if value = get_header(HTTP_X_FORWARDED_PORT) + return split_header(value).map(&:to_i) + end + end + end + + nil + end + + def forwarded_authority + forwarded_priority.each do |type| + case type + when :forwarded + if forwarded = get_http_forwarded(:host) + return forwarded.last + end + when :x_forwarded + if value = get_header(HTTP_X_FORWARDED_HOST) + return wrap_ipv6(split_header(value).last) + end + end + end + + nil + end + + def ssl? + scheme == 'https' || scheme == 'wss' + end + + def ip + remote_addresses = split_header(get_header('REMOTE_ADDR')) + external_addresses = reject_trusted_ip_addresses(remote_addresses) + + unless external_addresses.empty? + return external_addresses.last + end + + if (forwarded_for = self.forwarded_for) && !forwarded_for.empty? + # The forwarded for addresses are ordered: client, proxy1, proxy2. + # So we reject all the trusted addresses (proxy*) and return the + # last client. Or if we trust everyone, we just return the first + # address. + return reject_trusted_ip_addresses(forwarded_for).last || forwarded_for.first + end + + # If all the addresses are trusted, and we aren't forwarded, just return + # the first remote address, which represents the source of the request. + remote_addresses.first + end + + # The media type (type/subtype) portion of the CONTENT_TYPE header + # without any media type parameters. e.g., when CONTENT_TYPE is + # "text/plain;charset=utf-8", the media-type is "text/plain". + # + # For more information on the use of media types in HTTP, see: + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 + def media_type + MediaType.type(content_type) + end + + # The media type parameters provided in CONTENT_TYPE as a Hash, or + # an empty Hash if no CONTENT_TYPE or media-type parameters were + # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", + # this method responds with the following Hash: + # { 'charset' => 'utf-8' } + def media_type_params + MediaType.params(content_type) + end + + # The character set of the request body if a "charset" media type + # parameter was given, or nil if no "charset" was specified. Note + # that, per RFC2616, text/* media types that specify no explicit + # charset are to be considered ISO-8859-1. + def content_charset + media_type_params['charset'] + end + + # Determine whether the request body contains form-data by checking + # the request content-type for one of the media-types: + # "application/x-www-form-urlencoded" or "multipart/form-data". The + # list of form-data media types can be modified through the + # +FORM_DATA_MEDIA_TYPES+ array. + # + # A request body is also assumed to contain form-data when no + # content-type header is provided and the request_method is POST. + def form_data? + type = media_type + meth = get_header(RACK_METHODOVERRIDE_ORIGINAL_METHOD) || get_header(REQUEST_METHOD) + + (meth == POST && type.nil?) || FORM_DATA_MEDIA_TYPES.include?(type) + end + + # Determine whether the request body contains data by checking + # the request media_type against registered parse-data media-types + def parseable_data? + PARSEABLE_DATA_MEDIA_TYPES.include?(media_type) + end + + # Returns the data received in the query string. + def GET + rr_query_string = get_header(RACK_REQUEST_QUERY_STRING) + query_string = self.query_string + if rr_query_string == query_string + get_header(RACK_REQUEST_QUERY_HASH) + else + if rr_query_string + warn "query string used for GET parsing different from current query string. Starting in Rack 3.2, Rack will used the cached GET value instead of parsing the current query string.", uplevel: 1 + end + query_hash = parse_query(query_string, '&') + set_header(RACK_REQUEST_QUERY_STRING, query_string) + set_header(RACK_REQUEST_QUERY_HASH, query_hash) + end + end + + # Returns the data received in the request body. + # + # This method support both application/x-www-form-urlencoded and + # multipart/form-data. + def POST + if error = get_header(RACK_REQUEST_FORM_ERROR) + raise error.class, error.message, cause: error.cause + end + + begin + rack_input = get_header(RACK_INPUT) + + # If the form hash was already memoized: + if form_hash = get_header(RACK_REQUEST_FORM_HASH) + form_input = get_header(RACK_REQUEST_FORM_INPUT) + # And it was memoized from the same input: + if form_input.equal?(rack_input) + return form_hash + elsif form_input + warn "input stream used for POST parsing different from current input stream. Starting in Rack 3.2, Rack will used the cached POST value instead of parsing the current input stream.", uplevel: 1 + end + end + + # Otherwise, figure out how to parse the input: + if rack_input.nil? + set_header RACK_REQUEST_FORM_INPUT, nil + set_header(RACK_REQUEST_FORM_HASH, {}) + elsif form_data? || parseable_data? + if pairs = Rack::Multipart.parse_multipart(env, Rack::Multipart::ParamList) + set_header RACK_REQUEST_FORM_PAIRS, pairs + set_header RACK_REQUEST_FORM_HASH, expand_param_pairs(pairs) + else + form_vars = get_header(RACK_INPUT).read + + # Fix for Safari Ajax postings that always append \0 + # form_vars.sub!(/\0\z/, '') # performance replacement: + form_vars.slice!(-1) if form_vars.end_with?("\0") + + set_header RACK_REQUEST_FORM_VARS, form_vars + set_header RACK_REQUEST_FORM_HASH, parse_query(form_vars, '&') + end + + set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) + get_header RACK_REQUEST_FORM_HASH + else + set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) + set_header(RACK_REQUEST_FORM_HASH, {}) + end + rescue => error + set_header(RACK_REQUEST_FORM_ERROR, error) + raise + end + end + + # The union of GET and POST data. + # + # Note that modifications will not be persisted in the env. Use update_param or delete_param if you want to destructively modify params. + def params + self.GET.merge(self.POST) + end + + # Destructively update a parameter, whether it's in GET and/or POST. Returns nil. + # + # The parameter is updated wherever it was previous defined, so GET, POST, or both. If it wasn't previously defined, it's inserted into GET. + # + # env['rack.input'] is not touched. + def update_param(k, v) + found = false + if self.GET.has_key?(k) + found = true + self.GET[k] = v + end + if self.POST.has_key?(k) + found = true + self.POST[k] = v + end + unless found + self.GET[k] = v + end + end + + # Destructively delete a parameter, whether it's in GET or POST. Returns the value of the deleted parameter. + # + # If the parameter is in both GET and POST, the POST value takes precedence since that's how #params works. + # + # env['rack.input'] is not touched. + def delete_param(k) + post_value, get_value = self.POST.delete(k), self.GET.delete(k) + post_value || get_value + end + + def base_url + "#{scheme}://#{host_with_port}" + end + + # Tries to return a remake of the original request URL as a string. + def url + base_url + fullpath + end + + def path + script_name + path_info + end + + def fullpath + query_string.empty? ? path : "#{path}?#{query_string}" + end + + def accept_encoding + parse_http_accept_header(get_header("HTTP_ACCEPT_ENCODING")) + end + + def accept_language + parse_http_accept_header(get_header("HTTP_ACCEPT_LANGUAGE")) + end + + def trusted_proxy?(ip) + Rack::Request.ip_filter.call(ip) + end + + # like Hash#values_at + def values_at(*keys) + warn("Request#values_at is deprecated and will be removed in a future version of Rack. Please use request.params.values_at instead", uplevel: 1) + + keys.map { |key| params[key] } + end + + private + + def default_session; {}; end + + # Assist with compatibility when processing `X-Forwarded-For`. + def wrap_ipv6(host) + # Even thought IPv6 addresses should be wrapped in square brackets, + # sometimes this is not done in various legacy/underspecified headers. + # So we try to fix this situation for compatibility reasons. + + # Try to detect IPv6 addresses which aren't escaped yet: + if !host.start_with?('[') && host.count(':') > 1 + "[#{host}]" + else + host + end + end + + def parse_http_accept_header(header) + # It would be nice to use filter_map here, but it's Ruby 2.7+ + parts = header.to_s.split(',') + + parts.map! do |part| + part.strip! + next if part.empty? + + attribute, parameters = part.split(';', 2) + attribute.strip! + parameters&.strip! + quality = 1.0 + if parameters and /\Aq=([\d.]+)/ =~ parameters + quality = $1.to_f + end + [attribute, quality] + end + + parts.compact! + + parts + end + + # Get an array of values set in the RFC 7239 `Forwarded` request header. + def get_http_forwarded(token) + Utils.forwarded_values(get_header(HTTP_FORWARDED))&.[](token) + end + + def query_parser + Utils.default_query_parser + end + + def parse_query(qs, d = '&') + query_parser.parse_nested_query(qs, d) + end + + def parse_multipart + Rack::Multipart.extract_multipart(self, query_parser) + end + + def expand_param_pairs(pairs, query_parser = query_parser()) + params = query_parser.make_params + + pairs.each do |k, v| + query_parser.normalize_params(params, k, v) + end + + params.to_params_hash + end + + def split_header(value) + value ? value.strip.split(/[,\s]+/) : [] + end + + # ipv6 extracted from resolv stdlib, simplified + # to remove numbered match group creation. + ipv6 = Regexp.union( + /(?:[0-9A-Fa-f]{1,4}:){7} + [0-9A-Fa-f]{1,4}/x, + /(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: + (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?/x, + /(?:[0-9A-Fa-f]{1,4}:){6,6} + \d+\.\d+\.\d+\.\d+/x, + /(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: + (?:[0-9A-Fa-f]{1,4}:)* + \d+\.\d+\.\d+\.\d+/x, + /[Ff][Ee]80 + (?::[0-9A-Fa-f]{1,4}){7} + %[-0-9A-Za-z._~]+/x, + /[Ff][Ee]80: + (?: + (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: + (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? + | + :(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? + )? + :[0-9A-Fa-f]{1,4}%[-0-9A-Za-z._~]+/x) + + AUTHORITY = / + \A + (? + # Match IPv6 as a string of hex digits and colons in square brackets + \[(?
#{ipv6})\] + | + # Match any other printable string (except square brackets) as a hostname + (?
[[[:graph:]&&[^\[\]]]]*?) + ) + (:(?\d+))? + \z + /x + + private_constant :AUTHORITY + + def split_authority(authority) + return [] if authority.nil? + return [] unless match = AUTHORITY.match(authority) + return match[:host], match[:address], match[:port]&.to_i + end + + def reject_trusted_ip_addresses(ip_addresses) + ip_addresses.reject { |ip| trusted_proxy?(ip) } + end + + FORWARDED_SCHEME_HEADERS = { + proto: HTTP_X_FORWARDED_PROTO, + scheme: HTTP_X_FORWARDED_SCHEME + }.freeze + private_constant :FORWARDED_SCHEME_HEADERS + def forwarded_scheme + forwarded_priority.each do |type| + case type + when :forwarded + if (forwarded_proto = get_http_forwarded(:proto)) && + (scheme = allowed_scheme(forwarded_proto.last)) + return scheme + end + when :x_forwarded + x_forwarded_proto_priority.each do |x_type| + if header = FORWARDED_SCHEME_HEADERS[x_type] + split_header(get_header(header)).reverse_each do |scheme| + if allowed_scheme(scheme) + return scheme + end + end + end + end + end + end + + nil + end + + def allowed_scheme(header) + header if ALLOWED_SCHEMES.include?(header) + end + + def forwarded_priority + Request.forwarded_priority + end + + def x_forwarded_proto_priority + Request.x_forwarded_proto_priority + end + end + + include Env + include Helpers + end +end + +# :nocov: +require_relative 'multipart' unless defined?(Rack::Multipart) +# :nocov: diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/response.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/response.rb new file mode 100644 index 0000000..ece451d --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/response.rb @@ -0,0 +1,403 @@ +# frozen_string_literal: true + +require 'time' + +require_relative 'constants' +require_relative 'utils' +require_relative 'media_type' +require_relative 'headers' + +module Rack + # Rack::Response provides a convenient interface to create a Rack + # response. + # + # It allows setting of headers and cookies, and provides useful + # defaults (an OK response with empty headers and body). + # + # You can use Response#write to iteratively generate your response, + # but note that this is buffered by Rack::Response until you call + # +finish+. +finish+ however can take a block inside which calls to + # +write+ are synchronous with the Rack response. + # + # Your application's +call+ should end returning Response#finish. + class Response + def self.[](status, headers, body) + self.new(body, status, headers) + end + + CHUNKED = 'chunked' + STATUS_WITH_NO_ENTITY_BODY = Utils::STATUS_WITH_NO_ENTITY_BODY + + attr_accessor :length, :status, :body + attr_reader :headers + + # Initialize the response object with the specified +body+, +status+ + # and +headers+. + # + # If the +body+ is +nil+, construct an empty response object with internal + # buffering. + # + # If the +body+ responds to +to_str+, assume it's a string-like object and + # construct a buffered response object containing using that string as the + # initial contents of the buffer. + # + # Otherwise it is expected +body+ conforms to the normal requirements of a + # Rack response body, typically implementing one of +each+ (enumerable + # body) or +call+ (streaming body). + # + # The +status+ defaults to +200+ which is the "OK" HTTP status code. You + # can provide any other valid status code. + # + # The +headers+ must be a +Hash+ of key-value header pairs which conform to + # the Rack specification for response headers. The key must be a +String+ + # instance and the value can be either a +String+ or +Array+ instance. + def initialize(body = nil, status = 200, headers = {}) + @status = status.to_i + + unless headers.is_a?(Hash) + raise ArgumentError, "Headers must be a Hash!" + end + + @headers = Headers.new + # Convert headers input to a plain hash with lowercase keys. + headers.each do |k, v| + @headers[k] = v + end + + @writer = self.method(:append) + + @block = nil + + # Keep track of whether we have expanded the user supplied body. + if body.nil? + @body = [] + @buffered = true + # Body is unspecified - it may be a buffered response, or it may be a HEAD response. + @length = nil + elsif body.respond_to?(:to_str) + @body = [body] + @buffered = true + @length = body.to_str.bytesize + else + @body = body + @buffered = nil # undetermined as of yet. + @length = nil + end + + yield self if block_given? + end + + def redirect(target, status = 302) + self.status = status + self.location = target + end + + def chunked? + CHUNKED == get_header(TRANSFER_ENCODING) + end + + def no_entity_body? + # The response body is an enumerable body and it is not allowed to have an entity body. + @body.respond_to?(:each) && STATUS_WITH_NO_ENTITY_BODY[@status] + end + + # Generate a response array consistent with the requirements of the SPEC. + # @return [Array] a 3-tuple suitable of `[status, headers, body]` + # which is suitable to be returned from the middleware `#call(env)` method. + def finish(&block) + if no_entity_body? + delete_header CONTENT_TYPE + delete_header CONTENT_LENGTH + close + return [@status, @headers, []] + else + if block_given? + # We don't add the content-length here as the user has provided a block that can #write additional chunks to the body. + @block = block + return [@status, @headers, self] + else + # If we know the length of the body, set the content-length header... except if we are chunked? which is a legacy special case where the body might already be encoded and thus the actual encoded body length and the content-length are likely to be different. + if @length && !chunked? + @headers[CONTENT_LENGTH] = @length.to_s + end + return [@status, @headers, @body] + end + end + end + + alias to_a finish # For *response + + def each(&callback) + @body.each(&callback) + @buffered = true + + if @block + @writer = callback + @block.call(self) + end + end + + # Append a chunk to the response body. + # + # Converts the response into a buffered response if it wasn't already. + # + # NOTE: Do not mix #write and direct #body access! + # + def write(chunk) + buffered_body! + + @writer.call(chunk.to_s) + end + + def close + @body.close if @body.respond_to?(:close) + end + + def empty? + @block == nil && @body.empty? + end + + def has_header?(key) + raise ArgumentError unless key.is_a?(String) + @headers.key?(key) + end + def get_header(key) + raise ArgumentError unless key.is_a?(String) + @headers[key] + end + def set_header(key, value) + raise ArgumentError unless key.is_a?(String) + @headers[key] = value + end + def delete_header(key) + raise ArgumentError unless key.is_a?(String) + @headers.delete key + end + + alias :[] :get_header + alias :[]= :set_header + + module Helpers + def invalid?; status < 100 || status >= 600; end + + def informational?; status >= 100 && status < 200; end + def successful?; status >= 200 && status < 300; end + def redirection?; status >= 300 && status < 400; end + def client_error?; status >= 400 && status < 500; end + def server_error?; status >= 500 && status < 600; end + + def ok?; status == 200; end + def created?; status == 201; end + def accepted?; status == 202; end + def no_content?; status == 204; end + def moved_permanently?; status == 301; end + def bad_request?; status == 400; end + def unauthorized?; status == 401; end + def forbidden?; status == 403; end + def not_found?; status == 404; end + def method_not_allowed?; status == 405; end + def not_acceptable?; status == 406; end + def request_timeout?; status == 408; end + def precondition_failed?; status == 412; end + def unprocessable?; status == 422; end + + def redirect?; [301, 302, 303, 307, 308].include? status; end + + def include?(header) + has_header?(header) + end + + # Add a header that may have multiple values. + # + # Example: + # response.add_header 'vary', 'accept-encoding' + # response.add_header 'vary', 'cookie' + # + # assert_equal 'accept-encoding,cookie', response.get_header('vary') + # + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 + def add_header(key, value) + raise ArgumentError unless key.is_a?(String) + + if value.nil? + return get_header(key) + end + + value = value.to_s + + if header = get_header(key) + if header.is_a?(Array) + header << value + else + set_header(key, [header, value]) + end + else + set_header(key, value) + end + end + + # Get the content type of the response. + def content_type + get_header CONTENT_TYPE + end + + # Set the content type of the response. + def content_type=(content_type) + set_header CONTENT_TYPE, content_type + end + + def media_type + MediaType.type(content_type) + end + + def media_type_params + MediaType.params(content_type) + end + + def content_length + cl = get_header CONTENT_LENGTH + cl ? cl.to_i : cl + end + + def location + get_header "location" + end + + def location=(location) + set_header "location", location + end + + def set_cookie(key, value) + add_header SET_COOKIE, Utils.set_cookie_header(key, value) + end + + def delete_cookie(key, value = {}) + set_header(SET_COOKIE, + Utils.delete_set_cookie_header!( + get_header(SET_COOKIE), key, value + ) + ) + end + + def set_cookie_header + get_header SET_COOKIE + end + + def set_cookie_header=(value) + set_header SET_COOKIE, value + end + + def cache_control + get_header CACHE_CONTROL + end + + def cache_control=(value) + set_header CACHE_CONTROL, value + end + + # Specifies that the content shouldn't be cached. Overrides `cache!` if already called. + def do_not_cache! + set_header CACHE_CONTROL, "no-cache, must-revalidate" + set_header EXPIRES, Time.now.httpdate + end + + # Specify that the content should be cached. + # @param duration [Integer] The number of seconds until the cache expires. + # @option directive [String] The cache control directive, one of "public", "private", "no-cache" or "no-store". + def cache!(duration = 3600, directive: "public") + unless headers[CACHE_CONTROL] =~ /no-cache/ + set_header CACHE_CONTROL, "#{directive}, max-age=#{duration}" + set_header EXPIRES, (Time.now + duration).httpdate + end + end + + def etag + get_header ETAG + end + + def etag=(value) + set_header ETAG, value + end + + protected + + # Convert the body of this response into an internally buffered Array if possible. + # + # `@buffered` is a ternary value which indicates whether the body is buffered. It can be: + # * `nil` - The body has not been buffered yet. + # * `true` - The body is buffered as an Array instance. + # * `false` - The body is not buffered and cannot be buffered. + # + # @return [Boolean] whether the body is buffered as an Array instance. + def buffered_body! + if @buffered.nil? + if @body.is_a?(Array) + # The user supplied body was an array: + @body = @body.compact + @length = @body.sum{|part| part.bytesize} + @buffered = true + elsif @body.respond_to?(:each) + # Turn the user supplied body into a buffered array: + body = @body + @body = Array.new + @buffered = true + + body.each do |part| + @writer.call(part.to_s) + end + + body.close if body.respond_to?(:close) + else + # We don't know how to buffer the user-supplied body: + @buffered = false + end + end + + return @buffered + end + + def append(chunk) + chunk = chunk.dup unless chunk.frozen? + @body << chunk + + if @length + @length += chunk.bytesize + elsif @buffered + @length = chunk.bytesize + end + + return chunk + end + end + + include Helpers + + class Raw + include Helpers + + attr_reader :headers + attr_accessor :status + + def initialize(status, headers) + @status = status + @headers = headers + end + + def has_header?(key) + headers.key?(key) + end + + def get_header(key) + headers[key] + end + + def set_header(key, value) + headers[key] = value + end + + def delete_header(key) + headers.delete(key) + end + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb new file mode 100644 index 0000000..730c6a2 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb @@ -0,0 +1,113 @@ +# -*- encoding: binary -*- +# frozen_string_literal: true + +require 'tempfile' + +require_relative 'constants' + +module Rack + # Class which can make any IO object rewindable, including non-rewindable ones. It does + # this by buffering the data into a tempfile, which is rewindable. + # + # Don't forget to call #close when you're done. This frees up temporary resources that + # RewindableInput uses, though it does *not* close the original IO object. + class RewindableInput + # Makes rack.input rewindable, for compatibility with applications and middleware + # designed for earlier versions of Rack (where rack.input was required to be + # rewindable). + class Middleware + def initialize(app) + @app = app + end + + def call(env) + env[RACK_INPUT] = RewindableInput.new(env[RACK_INPUT]) + @app.call(env) + end + end + + def initialize(io) + @io = io + @rewindable_io = nil + @unlinked = false + end + + def gets + make_rewindable unless @rewindable_io + @rewindable_io.gets + end + + def read(*args) + make_rewindable unless @rewindable_io + @rewindable_io.read(*args) + end + + def each(&block) + make_rewindable unless @rewindable_io + @rewindable_io.each(&block) + end + + def rewind + make_rewindable unless @rewindable_io + @rewindable_io.rewind + end + + def size + make_rewindable unless @rewindable_io + @rewindable_io.size + end + + # Closes this RewindableInput object without closing the originally + # wrapped IO object. Cleans up any temporary resources that this RewindableInput + # has created. + # + # This method may be called multiple times. It does nothing on subsequent calls. + def close + if @rewindable_io + if @unlinked + @rewindable_io.close + else + @rewindable_io.close! + end + @rewindable_io = nil + end + end + + private + + def make_rewindable + # Buffer all data into a tempfile. Since this tempfile is private to this + # RewindableInput object, we chmod it so that nobody else can read or write + # it. On POSIX filesystems we also unlink the file so that it doesn't + # even have a file entry on the filesystem anymore, though we can still + # access it because we have the file handle open. + @rewindable_io = Tempfile.new('RackRewindableInput') + @rewindable_io.chmod(0000) + @rewindable_io.set_encoding(Encoding::BINARY) + @rewindable_io.binmode + # :nocov: + if filesystem_has_posix_semantics? + raise 'Unlink failed. IO closed.' if @rewindable_io.closed? + @unlinked = true + end + # :nocov: + + buffer = "".dup + while @io.read(1024 * 4, buffer) + entire_buffer_written_out = false + while !entire_buffer_written_out + written = @rewindable_io.write(buffer) + entire_buffer_written_out = written == buffer.bytesize + if !entire_buffer_written_out + buffer.slice!(0 .. written - 1) + end + end + end + @rewindable_io.rewind + end + + def filesystem_has_posix_semantics? + RUBY_PLATFORM !~ /(mswin|mingw|cygwin|java)/ + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/runtime.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/runtime.rb new file mode 100644 index 0000000..a1bfa69 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/runtime.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative 'utils' + +module Rack + # Sets an "x-runtime" response header, indicating the response + # time of the request, in seconds + # + # You can put it right before the application to see the processing + # time, or before all the other middlewares to include time for them, + # too. + class Runtime + FORMAT_STRING = "%0.6f" # :nodoc: + HEADER_NAME = "x-runtime" # :nodoc: + + def initialize(app, name = nil) + @app = app + @header_name = HEADER_NAME + @header_name += "-#{name.to_s.downcase}" if name + end + + def call(env) + start_time = Utils.clock_time + _, headers, _ = response = @app.call(env) + + request_time = Utils.clock_time - start_time + + unless headers.key?(@header_name) + headers[@header_name] = FORMAT_STRING % request_time + end + + response + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/sendfile.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/sendfile.rb new file mode 100644 index 0000000..9c6e0c4 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/sendfile.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' +require_relative 'body_proxy' + +module Rack + + # = Sendfile + # + # The Sendfile middleware intercepts responses whose body is being + # served from a file and replaces it with a server specific x-sendfile + # header. The web server is then responsible for writing the file contents + # to the client. This can dramatically reduce the amount of work required + # by the Ruby backend and takes advantage of the web server's optimized file + # delivery code. + # + # In order to take advantage of this middleware, the response body must + # respond to +to_path+ and the request must include an x-sendfile-type + # header. Rack::Files and other components implement +to_path+ so there's + # rarely anything you need to do in your application. The x-sendfile-type + # header is typically set in your web servers configuration. The following + # sections attempt to document + # + # === Nginx + # + # Nginx supports the x-accel-redirect header. This is similar to x-sendfile + # but requires parts of the filesystem to be mapped into a private URL + # hierarchy. + # + # The following example shows the Nginx configuration required to create + # a private "/files/" area, enable x-accel-redirect, and pass the special + # x-sendfile-type and x-accel-mapping headers to the backend: + # + # location ~ /files/(.*) { + # internal; + # alias /var/www/$1; + # } + # + # location / { + # proxy_redirect off; + # + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # + # proxy_set_header x-sendfile-type x-accel-redirect; + # proxy_set_header x-accel-mapping /var/www/=/files/; + # + # proxy_pass http://127.0.0.1:8080/; + # } + # + # Note that the x-sendfile-type header must be set exactly as shown above. + # The x-accel-mapping header should specify the location on the file system, + # followed by an equals sign (=), followed name of the private URL pattern + # that it maps to. The middleware performs a simple substitution on the + # resulting path. + # + # See Also: https://www.nginx.com/resources/wiki/start/topics/examples/xsendfile + # + # === lighttpd + # + # Lighttpd has supported some variation of the x-sendfile header for some + # time, although only recent version support x-sendfile in a reverse proxy + # configuration. + # + # $HTTP["host"] == "example.com" { + # proxy-core.protocol = "http" + # proxy-core.balancer = "round-robin" + # proxy-core.backends = ( + # "127.0.0.1:8000", + # "127.0.0.1:8001", + # ... + # ) + # + # proxy-core.allow-x-sendfile = "enable" + # proxy-core.rewrite-request = ( + # "x-sendfile-type" => (".*" => "x-sendfile") + # ) + # } + # + # See Also: http://redmine.lighttpd.net/wiki/lighttpd/Docs:ModProxyCore + # + # === Apache + # + # x-sendfile is supported under Apache 2.x using a separate module: + # + # https://tn123.org/mod_xsendfile/ + # + # Once the module is compiled and installed, you can enable it using + # XSendFile config directive: + # + # RequestHeader Set x-sendfile-type x-sendfile + # ProxyPassReverse / http://localhost:8001/ + # XSendFile on + # + # === Mapping parameter + # + # The third parameter allows for an overriding extension of the + # x-accel-mapping header. Mappings should be provided in tuples of internal to + # external. The internal values may contain regular expression syntax, they + # will be matched with case indifference. + + class Sendfile + def initialize(app, variation = nil, mappings = []) + @app = app + @variation = variation + @mappings = mappings.map do |internal, external| + [/^#{internal}/i, external] + end + end + + def call(env) + _, headers, body = response = @app.call(env) + + if body.respond_to?(:to_path) + case type = variation(env) + when /x-accel-redirect/i + path = ::File.expand_path(body.to_path) + if url = map_accel_path(env, path) + headers[CONTENT_LENGTH] = '0' + # '?' must be percent-encoded because it is not query string but a part of path + headers[type.downcase] = ::Rack::Utils.escape_path(url).gsub('?', '%3F') + obody = body + response[2] = Rack::BodyProxy.new([]) do + obody.close if obody.respond_to?(:close) + end + else + env[RACK_ERRORS].puts "x-accel-mapping header missing" + end + when /x-sendfile|x-lighttpd-send-file/i + path = ::File.expand_path(body.to_path) + headers[CONTENT_LENGTH] = '0' + headers[type.downcase] = path + obody = body + response[2] = Rack::BodyProxy.new([]) do + obody.close if obody.respond_to?(:close) + end + when '', nil + else + env[RACK_ERRORS].puts "Unknown x-sendfile variation: '#{type}'.\n" + end + end + response + end + + private + def variation(env) + @variation || + env['sendfile.type'] || + env['HTTP_X_SENDFILE_TYPE'] + end + + def map_accel_path(env, path) + if mapping = @mappings.find { |internal, _| internal =~ path } + path.sub(*mapping) + elsif mapping = env['HTTP_X_ACCEL_MAPPING'] + mapping.split(',').map(&:strip).each do |m| + internal, external = m.split('=', 2).map(&:strip) + new_path = path.sub(/^#{internal}/i, external) + return new_path unless path == new_path + end + path + end + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb new file mode 100644 index 0000000..9172a4d --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb @@ -0,0 +1,407 @@ +# frozen_string_literal: true + +require 'erb' + +require_relative 'constants' +require_relative 'utils' +require_relative 'request' + +module Rack + # Rack::ShowExceptions catches all exceptions raised from the app it + # wraps. It shows a useful backtrace with the sourcefile and + # clickable context, the whole Rack environment and the request + # data. + # + # Be careful when you use this on public-facing sites as it could + # reveal information helpful to attackers. + + class ShowExceptions + CONTEXT = 7 + + Frame = Struct.new(:filename, :lineno, :function, + :pre_context_lineno, :pre_context, + :context_line, :post_context_lineno, + :post_context) + + def initialize(app) + @app = app + end + + def call(env) + @app.call(env) + rescue StandardError, LoadError, SyntaxError => e + exception_string = dump_exception(e) + + env[RACK_ERRORS].puts(exception_string) + env[RACK_ERRORS].flush + + if accepts_html?(env) + content_type = "text/html" + body = pretty(env, e) + else + content_type = "text/plain" + body = exception_string + end + + [ + 500, + { + CONTENT_TYPE => content_type, + CONTENT_LENGTH => body.bytesize.to_s, + }, + [body], + ] + end + + def prefers_plaintext?(env) + !accepts_html?(env) + end + + def accepts_html?(env) + Rack::Utils.best_q_match(env["HTTP_ACCEPT"], %w[text/html]) + end + private :accepts_html? + + def dump_exception(exception) + if exception.respond_to?(:detailed_message) + message = exception.detailed_message(highlight: false) + else + message = exception.message + end + string = "#{exception.class}: #{message}\n".dup + string << exception.backtrace.map { |l| "\t#{l}" }.join("\n") + string + end + + def pretty(env, exception) + req = Rack::Request.new(env) + + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. + path = path = (req.script_name + req.path_info).squeeze("/") + + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. + frames = frames = exception.backtrace.map { |line| + frame = Frame.new + if line =~ /(.*?):(\d+)(:in `(.*)')?/ + frame.filename = $1 + frame.lineno = $2.to_i + frame.function = $4 + + begin + lineno = frame.lineno - 1 + lines = ::File.readlines(frame.filename) + frame.pre_context_lineno = [lineno - CONTEXT, 0].max + frame.pre_context = lines[frame.pre_context_lineno...lineno] + frame.context_line = lines[lineno].chomp + frame.post_context_lineno = [lineno + CONTEXT, lines.size].min + frame.post_context = lines[lineno + 1..frame.post_context_lineno] + rescue + end + + frame + else + nil + end + }.compact + + template.result(binding) + end + + def template + TEMPLATE + end + + def h(obj) # :nodoc: + case obj + when String + Utils.escape_html(obj) + else + Utils.escape_html(obj.inspect) + end + end + + # :stopdoc: + + # adapted from Django + # Copyright (c) Django Software Foundation and individual contributors. + # Used under the modified BSD license: + # http://www.xfree86.org/3.3.6/COPYRIGHT2.html#5 + TEMPLATE = ERB.new(<<-'HTML'.gsub(/^ /, '')) + + + + + + <%=h exception.class %> at <%=h path %> + + + + + +
+

<%=h exception.class %> at <%=h path %>

+ <% if exception.respond_to?(:detailed_message) %> +

<%=h exception.detailed_message(highlight: false) %>

+ <% else %> +

<%=h exception.message %>

+ <% end %> + + + + + + +
Ruby + <% if first = frames.first %> + <%=h first.filename %>: in <%=h first.function %>, line <%=h frames.first.lineno %> + <% else %> + unknown location + <% end %> +
Web<%=h req.request_method %> <%=h(req.host + path)%>
+ +

Jump to:

+ +
+ +
+

Traceback (innermost first)

+
    + <% frames.each { |frame| %> +
  • + <%=h frame.filename %>: in <%=h frame.function %> + + <% if frame.context_line %> +
    + <% if frame.pre_context %> +
      + <% frame.pre_context.each { |line| %> +
    1. <%=h line %>
    2. + <% } %> +
    + <% end %> + +
      +
    1. <%=h frame.context_line %>...
    + + <% if frame.post_context %> +
      + <% frame.post_context.each { |line| %> +
    1. <%=h line %>
    2. + <% } %> +
    + <% end %> +
    + <% end %> +
  • + <% } %> +
+
+ +
+

Request information

+ +

GET

+ <% if req.GET and not req.GET.empty? %> + + + + + + + + + <% req.GET.sort_by { |k, v| k.to_s }.each { |key, val| %> + + + + + <% } %> + +
VariableValue
<%=h key %>
<%=h val.inspect %>
+ <% else %> +

No GET data.

+ <% end %> + +

POST

+ <% if ((req.POST and not req.POST.empty?) rescue (no_post_data = "Invalid POST data"; nil)) %> + + + + + + + + + <% req.POST.sort_by { |k, v| k.to_s }.each { |key, val| %> + + + + + <% } %> + +
VariableValue
<%=h key %>
<%=h val.inspect %>
+ <% else %> +

<%= no_post_data || "No POST data" %>.

+ <% end %> + + + + <% unless req.cookies.empty? %> + + + + + + + + + <% req.cookies.each { |key, val| %> + + + + + <% } %> + +
VariableValue
<%=h key %>
<%=h val.inspect %>
+ <% else %> +

No cookie data.

+ <% end %> + +

Rack ENV

+ + + + + + + + + <% env.sort_by { |k, v| k.to_s }.each { |key, val| %> + + + + + <% } %> + +
VariableValue
<%=h key %>
<%=h val.inspect %>
+ +
+ +
+

+ You're seeing this error because you use Rack::ShowExceptions. +

+
+ + + + HTML + + # :startdoc: + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_status.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_status.rb new file mode 100644 index 0000000..b6f75a0 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_status.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'erb' + +require_relative 'constants' +require_relative 'utils' +require_relative 'request' +require_relative 'body_proxy' + +module Rack + # Rack::ShowStatus catches all empty responses and replaces them + # with a site explaining the error. + # + # Additional details can be put into rack.showstatus.detail + # and will be shown as HTML. If such details exist, the error page + # is always rendered, even if the reply was not empty. + + class ShowStatus + def initialize(app) + @app = app + @template = ERB.new(TEMPLATE) + end + + def call(env) + status, headers, body = response = @app.call(env) + empty = headers[CONTENT_LENGTH].to_i <= 0 + + # client or server error, or explicit message + if (status.to_i >= 400 && empty) || env[RACK_SHOWSTATUS_DETAIL] + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. + req = req = Rack::Request.new(env) + + message = Rack::Utils::HTTP_STATUS_CODES[status.to_i] || status.to_s + + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. + detail = detail = env[RACK_SHOWSTATUS_DETAIL] || message + + html = @template.result(binding) + size = html.bytesize + + response[2] = Rack::BodyProxy.new([html]) do + body.close if body.respond_to?(:close) + end + + headers[CONTENT_TYPE] = "text/html" + headers[CONTENT_LENGTH] = size.to_s + end + + response + end + + def h(obj) # :nodoc: + case obj + when String + Utils.escape_html(obj) + else + Utils.escape_html(obj.inspect) + end + end + + # :stopdoc: + +# adapted from Django +# Copyright (c) Django Software Foundation and individual contributors. +# Used under the modified BSD license: +# http://www.xfree86.org/3.3.6/COPYRIGHT2.html#5 +TEMPLATE = <<'HTML' + + + + + <%=h message %> at <%=h req.script_name + req.path_info %> + + + + +
+

<%=h message %> (<%= status.to_i %>)

+ + + + + + + + + +
Request Method:<%=h req.request_method %>
Request URL:<%=h req.url %>
+
+
+

<%=h detail %>

+
+ +
+

+ You're seeing this error because you use Rack::ShowStatus. +

+
+ + +HTML + + # :startdoc: + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/static.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/static.rb new file mode 100644 index 0000000..5c9b676 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/static.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'files' +require_relative 'mime' + +module Rack + + # The Rack::Static middleware intercepts requests for static files + # (javascript files, images, stylesheets, etc) based on the url prefixes or + # route mappings passed in the options, and serves them using a Rack::Files + # object. This allows a Rack stack to serve both static and dynamic content. + # + # Examples: + # + # Serve all requests beginning with /media from the "media" folder located + # in the current directory (ie media/*): + # + # use Rack::Static, :urls => ["/media"] + # + # Same as previous, but instead of returning 404 for missing files under + # /media, call the next middleware: + # + # use Rack::Static, :urls => ["/media"], :cascade => true + # + # Serve all requests beginning with /css or /images from the folder "public" + # in the current directory (ie public/css/* and public/images/*): + # + # use Rack::Static, :urls => ["/css", "/images"], :root => "public" + # + # Serve all requests to / with "index.html" from the folder "public" in the + # current directory (ie public/index.html): + # + # use Rack::Static, :urls => {"/" => 'index.html'}, :root => 'public' + # + # Serve all requests normally from the folder "public" in the current + # directory but uses index.html as default route for "/" + # + # use Rack::Static, :urls => [""], :root => 'public', :index => + # 'index.html' + # + # Set custom HTTP Headers for based on rules: + # + # use Rack::Static, :root => 'public', + # :header_rules => [ + # [rule, {header_field => content, header_field => content}], + # [rule, {header_field => content}] + # ] + # + # Rules for selecting files: + # + # 1) All files + # Provide the :all symbol + # :all => Matches every file + # + # 2) Folders + # Provide the folder path as a string + # '/folder' or '/folder/subfolder' => Matches files in a certain folder + # + # 3) File Extensions + # Provide the file extensions as an array + # ['css', 'js'] or %w(css js) => Matches files ending in .css or .js + # + # 4) Regular Expressions / Regexp + # Provide a regular expression + # %r{\.(?:css|js)\z} => Matches files ending in .css or .js + # /\.(?:eot|ttf|otf|woff2|woff|svg)\z/ => Matches files ending in + # the most common web font formats (.eot, .ttf, .otf, .woff2, .woff, .svg) + # Note: This Regexp is available as a shortcut, using the :fonts rule + # + # 5) Font Shortcut + # Provide the :fonts symbol + # :fonts => Uses the Regexp rule stated right above to match all common web font endings + # + # Rule Ordering: + # Rules are applied in the order that they are provided. + # List rather general rules above special ones. + # + # Complete example use case including HTTP header rules: + # + # use Rack::Static, :root => 'public', + # :header_rules => [ + # # Cache all static files in public caches (e.g. Rack::Cache) + # # as well as in the browser + # [:all, {'cache-control' => 'public, max-age=31536000'}], + # + # # Provide web fonts with cross-origin access-control-headers + # # Firefox requires this when serving assets using a Content Delivery Network + # [:fonts, {'access-control-allow-origin' => '*'}] + # ] + # + class Static + def initialize(app, options = {}) + @app = app + @urls = options[:urls] || ["/favicon.ico"] + @index = options[:index] + @gzip = options[:gzip] + @cascade = options[:cascade] + root = options[:root] || Dir.pwd + + # HTTP Headers + @header_rules = options[:header_rules] || [] + # Allow for legacy :cache_control option while prioritizing global header_rules setting + @header_rules.unshift([:all, { CACHE_CONTROL => options[:cache_control] }]) if options[:cache_control] + + @file_server = Rack::Files.new(root) + end + + def add_index_root?(path) + @index && route_file(path) && path.end_with?('/') + end + + def overwrite_file_path(path) + @urls.kind_of?(Hash) && @urls.key?(path) || add_index_root?(path) + end + + def route_file(path) + @urls.kind_of?(Array) && @urls.any? { |url| path.index(url) == 0 } + end + + def can_serve(path) + route_file(path) || overwrite_file_path(path) + end + + def call(env) + path = env[PATH_INFO] + + if can_serve(path) + if overwrite_file_path(path) + env[PATH_INFO] = (add_index_root?(path) ? path + @index : @urls[path]) + elsif @gzip && env['HTTP_ACCEPT_ENCODING'] && /\bgzip\b/.match?(env['HTTP_ACCEPT_ENCODING']) + path = env[PATH_INFO] + env[PATH_INFO] += '.gz' + response = @file_server.call(env) + env[PATH_INFO] = path + + if response[0] == 404 + response = nil + elsif response[0] == 304 + # Do nothing, leave headers as is + else + response[1][CONTENT_TYPE] = Mime.mime_type(::File.extname(path), 'text/plain') + response[1]['content-encoding'] = 'gzip' + end + end + + path = env[PATH_INFO] + response ||= @file_server.call(env) + + if @cascade && response[0] == 404 + return @app.call(env) + end + + headers = response[1] + applicable_rules(path).each do |rule, new_headers| + new_headers.each { |field, content| headers[field] = content } + end + + response + else + @app.call(env) + end + end + + # Convert HTTP header rules to HTTP headers + def applicable_rules(path) + @header_rules.find_all do |rule, new_headers| + case rule + when :all + true + when :fonts + /\.(?:ttf|otf|eot|woff2|woff|svg)\z/.match?(path) + when String + path = ::Rack::Utils.unescape(path) + path.start_with?(rule) || path.start_with?('/' + rule) + when Array + /\.(#{rule.join('|')})\z/.match?(path) + when Regexp + rule.match?(path) + else + false + end + end + end + + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb new file mode 100644 index 0000000..0b94cc7 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'body_proxy' + +module Rack + + # Middleware tracks and cleans Tempfiles created throughout a request (i.e. Rack::Multipart) + # Ideas/strategy based on posts by Eric Wong and Charles Oliver Nutter + # https://groups.google.com/forum/#!searchin/rack-devel/temp/rack-devel/brK8eh-MByw/sw61oJJCGRMJ + class TempfileReaper + def initialize(app) + @app = app + end + + def call(env) + env[RACK_TEMPFILES] ||= [] + + begin + _, _, body = response = @app.call(env) + rescue Exception + env[RACK_TEMPFILES]&.each(&:close!) + raise + end + + response[2] = BodyProxy.new(body) do + env[RACK_TEMPFILES]&.each(&:close!) + end + + response + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/urlmap.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/urlmap.rb new file mode 100644 index 0000000..99c4d82 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/urlmap.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'set' + +require_relative 'constants' + +module Rack + # Rack::URLMap takes a hash mapping urls or paths to apps, and + # dispatches accordingly. Support for HTTP/1.1 host names exists if + # the URLs start with http:// or https://. + # + # URLMap modifies the SCRIPT_NAME and PATH_INFO such that the part + # relevant for dispatch is in the SCRIPT_NAME, and the rest in the + # PATH_INFO. This should be taken care of when you need to + # reconstruct the URL in order to create links. + # + # URLMap dispatches in such a way that the longest paths are tried + # first, since they are most specific. + + class URLMap + def initialize(map = {}) + remap(map) + end + + def remap(map) + @known_hosts = Set[] + @mapping = map.map { |location, app| + if location =~ %r{\Ahttps?://(.*?)(/.*)} + host, location = $1, $2 + @known_hosts << host + else + host = nil + end + + unless location[0] == ?/ + raise ArgumentError, "paths need to start with /" + end + + location = location.chomp('/') + match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", Regexp::NOENCODING) + + [host, location, match, app] + }.sort_by do |(host, location, _, _)| + [host ? -host.size : Float::INFINITY, -location.size] + end + end + + def call(env) + path = env[PATH_INFO] + script_name = env[SCRIPT_NAME] + http_host = env[HTTP_HOST] + server_name = env[SERVER_NAME] + server_port = env[SERVER_PORT] + + is_same_server = casecmp?(http_host, server_name) || + casecmp?(http_host, "#{server_name}:#{server_port}") + + is_host_known = @known_hosts.include? http_host + + @mapping.each do |host, location, match, app| + unless casecmp?(http_host, host) \ + || casecmp?(server_name, host) \ + || (!host && is_same_server) \ + || (!host && !is_host_known) # If we don't have a matching host, default to the first without a specified host + next + end + + next unless m = match.match(path.to_s) + + rest = m[1] + next unless !rest || rest.empty? || rest[0] == ?/ + + env[SCRIPT_NAME] = (script_name + location) + env[PATH_INFO] = rest + + return app.call(env) + end + + [404, { CONTENT_TYPE => "text/plain", "x-cascade" => "pass" }, ["Not Found: #{path}"]] + + ensure + env[PATH_INFO] = path + env[SCRIPT_NAME] = script_name + end + + private + def casecmp?(v1, v2) + # if both nil, or they're the same string + return true if v1 == v2 + + # if either are nil... (but they're not the same) + return false if v1.nil? + return false if v2.nil? + + # otherwise check they're not case-insensitive the same + v1.casecmp(v2).zero? + end + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/utils.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/utils.rb new file mode 100644 index 0000000..bbf4969 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/utils.rb @@ -0,0 +1,631 @@ +# -*- encoding: binary -*- +# frozen_string_literal: true + +require 'uri' +require 'fileutils' +require 'set' +require 'tempfile' +require 'time' +require 'erb' + +require_relative 'query_parser' +require_relative 'mime' +require_relative 'headers' +require_relative 'constants' + +module Rack + # Rack::Utils contains a grab-bag of useful methods for writing web + # applications adopted from all kinds of Ruby libraries. + + module Utils + ParameterTypeError = QueryParser::ParameterTypeError + InvalidParameterError = QueryParser::InvalidParameterError + ParamsTooDeepError = QueryParser::ParamsTooDeepError + DEFAULT_SEP = QueryParser::DEFAULT_SEP + COMMON_SEP = QueryParser::COMMON_SEP + KeySpaceConstrainedParams = QueryParser::Params + URI_PARSER = defined?(::URI::RFC2396_PARSER) ? ::URI::RFC2396_PARSER : ::URI::DEFAULT_PARSER + + class << self + attr_accessor :default_query_parser + end + # The default amount of nesting to allowed by hash parameters. + # This helps prevent a rogue client from triggering a possible stack overflow + # when parsing parameters. + self.default_query_parser = QueryParser.make_default(32) + + module_function + + # URI escapes. (CGI style space to +) + def escape(s) + URI.encode_www_form_component(s) + end + + # Like URI escaping, but with %20 instead of +. Strictly speaking this is + # true URI escaping. + def escape_path(s) + URI_PARSER.escape s + end + + # Unescapes the **path** component of a URI. See Rack::Utils.unescape for + # unescaping query parameters or form components. + def unescape_path(s) + URI_PARSER.unescape s + end + + # Unescapes a URI escaped string with +encoding+. +encoding+ will be the + # target encoding of the string returned, and it defaults to UTF-8 + def unescape(s, encoding = Encoding::UTF_8) + URI.decode_www_form_component(s, encoding) + end + + class << self + attr_accessor :multipart_total_part_limit + + attr_accessor :multipart_file_limit + + # multipart_part_limit is the original name of multipart_file_limit, but + # the limit only counts parts with filenames. + alias multipart_part_limit multipart_file_limit + alias multipart_part_limit= multipart_file_limit= + end + + # The maximum number of file parts a request can contain. Accepting too + # many parts can lead to the server running out of file handles. + # Set to `0` for no limit. + self.multipart_file_limit = (ENV['RACK_MULTIPART_PART_LIMIT'] || ENV['RACK_MULTIPART_FILE_LIMIT'] || 128).to_i + + # The maximum total number of parts a request can contain. Accepting too + # many can lead to excessive memory use and parsing time. + self.multipart_total_part_limit = (ENV['RACK_MULTIPART_TOTAL_PART_LIMIT'] || 4096).to_i + + def self.param_depth_limit + default_query_parser.param_depth_limit + end + + def self.param_depth_limit=(v) + self.default_query_parser = self.default_query_parser.new_depth_limit(v) + end + + if defined?(Process::CLOCK_MONOTONIC) + def clock_time + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + else + # :nocov: + def clock_time + Time.now.to_f + end + # :nocov: + end + + def parse_query(qs, d = nil, &unescaper) + Rack::Utils.default_query_parser.parse_query(qs, d, &unescaper) + end + + def parse_nested_query(qs, d = nil) + Rack::Utils.default_query_parser.parse_nested_query(qs, d) + end + + def build_query(params) + params.map { |k, v| + if v.class == Array + build_query(v.map { |x| [k, x] }) + else + v.nil? ? escape(k) : "#{escape(k)}=#{escape(v)}" + end + }.join("&") + end + + def build_nested_query(value, prefix = nil) + case value + when Array + value.map { |v| + build_nested_query(v, "#{prefix}[]") + }.join("&") + when Hash + value.map { |k, v| + build_nested_query(v, prefix ? "#{prefix}[#{k}]" : k) + }.delete_if(&:empty?).join('&') + when nil + escape(prefix) + else + raise ArgumentError, "value must be a Hash" if prefix.nil? + "#{escape(prefix)}=#{escape(value)}" + end + end + + def q_values(q_value_header) + q_value_header.to_s.split(',').map do |part| + value, parameters = part.split(';', 2).map(&:strip) + quality = 1.0 + if parameters && (md = /\Aq=([\d.]+)/.match(parameters)) + quality = md[1].to_f + end + [value, quality] + end + end + + def forwarded_values(forwarded_header) + return nil unless forwarded_header + forwarded_header = forwarded_header.to_s.gsub("\n", ";") + + forwarded_header.split(';').each_with_object({}) do |field, values| + field.split(',').each do |pair| + pair = pair.split('=').map(&:strip).join('=') + return nil unless pair =~ /\A(by|for|host|proto)="?([^"]+)"?\Z/i + (values[$1.downcase.to_sym] ||= []) << $2 + end + end + end + module_function :forwarded_values + + # Return best accept value to use, based on the algorithm + # in RFC 2616 Section 14. If there are multiple best + # matches (same specificity and quality), the value returned + # is arbitrary. + def best_q_match(q_value_header, available_mimes) + values = q_values(q_value_header) + + matches = values.map do |req_mime, quality| + match = available_mimes.find { |am| Rack::Mime.match?(am, req_mime) } + next unless match + [match, quality] + end.compact.sort_by do |match, quality| + (match.split('/', 2).count('*') * -10) + quality + end.last + matches&.first + end + + # Introduced in ERB 4.0. ERB::Escape is an alias for ERB::Utils which + # doesn't get monkey-patched by rails + if defined?(ERB::Escape) && ERB::Escape.instance_method(:html_escape) + define_method(:escape_html, ERB::Escape.instance_method(:html_escape)) + else + require 'cgi/escape' + # Escape ampersands, brackets and quotes to their HTML/XML entities. + def escape_html(string) + CGI.escapeHTML(string.to_s) + end + end + + def select_best_encoding(available_encodings, accept_encoding) + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html + + expanded_accept_encoding = [] + + accept_encoding.each do |m, q| + preference = available_encodings.index(m) || available_encodings.size + + if m == "*" + (available_encodings - accept_encoding.map(&:first)).each do |m2| + expanded_accept_encoding << [m2, q, preference] + end + else + expanded_accept_encoding << [m, q, preference] + end + end + + encoding_candidates = expanded_accept_encoding + .sort_by { |_, q, p| [-q, p] } + .map!(&:first) + + unless encoding_candidates.include?("identity") + encoding_candidates.push("identity") + end + + expanded_accept_encoding.each do |m, q| + encoding_candidates.delete(m) if q == 0.0 + end + + (encoding_candidates & available_encodings)[0] + end + + # :call-seq: + # parse_cookies_header(value) -> hash + # + # Parse cookies from the provided header +value+ according to RFC6265. The + # syntax for cookie headers only supports semicolons. Returns a map of + # cookie +key+ to cookie +value+. + # + # parse_cookies_header('myname=myvalue; max-age=0') + # # => {"myname"=>"myvalue", "max-age"=>"0"} + # + def parse_cookies_header(value) + return {} unless value + + value.split(/; */n).each_with_object({}) do |cookie, cookies| + next if cookie.empty? + key, value = cookie.split('=', 2) + cookies[key] = (unescape(value) rescue value) unless cookies.key?(key) + end + end + + # :call-seq: + # parse_cookies(env) -> hash + # + # Parse cookies from the provided request environment using + # parse_cookies_header. Returns a map of cookie +key+ to cookie +value+. + # + # parse_cookies({'HTTP_COOKIE' => 'myname=myvalue'}) + # # => {'myname' => 'myvalue'} + # + def parse_cookies(env) + parse_cookies_header env[HTTP_COOKIE] + end + + # A valid cookie key according to RFC2616. + # A can be any US-ASCII characters, except control characters, spaces, or tabs. It also must not contain a separator character like the following: ( ) < > @ , ; : \ " / [ ] ? = { }. + VALID_COOKIE_KEY = /\A[!#$%&'*+\-\.\^_`|~0-9a-zA-Z]+\z/.freeze + private_constant :VALID_COOKIE_KEY + + private def escape_cookie_key(key) + if key =~ VALID_COOKIE_KEY + key + else + warn "Cookie key #{key.inspect} is not valid according to RFC2616; it will be escaped. This behaviour is deprecated and will be removed in a future version of Rack.", uplevel: 2 + escape(key) + end + end + + # :call-seq: + # set_cookie_header(key, value) -> encoded string + # + # Generate an encoded string using the provided +key+ and +value+ suitable + # for the +set-cookie+ header according to RFC6265. The +value+ may be an + # instance of either +String+ or +Hash+. + # + # If the cookie +value+ is an instance of +Hash+, it considers the following + # cookie attribute keys: +domain+, +max_age+, +expires+ (must be instance + # of +Time+), +secure+, +http_only+, +same_site+ and +value+. For more + # details about the interpretation of these fields, consult + # [RFC6265 Section 5.2](https://datatracker.ietf.org/doc/html/rfc6265#section-5.2). + # + # An extra cookie attribute +escape_key+ can be provided to control whether + # or not the cookie key is URL encoded. If explicitly set to +false+, the + # cookie key name will not be url encoded (escaped). The default is +true+. + # + # set_cookie_header("myname", "myvalue") + # # => "myname=myvalue" + # + # set_cookie_header("myname", {value: "myvalue", max_age: 10}) + # # => "myname=myvalue; max-age=10" + # + def set_cookie_header(key, value) + case value + when Hash + key = escape_cookie_key(key) unless value[:escape_key] == false + domain = "; domain=#{value[:domain]}" if value[:domain] + path = "; path=#{value[:path]}" if value[:path] + max_age = "; max-age=#{value[:max_age]}" if value[:max_age] + expires = "; expires=#{value[:expires].httpdate}" if value[:expires] + secure = "; secure" if value[:secure] + httponly = "; httponly" if (value.key?(:httponly) ? value[:httponly] : value[:http_only]) + same_site = + case value[:same_site] + when false, nil + nil + when :none, 'None', :None + '; samesite=none' + when :lax, 'Lax', :Lax + '; samesite=lax' + when true, :strict, 'Strict', :Strict + '; samesite=strict' + else + raise ArgumentError, "Invalid :same_site value: #{value[:same_site].inspect}" + end + partitioned = "; partitioned" if value[:partitioned] + value = value[:value] + else + key = escape_cookie_key(key) + end + + value = [value] unless Array === value + + return "#{key}=#{value.map { |v| escape v }.join('&')}#{domain}" \ + "#{path}#{max_age}#{expires}#{secure}#{httponly}#{same_site}#{partitioned}" + end + + # :call-seq: + # set_cookie_header!(headers, key, value) -> header value + # + # Append a cookie in the specified headers with the given cookie +key+ and + # +value+ using set_cookie_header. + # + # If the headers already contains a +set-cookie+ key, it will be converted + # to an +Array+ if not already, and appended to. + def set_cookie_header!(headers, key, value) + if header = headers[SET_COOKIE] + if header.is_a?(Array) + header << set_cookie_header(key, value) + else + headers[SET_COOKIE] = [header, set_cookie_header(key, value)] + end + else + headers[SET_COOKIE] = set_cookie_header(key, value) + end + end + + # :call-seq: + # delete_set_cookie_header(key, value = {}) -> encoded string + # + # Generate an encoded string based on the given +key+ and +value+ using + # set_cookie_header for the purpose of causing the specified cookie to be + # deleted. The +value+ may be an instance of +Hash+ and can include + # attributes as outlined by set_cookie_header. The encoded cookie will have + # a +max_age+ of 0 seconds, an +expires+ date in the past and an empty + # +value+. When used with the +set-cookie+ header, it will cause the client + # to *remove* any matching cookie. + # + # delete_set_cookie_header("myname") + # # => "myname=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" + # + def delete_set_cookie_header(key, value = {}) + set_cookie_header(key, value.merge(max_age: '0', expires: Time.at(0), value: '')) + end + + def delete_cookie_header!(headers, key, value = {}) + headers[SET_COOKIE] = delete_set_cookie_header!(headers[SET_COOKIE], key, value) + + return nil + end + + # :call-seq: + # delete_set_cookie_header!(header, key, value = {}) -> header value + # + # Set an expired cookie in the specified headers with the given cookie + # +key+ and +value+ using delete_set_cookie_header. This causes + # the client to immediately delete the specified cookie. + # + # delete_set_cookie_header!(nil, "mycookie") + # # => "mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" + # + # If the header is non-nil, it will be modified in place. + # + # header = [] + # delete_set_cookie_header!(header, "mycookie") + # # => ["mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"] + # header + # # => ["mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"] + # + def delete_set_cookie_header!(header, key, value = {}) + if header + header = Array(header) + header << delete_set_cookie_header(key, value) + else + header = delete_set_cookie_header(key, value) + end + + return header + end + + def rfc2822(time) + time.rfc2822 + end + + # Parses the "Range:" header, if present, into an array of Range objects. + # Returns nil if the header is missing or syntactically invalid. + # Returns an empty array if none of the ranges are satisfiable. + def byte_ranges(env, size) + get_byte_ranges env['HTTP_RANGE'], size + end + + def get_byte_ranges(http_range, size) + # See + # Ignore Range when file size is 0 to avoid a 416 error. + return nil if size.zero? + return nil unless http_range && http_range =~ /bytes=([^;]+)/ + ranges = [] + $1.split(/,\s*/).each do |range_spec| + return nil unless range_spec.include?('-') + range = range_spec.split('-') + r0, r1 = range[0], range[1] + if r0.nil? || r0.empty? + return nil if r1.nil? + # suffix-byte-range-spec, represents trailing suffix of file + r0 = size - r1.to_i + r0 = 0 if r0 < 0 + r1 = size - 1 + else + r0 = r0.to_i + if r1.nil? + r1 = size - 1 + else + r1 = r1.to_i + return nil if r1 < r0 # backwards range is syntactically invalid + r1 = size - 1 if r1 >= size + end + end + ranges << (r0..r1) if r0 <= r1 + end + + return [] if ranges.map(&:size).sum > size + + ranges + end + + # :nocov: + if defined?(OpenSSL.fixed_length_secure_compare) + # Constant time string comparison. + # + # NOTE: the values compared should be of fixed length, such as strings + # that have already been processed by HMAC. This should not be used + # on variable length plaintext strings because it could leak length info + # via timing attacks. + def secure_compare(a, b) + return false unless a.bytesize == b.bytesize + + OpenSSL.fixed_length_secure_compare(a, b) + end + # :nocov: + else + def secure_compare(a, b) + return false unless a.bytesize == b.bytesize + + l = a.unpack("C*") + + r, i = 0, -1 + b.each_byte { |v| r |= v ^ l[i += 1] } + r == 0 + end + end + + # Context allows the use of a compatible middleware at different points + # in a request handling stack. A compatible middleware must define + # #context which should take the arguments env and app. The first of which + # would be the request environment. The second of which would be the rack + # application that the request would be forwarded to. + class Context + attr_reader :for, :app + + def initialize(app_f, app_r) + raise 'running context does not respond to #context' unless app_f.respond_to? :context + @for, @app = app_f, app_r + end + + def call(env) + @for.context(env, @app) + end + + def recontext(app) + self.class.new(@for, app) + end + + def context(env, app = @app) + recontext(app).call(env) + end + end + + # Every standard HTTP code mapped to the appropriate message. + # Generated with: + # curl -s https://www.iana.org/assignments/http-status-codes/http-status-codes-1.csv \ + # | ruby -rcsv -e "puts CSV.parse(STDIN, headers: true) \ + # .reject {|v| v['Description'] == 'Unassigned' or v['Description'].include? '(' } \ + # .map {|v| %Q/#{v['Value']} => '#{v['Description']}'/ }.join(','+?\n)" + HTTP_STATUS_CODES = { + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 103 => 'Early Hints', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', + 208 => 'Already Reported', + 226 => 'IM Used', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Content Too Large', + 414 => 'URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Range Not Satisfiable', + 417 => 'Expectation Failed', + 421 => 'Misdirected Request', + 422 => 'Unprocessable Content', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Too Early', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 511 => 'Network Authentication Required' + } + + # Responses with HTTP status codes that should not have an entity body + STATUS_WITH_NO_ENTITY_BODY = Hash[((100..199).to_a << 204 << 304).product([true])] + + SYMBOL_TO_STATUS_CODE = Hash[*HTTP_STATUS_CODES.map { |code, message| + [message.downcase.gsub(/\s|-/, '_').to_sym, code] + }.flatten] + + OBSOLETE_SYMBOLS_TO_STATUS_CODES = { + payload_too_large: 413, + unprocessable_entity: 422, + bandwidth_limit_exceeded: 509, + not_extended: 510 + }.freeze + private_constant :OBSOLETE_SYMBOLS_TO_STATUS_CODES + + OBSOLETE_SYMBOL_MAPPINGS = { + payload_too_large: :content_too_large, + unprocessable_entity: :unprocessable_content + }.freeze + private_constant :OBSOLETE_SYMBOL_MAPPINGS + + def status_code(status) + if status.is_a?(Symbol) + SYMBOL_TO_STATUS_CODE.fetch(status) do + fallback_code = OBSOLETE_SYMBOLS_TO_STATUS_CODES.fetch(status) { raise ArgumentError, "Unrecognized status code #{status.inspect}" } + message = "Status code #{status.inspect} is deprecated and will be removed in a future version of Rack." + if canonical_symbol = OBSOLETE_SYMBOL_MAPPINGS[status] + # message = "#{message} Please use #{canonical_symbol.inspect} instead." + # For now, let's not emit any warning when there is a mapping. + else + warn message, uplevel: 3 + end + fallback_code + end + else + status.to_i + end + end + + PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact) + + def clean_path_info(path_info) + parts = path_info.split PATH_SEPS + + clean = [] + + parts.each do |part| + next if part.empty? || part == '.' + part == '..' ? clean.pop : clean << part + end + + clean_path = clean.join(::File::SEPARATOR) + clean_path.prepend("/") if parts.empty? || parts.first.empty? + clean_path + end + + NULL_BYTE = "\0" + + def valid_path?(path) + path.valid_encoding? && !path.include?(NULL_BYTE) + end + + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/version.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/version.rb new file mode 100644 index 0000000..5b45e76 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/version.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Copyright (C) 2007-2019 Leah Neukirchen +# +# Rack is freely distributable under the terms of an MIT-style license. +# See MIT-LICENSE or https://opensource.org/licenses/MIT. + +# The Rack main module, serving as a namespace for all core Rack +# modules and classes. +# +# All modules meant for use in your application are autoloaded here, +# so it should be enough just to require 'rack' in your code. + +module Rack + RELEASE = "3.1.8" + + # Return the Rack release as a dotted string. + def self.release + RELEASE + end +end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/rack.gemspec b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/rack.gemspec new file mode 100644 index 0000000..ed37415 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/rack.gemspec @@ -0,0 +1,31 @@ +# -*- encoding: utf-8 -*- +# stub: rack 3.1.8 ruby lib + +Gem::Specification.new do |s| + s.name = "rack".freeze + s.version = "3.1.8".freeze + + s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= + s.metadata = { "bug_tracker_uri" => "https://github.com/rack/rack/issues", "changelog_uri" => "https://github.com/rack/rack/blob/main/CHANGELOG.md", "documentation_uri" => "https://rubydoc.info/github/rack/rack", "source_code_uri" => "https://github.com/rack/rack" } if s.respond_to? :metadata= + s.require_paths = ["lib".freeze] + s.authors = ["Leah Neukirchen".freeze] + s.date = "2024-10-14" + s.description = "Rack provides a minimal, modular and adaptable interface for developing\nweb applications in Ruby. By wrapping HTTP requests and responses in\nthe simplest way possible, it unifies and distills the API for web\nservers, web frameworks, and software in between (the so-called\nmiddleware) into a single method call.\n".freeze + s.email = "leah@vuxu.org".freeze + s.extra_rdoc_files = ["README.md".freeze, "CHANGELOG.md".freeze, "CONTRIBUTING.md".freeze] + s.files = ["CHANGELOG.md".freeze, "CONTRIBUTING.md".freeze, "README.md".freeze] + s.homepage = "https://github.com/rack/rack".freeze + s.licenses = ["MIT".freeze] + s.required_ruby_version = Gem::Requirement.new(">= 2.4.0".freeze) + s.rubygems_version = "3.5.11".freeze + s.summary = "A modular Ruby webserver interface.".freeze + + s.installed_by_version = "3.5.22".freeze + + s.specification_version = 4 + + s.add_development_dependency(%q.freeze, ["~> 5.0".freeze]) + s.add_development_dependency(%q.freeze, [">= 0".freeze]) + s.add_development_dependency(%q.freeze, [">= 0".freeze]) + s.add_development_dependency(%q.freeze, [">= 0".freeze]) +end diff --git a/spikes/gem-checksums/path-with-checksums/before/.bundle/config b/spikes/gem-checksums/path-with-checksums/before/.bundle/config new file mode 100644 index 0000000..6eb400d --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/before/.bundle/config @@ -0,0 +1,3 @@ +--- +BUNDLE_PATH: "vendor/bundle" +BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/path-with-checksums/before/Gemfile b/spikes/gem-checksums/path-with-checksums/before/Gemfile new file mode 100644 index 0000000..864c947 --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/before/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rack", "3.1.8" diff --git a/spikes/gem-checksums/path-with-checksums/before/Gemfile.lock b/spikes/gem-checksums/path-with-checksums/before/Gemfile.lock new file mode 100644 index 0000000..7898b2f --- /dev/null +++ b/spikes/gem-checksums/path-with-checksums/before/Gemfile.lock @@ -0,0 +1,17 @@ +GEM + remote: https://rubygems.org/ + specs: + rack (3.1.8) + +PLATFORMS + aarch64-linux + ruby + +DEPENDENCIES + rack (= 3.1.8) + +CHECKSUMS + rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 + +BUNDLED WITH + 2.7.2 diff --git a/spikes/gem-checksums/registry-with-checksums/after/.bundle/config b/spikes/gem-checksums/registry-with-checksums/after/.bundle/config new file mode 100644 index 0000000..6eb400d --- /dev/null +++ b/spikes/gem-checksums/registry-with-checksums/after/.bundle/config @@ -0,0 +1,3 @@ +--- +BUNDLE_PATH: "vendor/bundle" +BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/registry-with-checksums/after/Gemfile b/spikes/gem-checksums/registry-with-checksums/after/Gemfile new file mode 100644 index 0000000..864c947 --- /dev/null +++ b/spikes/gem-checksums/registry-with-checksums/after/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rack", "3.1.8" diff --git a/spikes/gem-checksums/registry-with-checksums/after/Gemfile.lock b/spikes/gem-checksums/registry-with-checksums/after/Gemfile.lock new file mode 100644 index 0000000..7898b2f --- /dev/null +++ b/spikes/gem-checksums/registry-with-checksums/after/Gemfile.lock @@ -0,0 +1,17 @@ +GEM + remote: https://rubygems.org/ + specs: + rack (3.1.8) + +PLATFORMS + aarch64-linux + ruby + +DEPENDENCIES + rack (= 3.1.8) + +CHECKSUMS + rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 + +BUNDLED WITH + 2.7.2 diff --git a/spikes/gem-checksums/registry-with-checksums/before/.bundle/config b/spikes/gem-checksums/registry-with-checksums/before/.bundle/config new file mode 100644 index 0000000..6eb400d --- /dev/null +++ b/spikes/gem-checksums/registry-with-checksums/before/.bundle/config @@ -0,0 +1,3 @@ +--- +BUNDLE_PATH: "vendor/bundle" +BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/registry-with-checksums/before/Gemfile b/spikes/gem-checksums/registry-with-checksums/before/Gemfile new file mode 100644 index 0000000..864c947 --- /dev/null +++ b/spikes/gem-checksums/registry-with-checksums/before/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rack", "3.1.8" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/.bundle/config b/spikes/gem-checksums/stale-checksum-v1-bug/after/.bundle/config new file mode 100644 index 0000000..6eb400d --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/.bundle/config @@ -0,0 +1,3 @@ +--- +BUNDLE_PATH: "vendor/bundle" +BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile b/spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile new file mode 100644 index 0000000..6d26ec6 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rack", "3.1.8", path: "./vendored/rack-3.1.8" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile.lock b/spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile.lock new file mode 100644 index 0000000..2cbbe92 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile.lock @@ -0,0 +1,21 @@ +PATH + remote: vendored/rack-3.1.8 + specs: + rack (3.1.8) + +GEM + remote: https://rubygems.org/ + specs: + +PLATFORMS + aarch64-linux + ruby + +DEPENDENCIES + rack (= 3.1.8)! + +CHECKSUMS + rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 + +BUNDLED WITH + 2.7.2 diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CHANGELOG.md b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CHANGELOG.md new file mode 100644 index 0000000..18069d3 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CHANGELOG.md @@ -0,0 +1,998 @@ +# Changelog + +All notable changes to this project will be documented in this file. For info on how to format all future additions to this file please reference [Keep A Changelog](https://keepachangelog.com/en/1.0.0/). + +## [3.1.8] - 2024-10-14 + +- Resolve deprecation warnings about uri `DEFAULT_PARSER`. ([#2249](https://github.com/rack/rack/pull/2249), [@earlopain]) + +## [3.1.7] - 2024-07-11 + +### Fixed + +- Do not remove escaped opening/closing quotes for content-disposition filenames. ([#2229](https://github.com/rack/rack/pull/2229), [@jeremyevans]) +- Fix encoding setting for non-binary IO-like objects in MockRequest#env_for. ([#2227](https://github.com/rack/rack/pull/2227), [@jeremyevans]) +- `Rack::Response` should not generate invalid `content-length` header. ([#2219](https://github.com/rack/rack/pull/2219), [@ioquatix]) +- Allow empty PATH_INFO. ([#2214](https://github.com/rack/rack/pull/2214), [@ioquatix]) + +## [3.1.6] - 2024-07-03 + +### Fixed + +- Fix several edge cases in `Rack::Request#parse_http_accept_header`'s implementation. ([#2226](https://github.com/rack/rack/pull/2226), [@ioquatix]) + +## [3.1.5] - 2024-07-02 + +### Security + +- Fix potential ReDoS attack in `Rack::Request#parse_http_accept_header`. ([GHSA-cj83-2ww7-mvq7](https://github.com/rack/rack/security/advisories/GHSA-cj83-2ww7-mvq7), [@dwisiswant0](https://github.com/dwisiswant0)) + +## [3.1.4] - 2024-06-22 + +### Fixed + +- Fix `Rack::Lint` matching some paths incorrectly as authority form. ([#2220](https://github.com/rack/rack/pull/2220), [@ioquatix]) + +## [3.1.3] - 2024-06-12 + +### Fixed + +- Fix passing non-strings to `Rack::Utils.escape_html`. ([#2202](https://github.com/rack/rack/pull/2202), [@earlopain]) +- `Rack::MockResponse` gracefully handles empty cookies ([#2203](https://github.com/rack/rack/pull/2203) [@wynksaiddestroy]) + +## [3.1.2] - 2024-06-11 + +- `Rack::Response` will take in to consideration chunked encoding responses ([#2204](https://github.com/rack/rack/pull/2204), [@tenderlove]) + +## [3.1.1] - 2024-06-11 + +- Oops! I shouldn't have shipped that + +## [3.1.0] - 2024-06-11 + +:warning: **This release includes several breaking changes.** Refer to the **Removed** section below for the list of deprecated methods that have been removed in this release. + +Rack v3.1 is primarily a maintenance release that removes features deprecated in Rack v3.0. Alongside these removals, there are several improvements to the Rack SPEC, mainly focused on enhancing input and output handling. These changes aim to make Rack more efficient and align better with the requirements of server implementations and relevant HTTP specifications. + +### SPEC Changes + +- `rack.input` is now optional. ([#1997](https://github.com/rack/rack/pull/1997), [#2018](https://github.com/rack/rack/pull/2018), [@ioquatix]) +- `PATH_INFO` is now validated according to the HTTP/1.1 specification. ([#2117](https://github.com/rack/rack/pull/2117), [#2181](https://github.com/rack/rack/pull/2181), [@ioquatix]) + - `OPTIONS *` is now accepted. ([#2114](https://github.com/rack/rack/pull/2114), [@doriantaylor](https://github.com/doriantaylor)) +- Introduce optional `rack.protocol` request and response header for handling connection upgrades. ([#1954](https://github.com/rack/rack/pull/1954), [@ioquatix]) + +### Added + +- Introduce `Rack::Multipart::MissingInputError` for improved handling of missing input in `#parse_multipart`. ([#2018](https://github.com/rack/rack/pull/2018), [@ioquatix]) +- Introduce `module Rack::BadRequest` which is included in multipart and query parser errors. ([#2019](https://github.com/rack/rack/pull/2019), [@ioquatix]) +- Add `.mjs` MIME type ([#2057](https://github.com/rack/rack/pull/2057), [@axilleas](https://github.com/axilleas)) +- `set_cookie_header` utility now supports the `partitioned` cookie attribute. This is required by Chrome in some embedded contexts. ([#2131](https://github.com/rack/rack/pull/2131), [@flavio-b](https://github.com/flavio-b)) +- Introduce `rack.early_hints` for sending `103 Early Hints` informational responses. ([#1831](https://github.com/rack/rack/pull/1831), [@casperisfine](https://github.com/casperisfine), [@jeremyevans]) + +### Changed + +- MIME type for JavaScript files (`.js`) changed from `application/javascript` to `text/javascript` ([`1bd0f15`](https://github.com/rack/rack/commit/1bd0f1597d8f4a90d47115f3e156a8ce7870c9c8), [@ioquatix]) +- Update MIME types associated to `.ttf`, `.woff`, `.woff2` and `.otf` extensions to use mondern `font/*` types. ([#2065](https://github.com/rack/rack/pull/2065), [@davidstosik]) +- `Rack::Utils.escape_html` is now delegated to `CGI.escapeHTML`. `'` is escaped to `#39;` instead of `#x27;`. (decimal vs hexadecimal) ([#2099](https://github.com/rack/rack/pull/2099), [@JunichiIto](https://github.com/JunichiIto)) +- Clarify use of `@buffered` and only update `content-length` when `Rack::Response#finish` is invoked. ([#2149](https://github.com/rack/rack/pull/2149), [@ioquatix]) + +### Deprecated + +- Deprecate automatic cache invalidation in `Request#{GET,POST}` ([#2073](https://github.com/rack/rack/pull/2073), [@jeremyevans]) +- Only cookie keys that are not valid according to the HTTP specifications are escaped. We are planning to deprecate this behaviour, so now a deprecation message will be emitted in this case. In the future, invalid cookie keys may not be accepted. ([#2191](https://github.com/rack/rack/pull/2191), [@ioquatix]) +- `Rack::Logger` is deprecated. ([#2197](https://github.com/rack/rack/pull/2197), [@ioquatix]) +- Add fallback lookup and deprecation warning for obsolete status symbols. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn](https://github.com/wtn)) +- Deprecate `Rack::Request#values_at`, use `request.params.values_at` instead ([#2183](https://github.com/rack/rack/pull/2183), [@ioquatix]) + +### Removed + +- Remove deprecated `Rack::Auth::Digest` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::Cascade::NotFound` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::Chunked` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::File`, use `Rack::Files` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::QueryParser` `key_space_limit` parameter with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::Response#header`, use `Rack::Response#headers` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated cookie methods from `Rack::Utils`: `add_cookie_to_header`, `make_delete_cookie_header`, `add_remove_cookie_to_header`. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::Utils::HeaderHash`. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::VERSION`, `Rack::VERSION_STRING`, `Rack.version`, use `Rack.release` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove non-standard status codes 306, 509, & 510 and update descriptions for 413, 422, & 451. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn](https://github.com/wtn)) +- Remove any dependency on `transfer-encoding: chunked`. ([#2195](https://github.com/rack/rack/pull/2195), [@ioquatix]) +- Remove deprecated `Rack::Request#[]`, use `request.params[key]` instead ([#2183](https://github.com/rack/rack/pull/2183), [@ioquatix]) + +### Fixed + +- In `Rack::Files`, ignore the `Range` header if served file is 0 bytes. ([#2159](https://github.com/rack/rack/pull/2159), [@zarqman]) + +## [3.0.11] - 2024-05-10 + +- Backport #2062 to 3-0-stable: Do not allow `BodyProxy` to respond to `to_str`, make `to_ary` call close . ([#2062](https://github.com/rack/rack/pull/2062), [@jeremyevans](https://github.com/jeremyevans)) + +## [3.0.10] - 2024-03-21 + +- Backport #2104 to 3-0-stable: Return empty when parsing a multi-part POST with only one end delimiter. ([#2164](https://github.com/rack/rack/pull/2164), [@JoeDupuis](https://github.com/JoeDupuis)) + +## [3.0.9.1] - 2024-02-21 + +### Security + +* [CVE-2024-26146] Fixed ReDoS in Accept header parsing +* [CVE-2024-25126] Fixed ReDoS in Content Type header parsing +* [CVE-2024-26141] Reject Range headers which are too large + +[CVE-2024-26146]: https://github.com/advisories/GHSA-54rr-7fvw-6x8f +[CVE-2024-25126]: https://github.com/advisories/GHSA-22f2-v57c-j9cx +[CVE-2024-26141]: https://github.com/advisories/GHSA-xj5v-6v4g-jfw6 + +## [3.0.9] - 2024-01-31 + +- Fix incorrect content-length header that was emitted when `Rack::Response#write` was used in some situations. ([#2150](https://github.com/rack/rack/pull/2150), [@mattbrictson](https://github.com/mattbrictson)) + +## [3.0.8] - 2023-06-14 + +- Fix some unused variable verbose warnings. ([#2084](https://github.com/rack/rack/pull/2084), [@jeremyevans], [@skipkayhil](https://github.com/skipkayhil)) + +## [3.0.7] - 2023-03-16 + +- Make query parameters without `=` have `nil` values. ([#2059](https://github.com/rack/rack/pull/2059), [@jeremyevans]) + +## [3.0.6.1] - 2023-03-13 + +### Security + +- [CVE-2023-27539] Avoid ReDoS in header parsing + +## [3.0.6] - 2023-03-13 + +- Add `QueryParser#missing_value` for handling missing values + tests. ([#2052](https://github.com/rack/rack/pull/2052), [@ioquatix]) + +## [3.0.5] - 2023-03-13 + +- Split form/query parsing into two steps. ([#2038](https://github.com/rack/rack/pull/2038), [@matthewd](https://github.com/matthewd)) + +## [3.0.4.2] - 2023-03-02 + +### Security + +- [CVE-2023-27530] Introduce multipart_total_part_limit to limit total parts + +## [3.0.4.1] - 2023-01-17 + +### Security + +- [CVE-2022-44571] Fix ReDoS vulnerability in multipart parser +- [CVE-2022-44570] Fix ReDoS in Rack::Utils.get_byte_ranges +- [CVE-2022-44572] Forbid control characters in attributes (also ReDoS) + +## [3.0.4] - 2023-01-17 + +- `Rack::Request#POST` should consistently raise errors. Cache errors that occur when invoking `Rack::Request#POST` so they can be raised again later. ([#2010](https://github.com/rack/rack/pull/2010), [@ioquatix]) +- Fix `Rack::Lint` error message for `HTTP_CONTENT_TYPE` and `HTTP_CONTENT_LENGTH`. ([#2007](https://github.com/rack/rack/pull/2007), [@byroot](https://github.com/byroot)) +- Extend `Rack::MethodOverride` to handle `QueryParser::ParamsTooDeepError` error. ([#2006](https://github.com/rack/rack/pull/2006), [@byroot](https://github.com/byroot)) + +## [3.0.3] - 2022-12-27 + +### Fixed + +- `Rack::URLMap` uses non-deprecated form of `Regexp.new`. ([#1998](https://github.com/rack/rack/pull/1998), [@weizheheng](https://github.com/weizheheng)) + +## [3.0.2] - 2022-12-05 + +### Fixed + +- `Utils.build_nested_query` URL-encodes nested field names including the square brackets. +- Allow `Rack::Response` to pass through streaming bodies. ([#1993](https://github.com/rack/rack/pull/1993), [@ioquatix]) + +## [3.0.1] - 2022-11-18 + +### Fixed + +- `MethodOverride` does not look for an override if a request does not include form/parseable data. +- `Rack::Lint::Wrapper` correctly handles `respond_to?` with `to_ary`, `each`, `call` and `to_path`, forwarding to the body. ([#1981](https://github.com/rack/rack/pull/1981), [@ioquatix]) + +## [3.0.0] - 2022-09-06 + +- No changes + +## [3.0.0.rc1] - 2022-09-04 + +### SPEC Changes + +- Stream argument must implement `<<` https://github.com/rack/rack/pull/1959 +- `close` may be called on `rack.input` https://github.com/rack/rack/pull/1956 +- `rack.response_finished` may be used for executing code after the response has been finished https://github.com/rack/rack/pull/1952 + +## [3.0.0.beta1] - 2022-08-08 + +### Security + +- Do not use semicolon as GET parameter separator. ([#1733](https://github.com/rack/rack/pull/1733), [@jeremyevans]) + +### SPEC Changes + +- Response array must now be non-frozen. +- Response `status` must now be an integer greater than or equal to 100. +- Response `headers` must now be an unfrozen hash. +- Response header keys can no longer include uppercase characters. +- Response header values can be an `Array` to handle multiple values (and no longer supports `\n` encoded headers). +- Response body can now respond to `#call` (streaming body) instead of `#each` (enumerable body), for the equivalent of response hijacking in previous versions. +- Middleware must no longer call `#each` on the body, but they can call `#to_ary` on the body if it responds to `#to_ary`. +- `rack.input` is no longer required to be rewindable. +- `rack.multithread`/`rack.multiprocess`/`rack.run_once`/`rack.version` are no longer required environment keys. +- `SERVER_PROTOCOL` is now a required environment key, matching the HTTP protocol used in the request. +- `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. +- `rack.hijack_io` has been removed completely. +- `rack.response_finished` is an optional environment key which contains an array of callable objects that must accept `#call(env, status, headers, error)` and are invoked after the response is finished (either successfully or unsuccessfully). +- It is okay to call `#close` on `rack.input` to indicate that you no longer need or care about the input. +- The stream argument supplied to the streaming body and hijack must support `#<<` for writing output. + +### Removed + +- Remove `rack.multithread`/`rack.multiprocess`/`rack.run_once`. These variables generally come too late to be useful. ([#1720](https://github.com/rack/rack/pull/1720), [@ioquatix], [@jeremyevans])) +- Remove deprecated Rack::Request::SCHEME_WHITELIST. ([@jeremyevans]) +- Remove internal cookie deletion using pattern matching, there are very few practical cases where it would be useful and browsers handle it correctly without us doing anything special. ([#1844](https://github.com/rack/rack/pull/1844), [@ioquatix]) +- Remove `rack.version` as it comes too late to be useful. ([#1938](https://github.com/rack/rack/pull/1938), [@ioquatix]) +- Extract `rackup` command, `Rack::Server`, `Rack::Handler`, `Rack::Lobster` and related code into a separate gem. ([#1937](https://github.com/rack/rack/pull/1937), [@ioquatix]) + +### Added + +- `Rack::Headers` added to support lower-case header keys. ([@jeremyevans]) +- `Rack::Utils#set_cookie_header` now supports `escape_key: false` to avoid key escaping. ([@jeremyevans]) +- `Rack::RewindableInput` supports size. ([@ahorek](https://github.com/ahorek)) +- `Rack::RewindableInput::Middleware` added for making `rack.input` rewindable. ([@jeremyevans]) +- The RFC 7239 Forwarded header is now supported and considered by default when looking for information on forwarding, falling back to the X-Forwarded-* headers. `Rack::Request.forwarded_priority` accessor has been added for configuring the priority of which header to check. ([#1423](https://github.com/rack/rack/issues/1423), [@jeremyevans]) +- Allow response headers to contain array of values. ([#1598](https://github.com/rack/rack/issues/1598), [@ioquatix]) +- Support callable body for explicit streaming support and clarify streaming response body behaviour. ([#1745](https://github.com/rack/rack/pull/1745), [@ioquatix], [#1748](https://github.com/rack/rack/pull/1748), [@wjordan]) +- Allow `Rack::Builder#run` to take a block instead of an argument. ([#1942](https://github.com/rack/rack/pull/1942), [@ioquatix]) +- Add `rack.response_finished` to `Rack::Lint`. ([#1802](https://github.com/rack/rack/pull/1802), [@BlakeWilliams], [#1952](https://github.com/rack/rack/pull/1952), [@ioquatix]) +- The stream argument must implement `#<<`. ([#1959](https://github.com/rack/rack/pull/1959), [@ioquatix]) + +### Changed + +- BREAKING CHANGE: Require `status` to be an Integer. ([#1662](https://github.com/rack/rack/pull/1662), [@olleolleolle](https://github.com/olleolleolle)) +- BREAKING CHANGE: Query parsing now treats parameters without `=` as having the empty string value instead of nil value, to conform to the URL spec. ([#1696](https://github.com/rack/rack/issues/1696), [@jeremyevans]) +- Relax validations around `Rack::Request#host` and `Rack::Request#hostname`. ([#1606](https://github.com/rack/rack/issues/1606), [@pvande](https://github.com/pvande)) +- Removed antiquated handlers: FCGI, LSWS, SCGI, Thin. ([#1658](https://github.com/rack/rack/pull/1658), [@ioquatix]) +- Removed options from `Rack::Builder.parse_file` and `Rack::Builder.load_file`. ([#1663](https://github.com/rack/rack/pull/1663), [@ioquatix]) +- `Rack::HTTP_VERSION` has been removed and the `HTTP_VERSION` env setting is no longer set in the CGI and Webrick handlers. ([#970](https://github.com/rack/rack/issues/970), [@jeremyevans]) +- `Rack::Request#[]` and `#[]=` now warn even in non-verbose mode. ([#1277](https://github.com/rack/rack/issues/1277), [@jeremyevans]) +- Decrease default allowed parameter recursion level from 100 to 32. ([#1640](https://github.com/rack/rack/issues/1640), [@jeremyevans]) +- Attempting to parse a multipart response with an empty body now raises Rack::Multipart::EmptyContentError. ([#1603](https://github.com/rack/rack/issues/1603), [@jeremyevans]) +- `Rack::Utils.secure_compare` uses OpenSSL's faster implementation if available. ([#1711](https://github.com/rack/rack/pull/1711), [@bdewater](https://github.com/bdewater)) +- `Rack::Request#POST` now caches an empty hash if input content type is not parseable. ([#749](https://github.com/rack/rack/pull/749), [@jeremyevans]) +- BREAKING CHANGE: Updated `trusted_proxy?` to match full 127.0.0.0/8 network. ([#1781](https://github.com/rack/rack/pull/1781), [@snbloch](https://github.com/snbloch)) +- Explicitly deprecate `Rack::File` which was an alias for `Rack::Files`. ([#1811](https://github.com/rack/rack/pull/1720), [@ioquatix]). +- Moved `Rack::Session` into [separate gem](https://github.com/rack/rack-session). ([#1805](https://github.com/rack/rack/pull/1805), [@ioquatix]) +- `rackup -D` option to daemonizes no longer changes the working directory to the root. ([#1813](https://github.com/rack/rack/pull/1813), [@jeremyevans]) +- The `x-forwarded-proto` header is now considered before the `x-forwarded-scheme` header for determining the forwarded protocol. `Rack::Request.x_forwarded_proto_priority` accessor has been added for configuring the priority of which header to check. ([#1809](https://github.com/rack/rack/issues/1809), [@jeremyevans]) +- `Rack::Request.forwarded_authority` (and methods that call it, such as `host`) now returns the last authority in the forwarded header, instead of the first, as earlier forwarded authorities can be forged by clients. This restores the Rack 2.1 behavior. ([#1829](https://github.com/rack/rack/issues/1809), [@jeremyevans]) +- Use lower case cookie attributes when creating cookies, and fold cookie attributes to lower case when reading cookies (specifically impacting `secure` and `httponly` attributes). ([#1849](https://github.com/rack/rack/pull/1849), [@ioquatix]) +- The response array must now be mutable (non-frozen) so middleware can modify it without allocating a new Array,therefore reducing object allocations. ([#1887](https://github.com/rack/rack/pull/1887), [#1927](https://github.com/rack/rack/pull/1927), [@amatsuda], [@ioquatix]) +- `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. `rack.hijack_io` is no longer required/specified. ([#1939](https://github.com/rack/rack/pull/1939), [@ioquatix]) +- Allow calling close on `rack.input`. ([#1956](https://github.com/rack/rack/pull/1956), [@ioquatix]) + +### Fixed + +- Make Rack::MockResponse handle non-hash headers. ([#1629](https://github.com/rack/rack/issues/1629), [@jeremyevans]) +- TempfileReaper now deletes temp files if application raises an exception. ([#1679](https://github.com/rack/rack/issues/1679), [@jeremyevans]) +- Handle cookies with values that end in '=' ([#1645](https://github.com/rack/rack/pull/1645), [@lukaso](https://github.com/lukaso)) +- Make `Rack::NullLogger` respond to `#fatal!` [@jeremyevans]) +- Fix multipart filename generation for filenames that contain spaces. Encode spaces as "%20" instead of "+" which will be decoded properly by the multipart parser. ([#1736](https://github.com/rack/rack/pull/1645), [@muirdm](https://github.com/muirdm)) +- `Rack::Request#scheme` returns `ws` or `wss` when one of the `X-Forwarded-Scheme` / `X-Forwarded-Proto` headers is set to `ws` or `wss`, respectively. ([#1730](https://github.com/rack/rack/issues/1730), [@erwanst](https://github.com/erwanst)) + +## [2.2.4] - 2022-06-30 + +- Better support for lower case headers in `Rack::ETag` middleware. ([#1919](https://github.com/rack/rack/pull/1919), [@ioquatix](https://github.com/ioquatix)) +- Use custom exception on params too deep error. ([#1838](https://github.com/rack/rack/pull/1838), [@simi](https://github.com/simi)) + +## [2.2.3.1] - 2022-05-27 + +### Security + +- [CVE-2022-30123] Fix shell escaping issue in Common Logger +- [CVE-2022-30122] Restrict parsing of broken MIME attachments + +## [2.2.3] - 2020-06-15 + +### Security + +- [[CVE-2020-8184](https://nvd.nist.gov/vuln/detail/CVE-2020-8184)] Do not allow percent-encoded cookie name to override existing cookie names. BREAKING CHANGE: Accessing cookie names that require URL encoding with decoded name no longer works. ([@fletchto99](https://github.com/fletchto99)) + +## [2.2.2] - 2020-02-11 + +### Fixed + +- Fix incorrect `Rack::Request#host` value. ([#1591](https://github.com/rack/rack/pull/1591), [@ioquatix]) +- Revert `Rack::Handler::Thin` implementation. ([#1583](https://github.com/rack/rack/pull/1583), [@jeremyevans]) +- Double assignment is still needed to prevent an "unused variable" warning. ([#1589](https://github.com/rack/rack/pull/1589), [@kamipo](https://github.com/kamipo)) +- Fix to handle same_site option for session pool. ([#1587](https://github.com/rack/rack/pull/1587), [@kamipo](https://github.com/kamipo)) + +## [2.2.1] - 2020-02-09 + +### Fixed + +- Rework `Rack::Request#ip` to handle empty `forwarded_for`. ([#1577](https://github.com/rack/rack/pull/1577), [@ioquatix]) + +## [2.2.0] - 2020-02-08 + +### SPEC Changes + +- `rack.session` request environment entry must respond to `to_hash` and return unfrozen Hash. ([@jeremyevans]) +- Request environment cannot be frozen. ([@jeremyevans]) +- CGI values in the request environment with non-ASCII characters must use ASCII-8BIT encoding. ([@jeremyevans]) +- Improve SPEC/lint relating to SERVER_NAME, SERVER_PORT and HTTP_HOST. ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix]) + +### Added + +- `rackup` supports multiple `-r` options and will require all arguments. ([@jeremyevans]) +- `Server` supports an array of paths to require for the `:require` option. ([@khotta](https://github.com/khotta)) +- `Files` supports multipart range requests. ([@fatkodima](https://github.com/fatkodima)) +- `Multipart::UploadedFile` supports an IO-like object instead of using the filesystem, using `:filename` and `:io` options. ([@jeremyevans]) +- `Multipart::UploadedFile` supports keyword arguments `:path`, `:content_type`, and `:binary` in addition to positional arguments. ([@jeremyevans]) +- `Static` supports a `:cascade` option for calling the app if there is no matching file. ([@jeremyevans]) +- `Session::Abstract::SessionHash#dig`. ([@jeremyevans]) +- `Response.[]` and `MockResponse.[]` for creating instances using status, headers, and body. ([@ioquatix]) +- Convenient cache and content type methods for `Rack::Response`. ([#1555](https://github.com/rack/rack/pull/1555), [@ioquatix]) + +### Changed + +- `Request#params` no longer rescues EOFError. ([@jeremyevans]) +- `Directory` uses a streaming approach, significantly improving time to first byte for large directories. ([@jeremyevans]) +- `Directory` no longer includes a Parent directory link in the root directory index. ([@jeremyevans]) +- `QueryParser#parse_nested_query` uses original backtrace when reraising exception with new class. ([@jeremyevans]) +- `ConditionalGet` follows RFC 7232 precedence if both If-None-Match and If-Modified-Since headers are provided. ([@jeremyevans]) +- `.ru` files supports the `frozen-string-literal` magic comment. ([@eregon](https://github.com/eregon)) +- Rely on autoload to load constants instead of requiring internal files, make sure to require 'rack' and not just 'rack/...'. ([@jeremyevans]) +- BREAKING CHANGE: `Etag` will continue sending ETag even if the response should not be cached. Streaming no longer works without a workaround, see [#1619](https://github.com/rack/rack/issues/1619#issuecomment-848460528). ([@henm](https://github.com/henm)) +- `Request#host_with_port` no longer includes a colon for a missing or empty port. ([@AlexWayfer](https://github.com/AlexWayfer)) +- All handlers uses keywords arguments instead of an options hash argument. ([@ioquatix]) +- `Files` handling of range requests no longer return a body that supports `to_path`, to ensure range requests are handled correctly. ([@jeremyevans]) +- `Multipart::Generator` only includes `Content-Length` for files with paths, and `Content-Disposition` `filename` if the `UploadedFile` instance has one. ([@jeremyevans]) +- `Request#ssl?` is true for the `wss` scheme (secure websockets). ([@jeremyevans]) +- `Rack::HeaderHash` is memoized by default. ([#1549](https://github.com/rack/rack/pull/1549), [@ioquatix]) +- `Rack::Directory` allow directory traversal inside root directory. ([#1417](https://github.com/rack/rack/pull/1417), [@ThomasSevestre](https://github.com/ThomasSevestre)) +- Sort encodings by server preference. ([#1184](https://github.com/rack/rack/pull/1184), [@ioquatix], [@wjordan](https://github.com/wjordan)) +- Rework host/hostname/authority implementation in `Rack::Request`. `#host` and `#host_with_port` have been changed to correctly return IPv6 addresses formatted with square brackets, as defined by [RFC3986](https://tools.ietf.org/html/rfc3986#section-3.2.2). ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix]) +- `Rack::Builder` parsing options on first `#\` line is deprecated. ([#1574](https://github.com/rack/rack/pull/1574), [@ioquatix]) + +### Removed + +- `Directory#path` as it was not used and always returned nil. ([@jeremyevans]) +- `BodyProxy#each` as it was only needed to work around a bug in Ruby <1.9.3. ([@jeremyevans]) +- `URLMap::INFINITY` and `URLMap::NEGATIVE_INFINITY`, in favor of `Float::INFINITY`. ([@ch1c0t](https://github.com/ch1c0t)) +- Deprecation of `Rack::File`. It will be deprecated again in rack 2.2 or 3.0. ([@rafaelfranca](https://github.com/rafaelfranca)) +- Support for Ruby 2.2 as it is well past EOL. ([@ioquatix]) +- Remove `Rack::Files#response_body` as the implementation was broken. ([#1153](https://github.com/rack/rack/pull/1153), [@ioquatix]) +- Remove `SERVER_ADDR` which was never part of the original SPEC. ([#1573](https://github.com/rack/rack/pull/1573), [@ioquatix]) + +### Fixed + +- `Directory` correctly handles root paths containing glob metacharacters. ([@jeremyevans]) +- `Cascade` uses a new response object for each call if initialized with no apps. ([@jeremyevans]) +- `BodyProxy` correctly delegates keyword arguments to the body object on Ruby 2.7+. ([@jeremyevans]) +- `BodyProxy#method` correctly handles methods delegated to the body object. ([@jeremyevans]) +- `Request#host` and `Request#host_with_port` handle IPv6 addresses correctly. ([@AlexWayfer](https://github.com/AlexWayfer)) +- `Lint` checks when response hijacking that `rack.hijack` is called with a valid object. ([@jeremyevans]) +- `Response#write` correctly updates `Content-Length` if initialized with a body. ([@jeremyevans]) +- `CommonLogger` includes `SCRIPT_NAME` when logging. ([@Erol](https://github.com/Erol)) +- `Utils.parse_nested_query` correctly handles empty queries, using an empty instance of the params class instead of a hash. ([@jeremyevans]) +- `Directory` correctly escapes paths in links. ([@yous](https://github.com/yous)) +- `Request#delete_cookie` and related `Utils` methods handle `:domain` and `:path` options in same call. ([@jeremyevans]) +- `Request#delete_cookie` and related `Utils` methods do an exact match on `:domain` and `:path` options. ([@jeremyevans]) +- `Static` no longer adds headers when a gzipped file request has a 304 response. ([@chooh](https://github.com/chooh)) +- `ContentLength` sets `Content-Length` response header even for bodies not responding to `to_ary`. ([@jeremyevans]) +- Thin handler supports options passed directly to `Thin::Controllers::Controller`. ([@jeremyevans]) +- WEBrick handler no longer ignores `:BindAddress` option. ([@jeremyevans]) +- `ShowExceptions` handles invalid POST data. ([@jeremyevans]) +- Basic authentication requires a password, even if the password is empty. ([@jeremyevans]) +- `Lint` checks response is array with 3 elements, per SPEC. ([@jeremyevans]) +- Support for using `:SSLEnable` option when using WEBrick handler. (Gregor Melhorn) +- Close response body after buffering it when buffering. ([@ioquatix]) +- Only accept `;` as delimiter when parsing cookies. ([@mrageh](https://github.com/mrageh)) +- `Utils::HeaderHash#clear` clears the name mapping as well. ([@raxoft](https://github.com/raxoft)) +- Support for passing `nil` `Rack::Files.new`, which notably fixes Rails' current `ActiveStorage::FileServer` implementation. ([@ioquatix]) + +### Documentation + +- CHANGELOG updates. ([@aupajo](https://github.com/aupajo)) +- Added [CONTRIBUTING](CONTRIBUTING.md). ([@dblock](https://github.com/dblock)) + +## [2.0.9] - 2020-02-08 + +- Handle case where session id key is requested but missing ([@jeremyevans]) +- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans]) +- Add support for `SameSite=None` cookie value. ([@hennikul](https://github.com/hennikul)) + +## [2.1.2] - 2020-01-27 + +- Fix multipart parser for some files to prevent denial of service ([@aiomaster](https://github.com/aiomaster)) +- Fix `Rack::Builder#use` with keyword arguments ([@kamipo](https://github.com/kamipo)) +- Skip deflating in Rack::Deflater if Content-Length is 0 ([@jeremyevans]) +- Remove `SessionHash#transform_keys`, no longer needed ([@pavel](https://github.com/pavel)) +- Add to_hash to wrap Hash and Session classes ([@oleh-demyanyuk](https://github.com/oleh-demyanyuk)) +- Handle case where session id key is requested but missing ([@jeremyevans]) + +## [2.1.1] - 2020-01-12 + +- Remove `Rack::Chunked` from `Rack::Server` default middleware. ([#1475](https://github.com/rack/rack/pull/1475), [@ioquatix]) +- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans]) + +## [2.1.0] - 2020-01-10 + +### Added + +- Add support for `SameSite=None` cookie value. ([@hennikul](https://github.com/hennikul)) +- Add trailer headers. ([@eileencodes](https://github.com/eileencodes)) +- Add MIME Types for video streaming. ([@styd](https://github.com/styd)) +- Add MIME Type for WASM. ([@buildrtech](https://github.com/buildrtech)) +- Add `Early Hints(103)` to status codes. ([@egtra](https://github.com/egtra)) +- Add `Too Early(425)` to status codes. ([@y-yagi]((https://github.com/y-yagi))) +- Add `Bandwidth Limit Exceeded(509)` to status codes. ([@CJKinni](https://github.com/CJKinni)) +- Add method for custom `ip_filter`. ([@svcastaneda](https://github.com/svcastaneda)) +- Add boot-time profiling capabilities to `rackup`. ([@tenderlove](https://github.com/tenderlove)) +- Add multi mapping support for `X-Accel-Mappings` header. ([@yoshuki](https://github.com/yoshuki)) +- Add `sync: false` option to `Rack::Deflater`. (Eric Wong) +- Add `Builder#freeze_app` to freeze application and all middleware instances. ([@jeremyevans]) +- Add API to extract cookies from `Rack::MockResponse`. ([@petercline](https://github.com/petercline)) + +### Changed + +- Don't propagate nil values from middleware. ([@ioquatix]) +- Lazily initialize the response body and only buffer it if required. ([@ioquatix]) +- Fix deflater zlib buffer errors on empty body part. ([@felixbuenemann](https://github.com/felixbuenemann)) +- Set `X-Accel-Redirect` to percent-encoded path. ([@diskkid](https://github.com/diskkid)) +- Remove unnecessary buffer growing when parsing multipart. ([@tainoe](https://github.com/tainoe)) +- Expand the root path in `Rack::Static` upon initialization. ([@rosenfeld](https://github.com/rosenfeld)) +- Make `ShowExceptions` work with binary data. ([@axyjo](https://github.com/axyjo)) +- Use buffer string when parsing multipart requests. ([@janko-m](https://github.com/janko-m)) +- Support optional UTF-8 Byte Order Mark (BOM) in config.ru. ([@mikegee](https://github.com/mikegee)) +- Handle `X-Forwarded-For` with optional port. ([@dpritchett](https://github.com/dpritchett)) +- Use `Time#httpdate` format for Expires, as proposed by RFC 7231. ([@nanaya](https://github.com/nanaya)) +- Make `Utils.status_code` raise an error when the status symbol is invalid instead of `500`. ([@adambutler](https://github.com/adambutler)) +- Rename `Request::SCHEME_WHITELIST` to `Request::ALLOWED_SCHEMES`. +- Make `Multipart::Parser.get_filename` accept files with `+` in their name. ([@lucaskanashiro](https://github.com/lucaskanashiro)) +- Add Falcon to the default handler fallbacks. ([@ioquatix]) +- Update codebase to avoid string mutations in preparation for `frozen_string_literals`. ([@pat](https://github.com/pat)) +- Change `MockRequest#env_for` to rely on the input optionally responding to `#size` instead of `#length`. ([@janko](https://github.com/janko)) +- Rename `Rack::File` -> `Rack::Files` and add deprecation notice. ([@postmodern](https://github.com/postmodern)) +- Prefer Base64 “strict encoding” for Base64 cookies. ([@ioquatix]) + +### Removed + +- BREAKING CHANGE: Remove `to_ary` from Response ([@tenderlove](https://github.com/tenderlove)) +- Deprecate `Rack::Session::Memcache` in favor of `Rack::Session::Dalli` from dalli gem ([@fatkodima](https://github.com/fatkodima)) + +### Fixed + +- Eliminate warnings for Ruby 2.7. ([@osamtimizer](https://github.com/osamtimizer])) + +### Documentation + +- Update broken example in `Session::Abstract::ID` documentation. ([tonytonyjan](https://github.com/tonytonyjan)) +- Add Padrino to the list of frameworks implementing Rack. ([@wikimatze](https://github.com/wikimatze)) +- Remove Mongrel from the suggested server options in the help output. ([@tricknotes](https://github.com/tricknotes)) +- Replace `HISTORY.md` and `NEWS.md` with `CHANGELOG.md`. ([@twitnithegirl](https://github.com/twitnithegirl)) +- CHANGELOG updates. ([@drenmi](https://github.com/Drenmi), [@p8](https://github.com/p8)) + +## [2.0.8] - 2019-12-08 + +### Security + +- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) + +## [1.6.12] - 2019-12-08 + +### Security + +- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) + +## [2.0.7] - 2019-04-02 + +### Fixed + +- Remove calls to `#eof?` on Rack input in `Multipart::Parser`, as this breaks the specification. ([@matthewd](https://github.com/matthewd)) +- Preserve forwarded IP addresses for trusted proxy chains. ([@SamSaffron](https://github.com/SamSaffron)) + +## [2.0.6] - 2018-11-05 + +### Fixed + +- [[CVE-2018-16470](https://nvd.nist.gov/vuln/detail/CVE-2018-16470)] Reduce buffer size of `Multipart::Parser` to avoid pathological parsing. ([@tenderlove](https://github.com/tenderlove)) +- Fix a call to a non-existing method `#accepts_html` in the `ShowExceptions` middleware. ([@tomelm](https://github.com/tomelm)) +- [[CVE-2018-16471](https://nvd.nist.gov/vuln/detail/CVE-2018-16471)] Whitelist HTTP and HTTPS schemes in `Request#scheme` to prevent a possible XSS attack. ([@PatrickTulskie](https://github.com/PatrickTulskie)) + +## [2.0.5] - 2018-04-23 + +### Fixed + +- Record errors originating from invalid UTF8 in `MethodOverride` middleware instead of breaking. ([@mclark](https://github.com/mclark)) + +## [2.0.4] - 2018-01-31 + +### Changed + +- Ensure the `Lock` middleware passes the original `env` object. ([@lugray](https://github.com/lugray)) +- Improve performance of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) +- Increase buffer size in `Multipart::Parser` for better performance. ([@jkowens](https://github.com/jkowens)) +- Reduce memory usage of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) +- Replace ConcurrentRuby dependency with native `Queue`. ([@devmchakan](https://github.com/devmchakan)) + +### Fixed + +- Require the correct digest algorithm in the `ETag` middleware. ([@matthewd](https://github.com/matthewd)) + +### Documentation + +- Update homepage links to use SSL. ([@hugoabonizio](https://github.com/hugoabonizio)) + +## [2.0.3] - 2017-05-15 + +### Changed + +- Ensure `env` values are ASCII 8-bit encoded. ([@eileencodes](https://github.com/eileencodes)) + +### Fixed + +- Prevent exceptions when a class with mixins inherits from `Session::Abstract::ID`. ([@jnraine](https://github.com/jnraine)) + +## [2.0.2] - 2017-05-08 + +### Added + +- Allow `Session::Abstract::SessionHash#fetch` to accept a block with a default value. ([@yannvanhalewyn](https://github.com/yannvanhalewyn)) +- Add `Builder#freeze_app` to freeze application and all middleware. ([@jeremyevans]) + +### Changed + +- Freeze default session options to avoid accidental mutation. ([@kirs](https://github.com/kirs)) +- Detect partial hijack without hash headers. ([@devmchakan](https://github.com/devmchakan)) +- Update tests to use MiniTest 6 matchers. ([@tonytonyjan](https://github.com/tonytonyjan)) +- Allow 205 Reset Content responses to set a Content-Length, as RFC 7231 proposes setting this to 0. ([@devmchakan](https://github.com/devmchakan)) + +### Fixed + +- Handle `NULL` bytes in multipart filenames. ([@casperisfine](https://github.com/casperisfine)) +- Remove warnings due to miscapitalized global. ([@ioquatix]) +- Prevent exceptions caused by a race condition on multi-threaded servers. ([@sophiedeziel](https://github.com/sophiedeziel)) +- Add RDoc as an explicit dependency for `doc` group. ([@tonytonyjan](https://github.com/tonytonyjan)) +- Record errors originating from `Multipart::Parser` in the `MethodOverride` middleware instead of letting them bubble up. ([@carlzulauf](https://github.com/carlzulauf)) +- Remove remaining use of removed `Utils#bytesize` method from the `File` middleware. ([@brauliomartinezlm](https://github.com/brauliomartinezlm)) + +### Removed + +- Remove `deflate` encoding support to reduce caching overhead. ([@devmchakan](https://github.com/devmchakan)) + +### Documentation + +- Update broken example in `Deflater` documentation. ([@mwpastore](https://github.com/mwpastore)) + +## [2.0.1] - 2016-06-30 + +### Changed + +- Remove JSON as an explicit dependency. ([@mperham](https://github.com/mperham)) + + +# History/News Archive +Items below this line are from the previously maintained HISTORY.md and NEWS.md files. + +## [2.0.0.rc1] 2016-05-06 +- Rack::Session::Abstract::ID is deprecated. Please change to use Rack::Session::Abstract::Persisted + +## [2.0.0.alpha] 2015-12-04 +- First-party "SameSite" cookies. Browsers omit SameSite cookies from third-party requests, closing the door on many CSRF attacks. +- Pass `same_site: true` (or `:strict`) to enable: response.set_cookie 'foo', value: 'bar', same_site: true or `same_site: :lax` to use Lax enforcement: response.set_cookie 'foo', value: 'bar', same_site: :lax +- Based on version 7 of the Same-site Cookies internet draft: + https://tools.ietf.org/html/draft-west-first-party-cookies-07 +- Thanks to Ben Toews (@mastahyeti) and Bob Long (@bobjflong) for updating to drafts 5 and 7. +- Add `Rack::Events` middleware for adding event based middleware: middleware that does not care about the response body, but only cares about doing work at particular points in the request / response lifecycle. +- Add `Rack::Request#authority` to calculate the authority under which the response is being made (this will be handy for h2 pushes). +- Add `Rack::Response::Helpers#cache_control` and `cache_control=`. Use this for setting cache control headers on your response objects. +- Add `Rack::Response::Helpers#etag` and `etag=`. Use this for setting etag values on the response. +- Introduce `Rack::Response::Helpers#add_header` to add a value to a multi-valued response header. Implemented in terms of other `Response#*_header` methods, so it's available to any response-like class that includes the `Helpers` module. +- Add `Rack::Request#add_header` to match. +- `Rack::Session::Abstract::ID` IS DEPRECATED. Please switch to `Rack::Session::Abstract::Persisted`. `Rack::Session::Abstract::Persisted` uses a request object rather than the `env` hash. +- Pull `ENV` access inside the request object in to a module. This will help with legacy Request objects that are ENV based but don't want to inherit from Rack::Request +- Move most methods on the `Rack::Request` to a module `Rack::Request::Helpers` and use public API to get values from the request object. This enables users to mix `Rack::Request::Helpers` in to their own objects so they can implement `(get|set|fetch|each)_header` as they see fit (for example a proxy object). +- Files and directories with + in the name are served correctly. Rather than unescaping paths like a form, we unescape with a URI parser using `Rack::Utils.unescape_path`. Fixes #265 +- Tempfiles are automatically closed in the case that there were too + many posted. +- Added methods for manipulating response headers that don't assume + they're stored as a Hash. Response-like classes may include the + Rack::Response::Helpers module if they define these methods: + - Rack::Response#has_header? + - Rack::Response#get_header + - Rack::Response#set_header + - Rack::Response#delete_header +- Introduce Util.get_byte_ranges that will parse the value of the HTTP_RANGE string passed to it without depending on the `env` hash. `byte_ranges` is deprecated in favor of this method. +- Change Session internals to use Request objects for looking up session information. This allows us to only allocate one request object when dealing with session objects (rather than doing it every time we need to manipulate cookies, etc). +- Add `Rack::Request#initialize_copy` so that the env is duped when the request gets duped. +- Added methods for manipulating request specific data. This includes + data set as CGI parameters, and just any arbitrary data the user wants + to associate with a particular request. New methods: + - Rack::Request#has_header? + - Rack::Request#get_header + - Rack::Request#fetch_header + - Rack::Request#each_header + - Rack::Request#set_header + - Rack::Request#delete_header +- lib/rack/utils.rb: add a method for constructing "delete" cookie + headers. This allows us to construct cookie headers without depending + on the side effects of mutating a hash. +- Prevent extremely deep parameters from being parsed. CVE-2015-3225 + +## [1.6.1] 2015-05-06 + - Fix CVE-2014-9490, denial of service attack in OkJson + - Use a monotonic time for Rack::Runtime, if available + - RACK_MULTIPART_LIMIT changed to RACK_MULTIPART_PART_LIMIT (RACK_MULTIPART_LIMIT is deprecated and will be removed in 1.7.0) + +## [1.5.3] 2015-05-06 + - Fix CVE-2014-9490, denial of service attack in OkJson + - Backport bug fixes to 1.5 series + +## [1.6.0] 2014-01-18 + - Response#unauthorized? helper + - Deflater now accepts an options hash to control compression on a per-request level + - Builder#warmup method for app preloading + - Request#accept_language method to extract HTTP_ACCEPT_LANGUAGE + - Add quiet mode of rack server, rackup --quiet + - Update HTTP Status Codes to RFC 7231 + - Less strict header name validation according to RFC 2616 + - SPEC updated to specify headers conform to RFC7230 specification + - Etag correctly marks etags as weak + - Request#port supports multiple x-http-forwarded-proto values + - Utils#multipart_part_limit configures the maximum number of parts a request can contain + - Default host to localhost when in development mode + - Various bugfixes and performance improvements + +## [1.5.2] 2013-02-07 + - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie + - Fix CVE-2013-0262, symlink path traversal in Rack::File + - Add various methods to Session for enhanced Rails compatibility + - Request#trusted_proxy? now only matches whole strings + - Add JSON cookie coder, to be default in Rack 1.6+ due to security concerns + - URLMap host matching in environments that don't set the Host header fixed + - Fix a race condition that could result in overwritten pidfiles + - Various documentation additions + +## [1.4.5] 2013-02-07 + - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie + - Fix CVE-2013-0262, symlink path traversal in Rack::File + +## [1.1.6, 1.2.8, 1.3.10] 2013-02-07 + - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie + +## [1.5.1] 2013-01-28 + - Rack::Lint check_hijack now conforms to other parts of SPEC + - Added hash-like methods to Abstract::ID::SessionHash for compatibility + - Various documentation corrections + +## [1.5.0] 2013-01-21 + - Introduced hijack SPEC, for before-response and after-response hijacking + - SessionHash is no longer a Hash subclass + - Rack::File cache_control parameter is removed, in place of headers options + - Rack::Auth::AbstractRequest#scheme now yields strings, not symbols + - Rack::Utils cookie functions now format expires in RFC 2822 format + - Rack::File now has a default mime type + - rackup -b 'run Rack::Files.new(".")', option provides command line configs + - Rack::Deflater will no longer double encode bodies + - Rack::Mime#match? provides convenience for Accept header matching + - Rack::Utils#q_values provides splitting for Accept headers + - Rack::Utils#best_q_match provides a helper for Accept headers + - Rack::Handler.pick provides convenience for finding available servers + - Puma added to the list of default servers (preferred over Webrick) + - Various middleware now correctly close body when replacing it + - Rack::Request#params is no longer persistent with only GET params + - Rack::Request#update_param and #delete_param provide persistent operations + - Rack::Request#trusted_proxy? now returns true for local unix sockets + - Rack::Response no longer forces Content-Types + - Rack::Sendfile provides local mapping configuration options + - Rack::Utils#rfc2109 provides old netscape style time output + - Updated HTTP status codes + - Ruby 1.8.6 likely no longer passes tests, and is no longer fully supported + +## [1.4.4, 1.3.9, 1.2.7, 1.1.5] 2013-01-13 + - [SEC] Rack::Auth::AbstractRequest no longer symbolizes arbitrary strings + - Fixed erroneous test case in the 1.3.x series + +## [1.4.3] 2013-01-07 + - Security: Prevent unbounded reads in large multipart boundaries + +## [1.3.8] 2013-01-07 + - Security: Prevent unbounded reads in large multipart boundaries + +## [1.4.2] 2013-01-06 + - Add warnings when users do not provide a session secret + - Fix parsing performance for unquoted filenames + - Updated URI backports + - Fix URI backport version matching, and silence constant warnings + - Correct parameter parsing with empty values + - Correct rackup '-I' flag, to allow multiple uses + - Correct rackup pidfile handling + - Report rackup line numbers correctly + - Fix request loops caused by non-stale nonces with time limits + - Fix reloader on Windows + - Prevent infinite recursions from Response#to_ary + - Various middleware better conforms to the body close specification + - Updated language for the body close specification + - Additional notes regarding ECMA escape compatibility issues + - Fix the parsing of multiple ranges in range headers + - Prevent errors from empty parameter keys + - Added PATCH verb to Rack::Request + - Various documentation updates + - Fix session merge semantics (fixes rack-test) + - Rack::Static :index can now handle multiple directories + - All tests now utilize Rack::Lint (special thanks to Lars Gierth) + - Rack::File cache_control parameter is now deprecated, and removed by 1.5 + - Correct Rack::Directory script name escaping + - Rack::Static supports header rules for sophisticated configurations + - Multipart parsing now works without a Content-Length header + - New logos courtesy of Zachary Scott! + - Rack::BodyProxy now explicitly defines #each, useful for C extensions + - Cookies that are not URI escaped no longer cause exceptions + +## [1.3.7] 2013-01-06 + - Add warnings when users do not provide a session secret + - Fix parsing performance for unquoted filenames + - Updated URI backports + - Fix URI backport version matching, and silence constant warnings + - Correct parameter parsing with empty values + - Correct rackup '-I' flag, to allow multiple uses + - Correct rackup pidfile handling + - Report rackup line numbers correctly + - Fix request loops caused by non-stale nonces with time limits + - Fix reloader on Windows + - Prevent infinite recursions from Response#to_ary + - Various middleware better conforms to the body close specification + - Updated language for the body close specification + - Additional notes regarding ECMA escape compatibility issues + - Fix the parsing of multiple ranges in range headers + +## [1.2.6] 2013-01-06 + - Add warnings when users do not provide a session secret + - Fix parsing performance for unquoted filenames + +## [1.1.4] 2013-01-06 + - Add warnings when users do not provide a session secret + +## [1.4.1] 2012-01-22 + - Alter the keyspace limit calculations to reduce issues with nested params + - Add a workaround for multipart parsing where files contain unescaped "%" + - Added Rack::Response::Helpers#method_not_allowed? (code 405) + - Rack::File now returns 404 for illegal directory traversals + - Rack::File now returns 405 for illegal methods (non HEAD/GET) + - Rack::Cascade now catches 405 by default, as well as 404 + - Cookies missing '--' no longer cause an exception to be raised + - Various style changes and documentation spelling errors + - Rack::BodyProxy always ensures to execute its block + - Additional test coverage around cookies and secrets + - Rack::Session::Cookie can now be supplied either secret or old_secret + - Tests are no longer dependent on set order + - Rack::Static no longer defaults to serving index files + - Rack.release was fixed + +## [1.4.0] 2011-12-28 + - Ruby 1.8.6 support has officially been dropped. Not all tests pass. + - Raise sane error messages for broken config.ru + - Allow combining run and map in a config.ru + - Rack::ContentType will not set Content-Type for responses without a body + - Status code 205 does not send a response body + - Rack::Response::Helpers will not rely on instance variables + - Rack::Utils.build_query no longer outputs '=' for nil query values + - Various mime types added + - Rack::MockRequest now supports HEAD + - Rack::Directory now supports files that contain RFC3986 reserved chars + - Rack::File now only supports GET and HEAD requests + - Rack::Server#start now passes the block to Rack::Handler::#run + - Rack::Static now supports an index option + - Added the Teapot status code + - rackup now defaults to Thin instead of Mongrel (if installed) + - Support added for HTTP_X_FORWARDED_SCHEME + - Numerous bug fixes, including many fixes for new and alternate rubies + +## [1.1.3] 2011-12-28 + - Security fix. http://www.ocert.org/advisories/ocert-2011-003.html + Further information here: http://jruby.org/2011/12/27/jruby-1-6-5-1 + +## [1.3.5] 2011-10-17 + - Fix annoying warnings caused by the backport in 1.3.4 + +## [1.3.4] 2011-10-01 + - Backport security fix from 1.9.3, also fixes some roundtrip issues in URI + - Small documentation update + - Fix an issue where BodyProxy could cause an infinite recursion + - Add some supporting files for travis-ci + +## [1.2.4] 2011-09-16 + - Fix a bug with MRI regex engine to prevent XSS by malformed unicode + +## [1.3.3] 2011-09-16 + - Fix bug with broken query parameters in Rack::ShowExceptions + - Rack::Request#cookies no longer swallows exceptions on broken input + - Prevents XSS attacks enabled by bug in Ruby 1.8's regexp engine + - Rack::ConditionalGet handles broken If-Modified-Since helpers + +## [1.3.2] 2011-07-16 + - Fix for Rails and rack-test, Rack::Utils#escape calls to_s + +## [1.3.1] 2011-07-13 + - Fix 1.9.1 support + - Fix JRuby support + - Properly handle $KCODE in Rack::Utils.escape + - Make method_missing/respond_to behavior consistent for Rack::Lock, + Rack::Auth::Digest::Request and Rack::Multipart::UploadedFile + - Reenable passing rack.session to session middleware + - Rack::CommonLogger handles streaming responses correctly + - Rack::MockResponse calls close on the body object + - Fix a DOS vector from MRI stdlib backport + +## [1.2.3] 2011-05-22 + - Pulled in relevant bug fixes from 1.3 + - Fixed 1.8.6 support + +## [1.3.0] 2011-05-22 + - Various performance optimizations + - Various multipart fixes + - Various multipart refactors + - Infinite loop fix for multipart + - Test coverage for Rack::Server returns + - Allow files with '..', but not path components that are '..' + - rackup accepts handler-specific options on the command line + - Request#params no longer merges POST into GET (but returns the same) + - Use URI.encode_www_form_component instead. Use core methods for escaping. + - Allow multi-line comments in the config file + - Bug L#94 reported by Nikolai Lugovoi, query parameter unescaping. + - Rack::Response now deletes Content-Length when appropriate + - Rack::Deflater now supports streaming + - Improved Rack::Handler loading and searching + - Support for the PATCH verb + - env['rack.session.options'] now contains session options + - Cookies respect renew + - Session middleware uses SecureRandom.hex + +## [1.2.2, 1.1.2] 2011-03-13 + - Security fix in Rack::Auth::Digest::MD5: when authenticator + returned nil, permission was granted on empty password. + +## [1.2.1] 2010-06-15 + - Make CGI handler rewindable + - Rename spec/ to test/ to not conflict with SPEC on lesser + operating systems + +## [1.2.0] 2010-06-13 + - Removed Camping adapter: Camping 2.0 supports Rack as-is + - Removed parsing of quoted values + - Add Request.trace? and Request.options? + - Add mime-type for .webm and .htc + - Fix HTTP_X_FORWARDED_FOR + - Various multipart fixes + - Switch test suite to bacon + +## [1.1.0] 2010-01-03 + - Moved Auth::OpenID to rack-contrib. + - SPEC change that relaxes Lint slightly to allow subclasses of the + required types + - SPEC change to document rack.input binary mode in greater detail + - SPEC define optional rack.logger specification + - File servers support X-Cascade header + - Imported Config middleware + - Imported ETag middleware + - Imported Runtime middleware + - Imported Sendfile middleware + - New Logger and NullLogger middlewares + - Added mime type for .ogv and .manifest. + - Don't squeeze PATH_INFO slashes + - Use Content-Type to determine POST params parsing + - Update Rack::Utils::HTTP_STATUS_CODES hash + - Add status code lookup utility + - Response should call #to_i on the status + - Add Request#user_agent + - Request#host knows about forwarded host + - Return an empty string for Request#host if HTTP_HOST and + SERVER_NAME are both missing + - Allow MockRequest to accept hash params + - Optimizations to HeaderHash + - Refactored rackup into Rack::Server + - Added Utils.build_nested_query to complement Utils.parse_nested_query + - Added Utils::Multipart.build_multipart to complement + Utils::Multipart.parse_multipart + - Extracted set and delete cookie helpers into Utils so they can be + used outside Response + - Extract parse_query and parse_multipart in Request so subclasses + can change their behavior + - Enforce binary encoding in RewindableInput + - Set correct external_encoding for handlers that don't use RewindableInput + +## [1.0.1] 2009-10-18 + - Bump remainder of rack.versions. + - Support the pure Ruby FCGI implementation. + - Fix for form names containing "=": split first then unescape components + - Fixes the handling of the filename parameter with semicolons in names. + - Add anchor to nested params parsing regexp to prevent stack overflows + - Use more compatible gzip write api instead of "<<". + - Make sure that Reloader doesn't break when executed via ruby -e + - Make sure WEBrick respects the :Host option + - Many Ruby 1.9 fixes. + +## [1.0.0] 2009-04-25 + - SPEC change: Rack::VERSION has been pushed to [1,0]. + - SPEC change: header values must be Strings now, split on "\n". + - SPEC change: Content-Length can be missing, in this case chunked transfer + encoding is used. + - SPEC change: rack.input must be rewindable and support reading into + a buffer, wrap with Rack::RewindableInput if it isn't. + - SPEC change: rack.session is now specified. + - SPEC change: Bodies can now additionally respond to #to_path with + a filename to be served. + - NOTE: String bodies break in 1.9, use an Array consisting of a + single String instead. + - New middleware Rack::Lock. + - New middleware Rack::ContentType. + - Rack::Reloader has been rewritten. + - Major update to Rack::Auth::OpenID. + - Support for nested parameter parsing in Rack::Response. + - Support for redirects in Rack::Response. + - HttpOnly cookie support in Rack::Response. + - The Rakefile has been rewritten. + - Many bugfixes and small improvements. + +## [0.9.1] 2009-01-09 + - Fix directory traversal exploits in Rack::File and Rack::Directory. + +## [0.9] 2009-01-06 + - Rack is now managed by the Rack Core Team. + - Rack::Lint is stricter and follows the HTTP RFCs more closely. + - Added ConditionalGet middleware. + - Added ContentLength middleware. + - Added Deflater middleware. + - Added Head middleware. + - Added MethodOverride middleware. + - Rack::Mime now provides popular MIME-types and their extension. + - Mongrel Header now streams. + - Added Thin handler. + - Official support for swiftiplied Mongrel. + - Secure cookies. + - Made HeaderHash case-preserving. + - Many bugfixes and small improvements. + +## [0.4] 2008-08-21 + - New middleware, Rack::Deflater, by Christoffer Sawicki. + - OpenID authentication now needs ruby-openid 2. + - New Memcache sessions, by blink. + - Explicit EventedMongrel handler, by Joshua Peek + - Rack::Reloader is not loaded in rackup development mode. + - rackup can daemonize with -D. + - Many bugfixes, especially for pool sessions, URLMap, thread safety + and tempfile handling. + - Improved tests. + - Rack moved to Git. + +## [0.3] 2008-02-26 + - LiteSpeed handler, by Adrian Madrid. + - SCGI handler, by Jeremy Evans. + - Pool sessions, by blink. + - OpenID authentication, by blink. + - :Port and :File options for opening FastCGI sockets, by blink. + - Last-Modified HTTP header for Rack::File, by blink. + - Rack::Builder#use now accepts blocks, by Corey Jewett. + (See example/protectedlobster.ru) + - HTTP status 201 can contain a Content-Type and a body now. + - Many bugfixes, especially related to Cookie handling. + +## [0.2] 2007-05-16 + - HTTP Basic authentication. + - Cookie Sessions. + - Static file handler. + - Improved Rack::Request. + - Improved Rack::Response. + - Added Rack::ShowStatus, for better default error messages. + - Bug fixes in the Camping adapter. + - Removed Rails adapter, was too alpha. + +## [0.1] 2007-03-03 + +[@ioquatix]: https://github.com/ioquatix "Samuel Williams" +[@jeremyevans]: https://github.com/jeremyevans "Jeremy Evans" +[@amatsuda]: https://github.com/amatsuda "Akira Matsuda" +[@wjordan]: https://github.com/wjordan "Will Jordan" +[@BlakeWilliams]: https://github.com/BlakeWilliams "Blake Williams" +[@davidstosik]: https://github.com/davidstosik "David Stosik" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CONTRIBUTING.md b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CONTRIBUTING.md new file mode 100644 index 0000000..a95263d --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CONTRIBUTING.md @@ -0,0 +1,144 @@ +# Contributing to Rack + +Rack is work of [hundreds of +contributors](https://github.com/rack/rack/graphs/contributors). You're +encouraged to submit [pull requests](https://github.com/rack/rack/pulls) and +[propose features and discuss issues](https://github.com/rack/rack/issues). + +## Backports + +Only security patches are ideal for backporting to non-main release versions. If +you're not sure if your bug fix is backportable, you should open a discussion to +discuss it first. + +The [Security Policy] documents which release versions will receive security +backports. + +## Fork the Project + +Fork the [project on GitHub](https://github.com/rack/rack) and check out your +copy. + +``` +git clone https://github.com/(your-github-username)/rack.git +cd rack +git remote add upstream https://github.com/rack/rack.git +``` + +## Create a Topic Branch + +Make sure your fork is up-to-date and create a topic branch for your feature or +bug fix. + +``` +git checkout main +git pull upstream main +git checkout -b my-feature-branch +``` + +## Running All Tests + +Install all dependencies. + +``` +bundle install +``` + +Run all tests. + +``` +rake test +``` + +## Write Tests + +Try to write a test that reproduces the problem you're trying to fix or +describes a feature that you want to build. + +We definitely appreciate pull requests that highlight or reproduce a problem, +even without a fix. + +## Write Code + +Implement your feature or bug fix. + +Make sure that all tests pass: + +``` +bundle exec rake test +``` + +## Write Documentation + +Document any external behavior in the [README](README.md). + +## Update Changelog + +Add a line to [CHANGELOG](CHANGELOG.md). + +## Commit Changes + +Make sure git knows your name and email address: + +``` +git config --global user.name "Your Name" +git config --global user.email "contributor@example.com" +``` + +Writing good commit logs is important. A commit log should describe what changed +and why. + +``` +git add ... +git commit +``` + +## Push + +``` +git push origin my-feature-branch +``` + +## Make a Pull Request + +Go to your fork of rack on GitHub and select your feature branch. Click the +'Pull Request' button and fill out the form. Pull requests are usually +reviewed within a few days. + +## Rebase + +If you've been working on a change for a while, rebase with upstream/main. + +``` +git fetch upstream +git rebase upstream/main +git push origin my-feature-branch -f +``` + +## Make Required Changes + +Amend your previous commit and force push the changes. + +``` +git commit --amend +git push origin my-feature-branch -f +``` + +## Check on Your Pull Request + +Go back to your pull request after a few minutes and see whether it passed +tests with GitHub Actions. Everything should look green, otherwise fix issues and +amend your commit as described above. + +## Be Patient + +It's likely that your change will not be merged and that the nitpicky +maintainers will ask you to do more, or fix seemingly benign problems. Hang in +there! + +## Thank You + +Please do know that we really appreciate and value your time and work. We love +you, really. + +[Security Policy]: SECURITY.md diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/MIT-LICENSE b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/MIT-LICENSE new file mode 100644 index 0000000..fb33b7f --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/MIT-LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (C) 2007-2021 Leah Neukirchen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/README.md b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/README.md new file mode 100644 index 0000000..3a197b1 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/README.md @@ -0,0 +1,328 @@ +# ![Rack](contrib/logo.webp) + +Rack provides a minimal, modular, and adaptable interface for developing web +applications in Ruby. By wrapping HTTP requests and responses in the simplest +way possible, it unifies and distills the bridge between web servers, web +frameworks, and web application into a single method call. + +The exact details of this are described in the [Rack Specification], which all +Rack applications should conform to. + +## Version support + +| Version | Support | +|----------|------------------------------------| +| 3.0.x | Bug fixes and security patches. | +| 2.2.x | Security patches only. | +| <= 2.1.x | End of support. | + +Please see the [Security Policy] for more information. + +## Rack 3.0 + +This is the latest version of Rack. It contains API improvements but also some +breaking changes. Please check the [Upgrade Guide](UPGRADE-GUIDE.md) for more +details about migrating servers, middlewares and applications designed for Rack 2 +to Rack 3. For detailed information on specific changes, check the [Change Log](CHANGELOG.md). + +## Rack 2.2 + +This version of Rack is receiving security patches only, and effort should be +made to move to Rack 3. + +Starting in Ruby 3.4 the `base64` dependency will no longer be a default gem, +and may cause a warning or error about `base64` being missing. To correct this, +add `base64` as a dependency to your project. + +## Installation + +Add the rack gem to your application bundle, or follow the instructions provided +by a [supported web framework](#supported-web-frameworks): + +```bash +# Install it generally: +$ gem install rack + +# or, add it to your current application gemfile: +$ bundle add rack +``` + +If you need features from `Rack::Session` or `bin/rackup` please add those gems separately. + +```bash +$ gem install rack-session rackup +``` + +## Usage + +Create a file called `config.ru` with the following contents: + +```ruby +run do |env| + [200, {}, ["Hello World"]] +end +``` + +Run this using the rackup gem or another [supported web +server](#supported-web-servers). + +```bash +$ gem install rackup +$ rackup +$ curl http://localhost:9292 +Hello World +``` + +## Supported web servers + +Rack is supported by a wide range of servers, including: + +* [Agoo](https://github.com/ohler55/agoo) +* [Falcon](https://github.com/socketry/falcon) +* [Iodine](https://github.com/boazsegev/iodine) +* [NGINX Unit](https://unit.nginx.org/) +* [Phusion Passenger](https://www.phusionpassenger.com/) (which is mod_rack for + Apache and for nginx) +* [Puma](https://puma.io/) +* [Thin](https://github.com/macournoyer/thin) +* [Unicorn](https://yhbt.net/unicorn/) +* [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) +* [Lamby](https://lamby.custominktech.com) (for AWS Lambda) + +You will need to consult the server documentation to find out what features and +limitations they may have. In general, any valid Rack app will run the same on +all these servers, without changing anything. + +### Rackup + +Rack provides a separate gem, [rackup](https://github.com/rack/rackup) which is +a generic interface for running a Rack application on supported servers, which +include `WEBRick`, `Puma`, `Falcon` and others. + +## Supported web frameworks + +These frameworks and many others support the [Rack Specification]: + +* [Camping](https://github.com/camping/camping) +* [Hanami](https://hanamirb.org/) +* [Ramaze](https://github.com/ramaze/ramaze) +* [Padrino](https://padrinorb.com/) +* [Roda](https://github.com/jeremyevans/roda) +* [Ruby on Rails](https://rubyonrails.org/) +* [Rum](https://github.com/leahneukirchen/rum) +* [Sinatra](https://sinatrarb.com/) +* [Utopia](https://github.com/socketry/utopia) +* [WABuR](https://github.com/ohler55/wabur) + +## Available middleware shipped with Rack + +Between the server and the framework, Rack can be customized to your +applications needs using middleware. Rack itself ships with the following +middleware: + +* `Rack::CommonLogger` for creating Apache-style logfiles. +* `Rack::ConditionalGet` for returning [Not + Modified](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) + responses when the response has not changed. +* `Rack::Config` for modifying the environment before processing the request. +* `Rack::ContentLength` for setting a `content-length` header based on body + size. +* `Rack::ContentType` for setting a default `content-type` header for responses. +* `Rack::Deflater` for compressing responses with gzip. +* `Rack::ETag` for setting `etag` header on bodies that can be buffered. +* `Rack::Events` for providing easy hooks when a request is received and when + the response is sent. +* `Rack::Files` for serving static files. +* `Rack::Head` for returning an empty body for HEAD requests. +* `Rack::Lint` for checking conformance to the [Rack Specification]. +* `Rack::Lock` for serializing requests using a mutex. +* `Rack::Logger` for setting a logger to handle logging errors. +* `Rack::MethodOverride` for modifying the request method based on a submitted + parameter. +* `Rack::Recursive` for including data from other paths in the application, and + for performing internal redirects. +* `Rack::Reloader` for reloading files if they have been modified. +* `Rack::Runtime` for including a response header with the time taken to process + the request. +* `Rack::Sendfile` for working with web servers that can use optimized file + serving for file system paths. +* `Rack::ShowException` for catching unhandled exceptions and presenting them in + a nice and helpful way with clickable backtrace. +* `Rack::ShowStatus` for using nice error pages for empty client error + responses. +* `Rack::Static` for more configurable serving of static files. +* `Rack::TempfileReaper` for removing temporary files creating during a request. + +All these components use the same interface, which is described in detail in the +[Rack Specification]. These optional components can be used in any way you wish. + +### Convenience interfaces + +If you want to develop outside of existing frameworks, implement your own ones, +or develop middleware, Rack provides many helpers to create Rack applications +quickly and without doing the same web stuff all over: + +* `Rack::Request` which also provides query string parsing and multipart + handling. +* `Rack::Response` for convenient generation of HTTP replies and cookie + handling. +* `Rack::MockRequest` and `Rack::MockResponse` for efficient and quick testing + of Rack application without real HTTP round-trips. +* `Rack::Cascade` for trying additional Rack applications if an application + returns a not found or method not supported response. +* `Rack::Directory` for serving files under a given directory, with directory + indexes. +* `Rack::MediaType` for parsing content-type headers. +* `Rack::Mime` for determining content-type based on file extension. +* `Rack::RewindableInput` for making any IO object rewindable, using a temporary + file buffer. +* `Rack::URLMap` to route to multiple applications inside the same process. + +## Configuration + +Rack exposes several configuration parameters to control various features of the +implementation. + +### `param_depth_limit` + +```ruby +Rack::Utils.param_depth_limit = 32 # default +``` + +The maximum amount of nesting allowed in parameters. For example, if set to 3, +this query string would be allowed: + +``` +?a[b][c]=d +``` + +but this query string would not be allowed: + +``` +?a[b][c][d]=e +``` + +Limiting the depth prevents a possible stack overflow when parsing parameters. + +### `multipart_file_limit` + +```ruby +Rack::Utils.multipart_file_limit = 128 # default +``` + +The maximum number of parts with a filename a request can contain. Accepting +too many parts can lead to the server running out of file handles. + +The default is 128, which means that a single request can't upload more than 128 +files at once. Set to 0 for no limit. + +Can also be set via the `RACK_MULTIPART_FILE_LIMIT` environment variable. + +(This is also aliased as `multipart_part_limit` and `RACK_MULTIPART_PART_LIMIT` for compatibility) + + +### `multipart_total_part_limit` + +The maximum total number of parts a request can contain of any type, including +both file and non-file form fields. + +The default is 4096, which means that a single request can't contain more than +4096 parts. + +Set to 0 for no limit. + +Can also be set via the `RACK_MULTIPART_TOTAL_PART_LIMIT` environment variable. + + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for specific details about how to make a +contribution to Rack. + +Please post bugs, suggestions and patches to [GitHub +Issues](https://github.com/rack/rack/issues). + +Please check our [Security Policy](https://github.com/rack/rack/security/policy) +for responsible disclosure and security bug reporting process. Due to wide usage +of the library, it is strongly preferred that we manage timing in order to +provide viable patches at the time of disclosure. Your assistance in this matter +is greatly appreciated. + +## See Also + +### `rack-contrib` + +The plethora of useful middleware created the need for a project that collects +fresh Rack middleware. `rack-contrib` includes a variety of add-on components +for Rack and it is easy to contribute new modules. + +* https://github.com/rack/rack-contrib + +### `rack-session` + +Provides convenient session management for Rack. + +* https://github.com/rack/rack-session + +## Thanks + +The Rack Core Team, consisting of + +* Aaron Patterson [tenderlove](https://github.com/tenderlove) +* Samuel Williams [ioquatix](https://github.com/ioquatix) +* Jeremy Evans [jeremyevans](https://github.com/jeremyevans) +* Eileen Uchitelle [eileencodes](https://github.com/eileencodes) +* Matthew Draper [matthewd](https://github.com/matthewd) +* Rafael França [rafaelfranca](https://github.com/rafaelfranca) + +and the Rack Alumni + +* Ryan Tomayko [rtomayko](https://github.com/rtomayko) +* Scytrin dai Kinthra [scytrin](https://github.com/scytrin) +* Leah Neukirchen [leahneukirchen](https://github.com/leahneukirchen) +* James Tucker [raggi](https://github.com/raggi) +* Josh Peek [josh](https://github.com/josh) +* José Valim [josevalim](https://github.com/josevalim) +* Michael Fellinger [manveru](https://github.com/manveru) +* Santiago Pastorino [spastorino](https://github.com/spastorino) +* Konstantin Haase [rkh](https://github.com/rkh) + +would like to thank: + +* Adrian Madrid, for the LiteSpeed handler. +* Christoffer Sawicki, for the first Rails adapter and `Rack::Deflater`. +* Tim Fletcher, for the HTTP authentication code. +* Luc Heinrich for the Cookie sessions, the static file handler and bugfixes. +* Armin Ronacher, for the logo and racktools. +* Alex Beregszaszi, Alexander Kahn, Anil Wadghule, Aredridel, Ben Alpert, Dan + Kubb, Daniel Roethlisberger, Matt Todd, Tom Robinson, Phil Hagelberg, S. Brent + Faulkner, Bosko Milekic, Daniel Rodríguez Troitiño, Genki Takiuchi, Geoffrey + Grosenbach, Julien Sanchez, Kamal Fariz Mahyuddin, Masayoshi Takahashi, + Patrick Aljordm, Mig, Kazuhiro Nishiyama, Jon Bardin, Konstantin Haase, Larry + Siden, Matias Korhonen, Sam Ruby, Simon Chiang, Tim Connor, Timur Batyrshin, + and Zach Brock for bug fixing and other improvements. +* Eric Wong, Hongli Lai, Jeremy Kemper for their continuous support and API + improvements. +* Yehuda Katz and Carl Lerche for refactoring rackup. +* Brian Candler, for `Rack::ContentType`. +* Graham Batty, for improved handler loading. +* Stephen Bannasch, for bug reports and documentation. +* Gary Wright, for proposing a better `Rack::Response` interface. +* Jonathan Buch, for improvements regarding `Rack::Response`. +* Armin Röhrl, for tracking down bugs in the Cookie generator. +* Alexander Kellett for testing the Gem and reviewing the announcement. +* Marcus Rückert, for help with configuring and debugging lighttpd. +* The WSGI team for the well-done and documented work they've done and Rack + builds up on. +* All bug reporters and patch contributors not mentioned above. + +## License + +Rack is released under the [MIT License](MIT-LICENSE). + +[Rack Specification]: SPEC.rdoc +[Security Policy]: SECURITY.md diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/SPEC.rdoc b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/SPEC.rdoc new file mode 100644 index 0000000..ed5d982 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/SPEC.rdoc @@ -0,0 +1,365 @@ +This specification aims to formalize the Rack protocol. You +can (and should) use Rack::Lint to enforce it. + +When you develop middleware, be sure to add a Lint before and +after to catch all mistakes. + += Rack applications + +A Rack application is a Ruby object (not a class) that +responds to +call+. +It takes exactly one argument, the *environment* +and returns a non-frozen Array of exactly three values: +The *status*, +the *headers*, +and the *body*. + +== The Environment + +The environment must be an unfrozen instance of Hash that includes +CGI-like headers. The Rack application is free to modify the +environment. + +The environment is required to include these variables +(adopted from {PEP 333}[https://peps.python.org/pep-0333/]), except when they'd be empty, but see +below. +REQUEST_METHOD:: The HTTP request method, such as + "GET" or "POST". This cannot ever + be an empty string, and so is + always required. +SCRIPT_NAME:: The initial portion of the request + URL's "path" that corresponds to the + application object, so that the + application knows its virtual + "location". This may be an empty + string, if the application corresponds + to the "root" of the server. +PATH_INFO:: The remainder of the request URL's + "path", designating the virtual + "location" of the request's target + within the application. This may be an + empty string, if the request URL targets + the application root and does not have a + trailing slash. This value may be + percent-encoded when originating from + a URL. +QUERY_STRING:: The portion of the request URL that + follows the ?, if any. May be + empty, but is always required! +SERVER_NAME:: When combined with SCRIPT_NAME and + PATH_INFO, these variables can be + used to complete the URL. Note, however, + that HTTP_HOST, if present, + should be used in preference to + SERVER_NAME for reconstructing + the request URL. + SERVER_NAME can never be an empty + string, and so is always required. +SERVER_PORT:: An optional +Integer+ which is the port the + server is running on. Should be specified if + the server is running on a non-standard port. +SERVER_PROTOCOL:: A string representing the HTTP version used + for the request. +HTTP_ Variables:: Variables corresponding to the + client-supplied HTTP request + headers (i.e., variables whose + names begin with HTTP_). The + presence or absence of these + variables should correspond with + the presence or absence of the + appropriate HTTP header in the + request. See + {RFC3875 section 4.1.18}[https://tools.ietf.org/html/rfc3875#section-4.1.18] + for specific behavior. +In addition to this, the Rack environment must include these +Rack-specific variables: +rack.url_scheme:: +http+ or +https+, depending on the + request URL. +rack.input:: See below, the input stream. +rack.errors:: See below, the error stream. +rack.hijack?:: See below, if present and true, indicates + that the server supports partial hijacking. +rack.hijack:: See below, if present, an object responding + to +call+ that is used to perform a full + hijack. +rack.protocol:: An optional +Array+ of +String+, containing + the protocols advertised by the client in + the +upgrade+ header (HTTP/1) or the + +:protocol+ pseudo-header (HTTP/2). +Additional environment specifications have approved to +standardized middleware APIs. None of these are required to +be implemented by the server. +rack.session:: A hash-like interface for storing + request session data. + The store must implement: + store(key, value) (aliased as []=); + fetch(key, default = nil) (aliased as []); + delete(key); + clear; + to_hash (returning unfrozen Hash instance); +rack.logger:: A common object interface for logging messages. + The object must implement: + info(message, &block) + debug(message, &block) + warn(message, &block) + error(message, &block) + fatal(message, &block) +rack.multipart.buffer_size:: An Integer hint to the multipart parser as to what chunk size to use for reads and writes. +rack.multipart.tempfile_factory:: An object responding to #call with two arguments, the filename and content_type given for the multipart form field, and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate the tempfile for each multipart form file upload field, rather than the default class of Tempfile. +The server or the application can store their own data in the +environment, too. The keys must contain at least one dot, +and should be prefixed uniquely. The prefix rack. +is reserved for use with the Rack core distribution and other +accepted specifications and must not be used otherwise. + +The SERVER_PORT must be an Integer if set. +The SERVER_NAME must be a valid authority as defined by RFC7540. +The HTTP_HOST must be a valid authority as defined by RFC7540. +The SERVER_PROTOCOL must match the regexp HTTP/\d(\.\d)?. +The environment must not contain the keys +HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH +(use the versions without HTTP_). +The CGI keys (named without a period) must have String values. +If the string values for CGI keys contain non-ASCII characters, +they should use ASCII-8BIT encoding. +There are the following restrictions: +* rack.url_scheme must either be +http+ or +https+. +* There may be a valid input stream in rack.input. +* There must be a valid error stream in rack.errors. +* There may be a valid hijack callback in rack.hijack +* There may be a valid early hints callback in rack.early_hints +* The REQUEST_METHOD must be a valid token. +* The SCRIPT_NAME, if non-empty, must start with / +* The PATH_INFO, if provided, must be a valid request target or an empty string. + * Only OPTIONS requests may have PATH_INFO set to * (asterisk-form). + * Only CONNECT requests may have PATH_INFO set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. + * CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form). + * Otherwise, PATH_INFO must start with a / and must not include a fragment part starting with '#' (origin-form). +* The CONTENT_LENGTH, if given, must consist of digits only. +* One of SCRIPT_NAME or PATH_INFO must be + set. PATH_INFO should be / if + SCRIPT_NAME is empty. + SCRIPT_NAME never should be /, but instead be empty. +rack.response_finished:: An array of callables run by the server after the response has been +processed. This would typically be invoked after sending the response to the client, but it could also be +invoked if an error occurs while generating the response or sending the response; in that case, the error +argument will be a subclass of +Exception+. +The callables are invoked with +env, status, headers, error+ arguments and should not raise any +exceptions. They should be invoked in reverse order of registration. + +=== The Input Stream + +The input stream is an IO-like object which contains the raw HTTP +POST data. +When applicable, its external encoding must be "ASCII-8BIT" and it +must be opened in binary mode. +The input stream must respond to +gets+, +each+, and +read+. +* +gets+ must be called without arguments and return a string, + or +nil+ on EOF. +* +read+ behaves like IO#read. + Its signature is read([length, [buffer]]). + + If given, +length+ must be a non-negative Integer (>= 0) or +nil+, + and +buffer+ must be a String and may not be nil. + + If +length+ is given and not nil, then this method reads at most + +length+ bytes from the input stream. + + If +length+ is not given or nil, then this method reads + all data until EOF. + + When EOF is reached, this method returns nil if +length+ is given + and not nil, or "" if +length+ is not given or is nil. + + If +buffer+ is given, then the read data will be placed + into +buffer+ instead of a newly created String object. +* +each+ must be called without arguments and only yield Strings. +* +close+ can be called on the input stream to indicate that + any remaining input is not needed. + +=== The Error Stream + +The error stream must respond to +puts+, +write+ and +flush+. +* +puts+ must be called with a single argument that responds to +to_s+. +* +write+ must be called with a single argument that is a String. +* +flush+ must be called without arguments and must be called + in order to make the error appear for sure. +* +close+ must never be called on the error stream. + +=== Hijacking + +The hijacking interfaces provides a means for an application to take +control of the HTTP connection. There are two distinct hijack +interfaces: full hijacking where the application takes over the raw +connection, and partial hijacking where the application takes over +just the response body stream. In both cases, the application is +responsible for closing the hijacked stream. + +Full hijacking only works with HTTP/1. Partial hijacking is functionally +equivalent to streaming bodies, and is still optionally supported for +backwards compatibility with older Rack versions. + +==== Full Hijack + +Full hijack is used to completely take over an HTTP/1 connection. It +occurs before any headers are written and causes the request to +ignores any response generated by the application. + +It is intended to be used when applications need access to raw HTTP/1 +connection. + +If +rack.hijack+ is present in +env+, it must respond to +call+ +and return an +IO+ instance which can be used to read and write +to the underlying connection using HTTP/1 semantics and +formatting. + +==== Partial Hijack + +Partial hijack is used for bi-directional streaming of the request and +response body. It occurs after the status and headers are written by +the server and causes the server to ignore the Body of the response. + +It is intended to be used when applications need bi-directional +streaming. + +If +rack.hijack?+ is present in +env+ and truthy, +an application may set the special response header +rack.hijack+ +to an object that responds to +call+, +accepting a +stream+ argument. + +After the response status and headers have been sent, this hijack +callback will be invoked with a +stream+ argument which follows the +same interface as outlined in "Streaming Body". Servers must +ignore the +body+ part of the response tuple when the ++rack.hijack+ response header is present. Using an empty +Array+ +instance is recommended. + +The special response header +rack.hijack+ must only be set +if the request +env+ has a truthy +rack.hijack?+. + +=== Early Hints + +The application or any middleware may call the rack.early_hints +with an object which would be valid as the headers of a Rack response. + +If rack.early_hints is present, it must respond to #call. +If rack.early_hints is called, it must be called with +valid Rack response headers. + +== The Response + +=== The Status + +This is an HTTP status. It must be an Integer greater than or equal to +100. + +=== The Headers + +The headers must be a unfrozen Hash. +The header keys must be Strings. +Special headers starting "rack." are for communicating with the +server, and must not be sent back to the client. +The header must not contain a +Status+ key. +Header keys must conform to RFC7230 token specification, i.e. cannot +contain non-printable ASCII, DQUOTE or "(),/:;<=>?@[\]{}". +Header keys must not contain uppercase ASCII characters (A-Z). +Header values must be either a String instance, +or an Array of String instances, +such that each String instance must not contain characters below 037. + +==== The +content-type+ Header + +There must not be a content-type header key when the +Status+ is 1xx, +204, or 304. + +==== The +content-length+ Header + +There must not be a content-length header key when the ++Status+ is 1xx, 204, or 304. + +==== The +rack.protocol+ Header + +If the +rack.protocol+ header is present, it must be a +String+, and +must be one of the values from the +rack.protocol+ array from the +environment. + +Setting this value informs the server that it should perform a +connection upgrade. In HTTP/1, this is done using the +upgrade+ +header. In HTTP/2, this is done by accepting the request. + +=== The Body + +The Body is typically an +Array+ of +String+ instances, an enumerable +that yields +String+ instances, a +Proc+ instance, or a File-like +object. + +The Body must respond to +each+ or +call+. It may optionally respond +to +to_path+ or +to_ary+. A Body that responds to +each+ is considered +to be an Enumerable Body. A Body that responds to +call+ is considered +to be a Streaming Body. + +A Body that responds to both +each+ and +call+ must be treated as an +Enumerable Body, not a Streaming Body. If it responds to +each+, you +must call +each+ and not +call+. If the Body doesn't respond to ++each+, then you can assume it responds to +call+. + +The Body must either be consumed or returned. The Body is consumed by +optionally calling either +each+ or +call+. +Then, if the Body responds to +close+, it must be called to release +any resources associated with the generation of the body. +In other words, +close+ must always be called at least once; typically +after the web server has sent the response to the client, but also in +cases where the Rack application makes internal/virtual requests and +discards the response. + + +After calling +close+, the Body is considered closed and should not +be consumed again. +If the original Body is replaced by a new Body, the new Body must +also consume the original Body by calling +close+ if possible. + +If the Body responds to +to_path+, it must return a +String+ +path for the local file system whose contents are identical +to that produced by calling +each+; this may be used by the +server as an alternative, possibly more efficient way to +transport the response. The +to_path+ method does not consume +the body. + +==== Enumerable Body + +The Enumerable Body must respond to +each+. +It must only be called once. +It must not be called after being closed, +and must only yield String values. + +Middleware must not call +each+ directly on the Body. +Instead, middleware can return a new Body that calls +each+ on the +original Body, yielding at least once per iteration. + +If the Body responds to +to_ary+, it must return an +Array+ whose +contents are identical to that produced by calling +each+. +Middleware may call +to_ary+ directly on the Body and return a new +Body in its place. In other words, middleware can only process the +Body directly if it responds to +to_ary+. If the Body responds to both ++to_ary+ and +close+, its implementation of +to_ary+ must call ++close+. + +==== Streaming Body + +The Streaming Body must respond to +call+. +It must only be called once. +It must not be called after being closed. +It takes a +stream+ argument. + +The +stream+ argument must implement: +read, write, <<, flush, close, close_read, close_write, closed? + +The semantics of these IO methods must be a best effort match to +those of a normal Ruby IO or Socket object, using standard arguments +and raising standard exceptions. Servers are encouraged to simply +pass on real IO objects, although it is recognized that this approach +is not directly compatible with HTTP/2. + +== Thanks +Some parts of this specification are adopted from {PEP 333 – Python Web Server Gateway Interface v1.0}[https://peps.python.org/pep-0333/] +I'd like to thank everyone involved in that effort. diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack.rb new file mode 100644 index 0000000..6021248 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack.rb @@ -0,0 +1,66 @@ +# socket-patch: patched rack-3.1.8 (spike marker) +# frozen_string_literal: true + +# Copyright (C) 2007-2019 Leah Neukirchen +# +# Rack is freely distributable under the terms of an MIT-style license. +# See MIT-LICENSE or https://opensource.org/licenses/MIT. + +# The Rack main module, serving as a namespace for all core Rack +# modules and classes. +# +# All modules meant for use in your application are autoloaded here, +# so it should be enough just to require 'rack' in your code. + +require_relative 'rack/version' +require_relative 'rack/constants' + +module Rack + autoload :BadRequest, "rack/bad_request" + autoload :BodyProxy, "rack/body_proxy" + autoload :Builder, "rack/builder" + autoload :Cascade, "rack/cascade" + autoload :CommonLogger, "rack/common_logger" + autoload :ConditionalGet, "rack/conditional_get" + autoload :Config, "rack/config" + autoload :ContentLength, "rack/content_length" + autoload :ContentType, "rack/content_type" + autoload :Deflater, "rack/deflater" + autoload :Directory, "rack/directory" + autoload :ETag, "rack/etag" + autoload :Events, "rack/events" + autoload :Files, "rack/files" + autoload :ForwardRequest, "rack/recursive" + autoload :Head, "rack/head" + autoload :Headers, "rack/headers" + autoload :Lint, "rack/lint" + autoload :Lock, "rack/lock" + autoload :Logger, "rack/logger" + autoload :MediaType, "rack/media_type" + autoload :MethodOverride, "rack/method_override" + autoload :Mime, "rack/mime" + autoload :MockRequest, "rack/mock_request" + autoload :MockResponse, "rack/mock_response" + autoload :Multipart, "rack/multipart" + autoload :NullLogger, "rack/null_logger" + autoload :QueryParser, "rack/query_parser" + autoload :Recursive, "rack/recursive" + autoload :Reloader, "rack/reloader" + autoload :Request, "rack/request" + autoload :Response, "rack/response" + autoload :RewindableInput, "rack/rewindable_input" + autoload :Runtime, "rack/runtime" + autoload :Sendfile, "rack/sendfile" + autoload :ShowExceptions, "rack/show_exceptions" + autoload :ShowStatus, "rack/show_status" + autoload :Static, "rack/static" + autoload :TempfileReaper, "rack/tempfile_reaper" + autoload :URLMap, "rack/urlmap" + autoload :Utils, "rack/utils" + + module Auth + autoload :Basic, "rack/auth/basic" + autoload :AbstractHandler, "rack/auth/abstract/handler" + autoload :AbstractRequest, "rack/auth/abstract/request" + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb new file mode 100644 index 0000000..4731ee8 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative '../../constants' + +module Rack + module Auth + # Rack::Auth::AbstractHandler implements common authentication functionality. + # + # +realm+ should be set for all handlers. + + class AbstractHandler + + attr_accessor :realm + + def initialize(app, realm = nil, &authenticator) + @app, @realm, @authenticator = app, realm, authenticator + end + + + private + + def unauthorized(www_authenticate = challenge) + return [ 401, + { CONTENT_TYPE => 'text/plain', + CONTENT_LENGTH => '0', + 'www-authenticate' => www_authenticate.to_s }, + [] + ] + end + + def bad_request + return [ 400, + { CONTENT_TYPE => 'text/plain', + CONTENT_LENGTH => '0' }, + [] + ] + end + + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb new file mode 100644 index 0000000..f872331 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require_relative '../../request' + +module Rack + module Auth + class AbstractRequest + + def initialize(env) + @env = env + end + + def request + @request ||= Request.new(@env) + end + + def provided? + !authorization_key.nil? && valid? + end + + def valid? + !@env[authorization_key].nil? + end + + def parts + @parts ||= @env[authorization_key].split(' ', 2) + end + + def scheme + @scheme ||= parts.first&.downcase + end + + def params + @params ||= parts.last + end + + + private + + AUTHORIZATION_KEYS = ['HTTP_AUTHORIZATION', 'X-HTTP_AUTHORIZATION', 'X_HTTP_AUTHORIZATION'] + + def authorization_key + @authorization_key ||= AUTHORIZATION_KEYS.detect { |key| @env.has_key?(key) } + end + + end + + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb new file mode 100644 index 0000000..67ffc49 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_relative 'abstract/handler' +require_relative 'abstract/request' + +module Rack + module Auth + # Rack::Auth::Basic implements HTTP Basic Authentication, as per RFC 2617. + # + # Initialize with the Rack application that you want protecting, + # and a block that checks if a username and password pair are valid. + + class Basic < AbstractHandler + + def call(env) + auth = Basic::Request.new(env) + + return unauthorized unless auth.provided? + + return bad_request unless auth.basic? + + if valid?(auth) + env['REMOTE_USER'] = auth.username + + return @app.call(env) + end + + unauthorized + end + + + private + + def challenge + 'Basic realm="%s"' % realm + end + + def valid?(auth) + @authenticator.call(*auth.credentials) + end + + class Request < Auth::AbstractRequest + def basic? + "basic" == scheme && credentials.length == 2 + end + + def credentials + @credentials ||= params.unpack1('m').split(':', 2) + end + + def username + credentials.first + end + end + + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/bad_request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/bad_request.rb new file mode 100644 index 0000000..8eaa94e --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/bad_request.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Rack + # Represents a 400 Bad Request error when input data fails to meet the + # requirements. + module BadRequest + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb new file mode 100644 index 0000000..7291579 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Rack + # Proxy for response bodies allowing calling a block when + # the response body is closed (after the response has been fully + # sent to the client). + class BodyProxy + # Set the response body to wrap, and the block to call when the + # response has been fully sent. + def initialize(body, &block) + @body = body + @block = block + @closed = false + end + + # Return whether the wrapped body responds to the method. + def respond_to_missing?(method_name, include_all = false) + case method_name + when :to_str + false + else + super or @body.respond_to?(method_name, include_all) + end + end + + # If not already closed, close the wrapped body and + # then call the block the proxy was initialized with. + def close + return if @closed + @closed = true + begin + @body.close if @body.respond_to?(:close) + ensure + @block.call + end + end + + # Whether the proxy is closed. The proxy starts as not closed, + # and becomes closed on the first call to close. + def closed? + @closed + end + + # Delegate missing methods to the wrapped body. + def method_missing(method_name, *args, &block) + case method_name + when :to_str + super + when :to_ary + begin + @body.__send__(method_name, *args, &block) + ensure + close + end + else + @body.__send__(method_name, *args, &block) + end + end + # :nocov: + ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) + # :nocov: + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/builder.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/builder.rb new file mode 100644 index 0000000..9faeffb --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/builder.rb @@ -0,0 +1,290 @@ +# frozen_string_literal: true + +require_relative 'urlmap' + +module Rack; end +Rack::BUILDER_TOPLEVEL_BINDING = ->(builder){builder.instance_eval{binding}} + +module Rack + # Rack::Builder provides a domain-specific language (DSL) to construct Rack + # applications. It is primarily used to parse +config.ru+ files which + # instantiate several middleware and a final application which are hosted + # by a Rack-compatible web server. + # + # Example: + # + # app = Rack::Builder.new do + # use Rack::CommonLogger + # map "/ok" do + # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } + # end + # end + # + # run app + # + # Or + # + # app = Rack::Builder.app do + # use Rack::CommonLogger + # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } + # end + # + # run app + # + # +use+ adds middleware to the stack, +run+ dispatches to an application. + # You can use +map+ to construct a Rack::URLMap in a convenient way. + class Builder + + # https://stackoverflow.com/questions/2223882/whats-the-difference-between-utf-8-and-utf-8-without-bom + UTF_8_BOM = '\xef\xbb\xbf' + + # Parse the given config file to get a Rack application. + # + # If the config file ends in +.ru+, it is treated as a + # rackup file and the contents will be treated as if + # specified inside a Rack::Builder block. + # + # If the config file does not end in +.ru+, it is + # required and Rack will use the basename of the file + # to guess which constant will be the Rack application to run. + # + # Examples: + # + # Rack::Builder.parse_file('config.ru') + # # Rack application built using Rack::Builder.new + # + # Rack::Builder.parse_file('app.rb') + # # requires app.rb, which can be anywhere in Ruby's + # # load path. After requiring, assumes App constant + # # is a Rack application + # + # Rack::Builder.parse_file('./my_app.rb') + # # requires ./my_app.rb, which should be in the + # # process's current directory. After requiring, + # # assumes MyApp constant is a Rack application + def self.parse_file(path, **options) + if path.end_with?('.ru') + return self.load_file(path, **options) + else + require path + return Object.const_get(::File.basename(path, '.rb').split('_').map(&:capitalize).join('')) + end + end + + # Load the given file as a rackup file, treating the + # contents as if specified inside a Rack::Builder block. + # + # Ignores content in the file after +__END__+, so that + # use of +__END__+ will not result in a syntax error. + # + # Example config.ru file: + # + # $ cat config.ru + # + # use Rack::ContentLength + # require './app.rb' + # run App + def self.load_file(path, **options) + config = ::File.read(path) + config.slice!(/\A#{UTF_8_BOM}/) if config.encoding == Encoding::UTF_8 + + if config[/^#\\(.*)/] + fail "Parsing options from the first comment line is no longer supported: #{path}" + end + + config.sub!(/^__END__\n.*\Z/m, '') + + return new_from_string(config, path, **options) + end + + # Evaluate the given +builder_script+ string in the context of + # a Rack::Builder block, returning a Rack application. + def self.new_from_string(builder_script, path = "(rackup)", **options) + builder = self.new(**options) + + # We want to build a variant of TOPLEVEL_BINDING with self as a Rack::Builder instance. + # We cannot use instance_eval(String) as that would resolve constants differently. + binding = BUILDER_TOPLEVEL_BINDING.call(builder) + eval(builder_script, binding, path) + + return builder.to_app + end + + # Initialize a new Rack::Builder instance. +default_app+ specifies the + # default application if +run+ is not called later. If a block + # is given, it is evaluated in the context of the instance. + def initialize(default_app = nil, **options, &block) + @use = [] + @map = nil + @run = default_app + @warmup = nil + @freeze_app = false + @options = options + + instance_eval(&block) if block_given? + end + + # Any options provided to the Rack::Builder instance at initialization. + # These options can be server-specific. Some general options are: + # + # * +:isolation+: One of +process+, +thread+ or +fiber+. The execution + # isolation model to use. + attr :options + + # Create a new Rack::Builder instance and return the Rack application + # generated from it. + def self.app(default_app = nil, &block) + self.new(default_app, &block).to_app + end + + # Specifies middleware to use in a stack. + # + # class Middleware + # def initialize(app) + # @app = app + # end + # + # def call(env) + # env["rack.some_header"] = "setting an example" + # @app.call(env) + # end + # end + # + # use Middleware + # run lambda { |env| [200, { "content-type" => "text/plain" }, ["OK"]] } + # + # All requests through to this application will first be processed by the middleware class. + # The +call+ method in this example sets an additional environment key which then can be + # referenced in the application if required. + def use(middleware, *args, &block) + if @map + mapping, @map = @map, nil + @use << proc { |app| generate_map(app, mapping) } + end + @use << proc { |app| middleware.new(app, *args, &block) } + end + # :nocov: + ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true) + # :nocov: + + # Takes a block or argument that is an object that responds to #call and + # returns a Rack response. + # + # You can use a block: + # + # run do |env| + # [200, { "content-type" => "text/plain" }, ["Hello World!"]] + # end + # + # You can also provide a lambda: + # + # run lambda { |env| [200, { "content-type" => "text/plain" }, ["OK"]] } + # + # You can also provide a class instance: + # + # class Heartbeat + # def call(env) + # [200, { "content-type" => "text/plain" }, ["OK"]] + # end + # end + # + # run Heartbeat.new + # + def run(app = nil, &block) + raise ArgumentError, "Both app and block given!" if app && block_given? + + @run = app || block + end + + # Takes a lambda or block that is used to warm-up the application. This block is called + # before the Rack application is returned by to_app. + # + # warmup do |app| + # client = Rack::MockRequest.new(app) + # client.get('/') + # end + # + # use SomeMiddleware + # run MyApp + def warmup(prc = nil, &block) + @warmup = prc || block + end + + # Creates a route within the application. Routes under the mapped path will be sent to + # the Rack application specified by run inside the block. Other requests will be sent to the + # default application specified by run outside the block. + # + # class App + # def call(env) + # [200, {'content-type' => 'text/plain'}, ["Hello World"]] + # end + # end + # + # class Heartbeat + # def call(env) + # [200, { "content-type" => "text/plain" }, ["OK"]] + # end + # end + # + # app = Rack::Builder.app do + # map '/heartbeat' do + # run Heartbeat.new + # end + # run App.new + # end + # + # run app + # + # The +use+ method can also be used inside the block to specify middleware to run under a specific path: + # + # app = Rack::Builder.app do + # map '/heartbeat' do + # use Middleware + # run Heartbeat.new + # end + # run App.new + # end + # + # This example includes a piece of middleware which will run before +/heartbeat+ requests hit +Heartbeat+. + # + # Note that providing a +path+ of +/+ will ignore any default application given in a +run+ statement + # outside the block. + def map(path, &block) + @map ||= {} + @map[path] = block + end + + # Freeze the app (set using run) and all middleware instances when building the application + # in to_app. + def freeze_app + @freeze_app = true + end + + # Return the Rack application generated by this instance. + def to_app + app = @map ? generate_map(@run, @map) : @run + fail "missing run or map statement" unless app + app.freeze if @freeze_app + app = @use.reverse.inject(app) { |a, e| e[a].tap { |x| x.freeze if @freeze_app } } + @warmup.call(app) if @warmup + app + end + + # Call the Rack application generated by this builder instance. Note that + # this rebuilds the Rack application and runs the warmup code (if any) + # every time it is called, so it should not be used if performance is important. + def call(env) + to_app.call(env) + end + + private + + # Generate a URLMap instance by generating new Rack applications for each + # map block in this instance. + def generate_map(default_app, mapping) + mapped = default_app ? { '/' => default_app } : {} + mapping.each { |r, b| mapped[r] = self.class.new(default_app, &b).to_app } + URLMap.new(mapped) + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/cascade.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/cascade.rb new file mode 100644 index 0000000..9c952fd --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/cascade.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative 'constants' + +module Rack + # Rack::Cascade tries a request on several apps, and returns the + # first response that is not 404 or 405 (or in a list of configured + # status codes). If all applications tried return one of the configured + # status codes, return the last response. + + class Cascade + # An array of applications to try in order. + attr_reader :apps + + # Set the apps to send requests to, and what statuses result in + # cascading. Arguments: + # + # apps: An enumerable of rack applications. + # cascade_for: The statuses to use cascading for. If a response is received + # from an app, the next app is tried. + def initialize(apps, cascade_for = [404, 405]) + @apps = [] + apps.each { |app| add app } + + @cascade_for = {} + [*cascade_for].each { |status| @cascade_for[status] = true } + end + + # Call each app in order. If the responses uses a status that requires + # cascading, try the next app. If all responses require cascading, + # return the response from the last app. + def call(env) + return [404, { CONTENT_TYPE => "text/plain" }, []] if @apps.empty? + result = nil + last_body = nil + + @apps.each do |app| + # The SPEC says that the body must be closed after it has been iterated + # by the server, or if it is replaced by a middleware action. Cascade + # replaces the body each time a cascade happens. It is assumed that nil + # does not respond to close, otherwise the previous application body + # will be closed. The final application body will not be closed, as it + # will be passed to the server as a result. + last_body.close if last_body.respond_to? :close + + result = app.call(env) + return result unless @cascade_for.include?(result[0].to_i) + last_body = result[2] + end + + result + end + + # Append an app to the list of apps to cascade. This app will + # be tried last. + def add(app) + @apps << app + end + + # Whether the given app is one of the apps to cascade to. + def include?(app) + @apps.include?(app) + end + + alias_method :<<, :add + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/common_logger.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/common_logger.rb new file mode 100644 index 0000000..2feb067 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/common_logger.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' +require_relative 'body_proxy' +require_relative 'request' + +module Rack + # Rack::CommonLogger forwards every request to the given +app+, and + # logs a line in the + # {Apache common log format}[http://httpd.apache.org/docs/1.3/logs.html#common] + # to the configured logger. + class CommonLogger + # Common Log Format: http://httpd.apache.org/docs/1.3/logs.html#common + # + # lilith.local - - [07/Aug/2006 23:58:02 -0400] "GET / HTTP/1.1" 500 - + # + # %{%s - %s [%s] "%s %s%s %s" %d %s\n} % + # + # The actual format is slightly different than the above due to the + # separation of SCRIPT_NAME and PATH_INFO, and because the elapsed + # time in seconds is included at the end. + FORMAT = %{%s - %s [%s] "%s %s%s%s %s" %d %s %0.4f\n} + + # +logger+ can be any object that supports the +write+ or +<<+ methods, + # which includes the standard library Logger. These methods are called + # with a single string argument, the log message. + # If +logger+ is nil, CommonLogger will fall back env['rack.errors']. + def initialize(app, logger = nil) + @app = app + @logger = logger + end + + # Log all requests in common_log format after a response has been + # returned. Note that if the app raises an exception, the request + # will not be logged, so if exception handling middleware are used, + # they should be loaded after this middleware. Additionally, because + # the logging happens after the request body has been fully sent, any + # exceptions raised during the sending of the response body will + # cause the request not to be logged. + def call(env) + began_at = Utils.clock_time + status, headers, body = response = @app.call(env) + + response[2] = BodyProxy.new(body) { log(env, status, headers, began_at) } + response + end + + private + + # Log the request to the configured logger. + def log(env, status, response_headers, began_at) + request = Rack::Request.new(env) + length = extract_content_length(response_headers) + + msg = sprintf(FORMAT, + request.ip || "-", + request.get_header("REMOTE_USER") || "-", + Time.now.strftime("%d/%b/%Y:%H:%M:%S %z"), + request.request_method, + request.script_name, + request.path_info, + request.query_string.empty? ? "" : "?#{request.query_string}", + request.get_header(SERVER_PROTOCOL), + status.to_s[0..3], + length, + Utils.clock_time - began_at) + + msg.gsub!(/[^[:print:]\n]/) { |c| sprintf("\\x%x", c.ord) } + + logger = @logger || request.get_header(RACK_ERRORS) + # Standard library logger doesn't support write but it supports << which actually + # calls to write on the log device without formatting + if logger.respond_to?(:write) + logger.write(msg) + else + logger << msg + end + end + + # Attempt to determine the content length for the response to + # include it in the logged data. + def extract_content_length(headers) + value = headers[CONTENT_LENGTH] + !value || value.to_s == '0' ? '-' : value + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb new file mode 100644 index 0000000..c3b334a --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' +require_relative 'body_proxy' + +module Rack + + # Middleware that enables conditional GET using if-none-match and + # if-modified-since. The application should set either or both of the + # last-modified or etag response headers according to RFC 2616. When + # either of the conditions is met, the response body is set to be zero + # length and the response status is set to 304 Not Modified. + # + # Applications that defer response body generation until the body's each + # message is received will avoid response body generation completely when + # a conditional GET matches. + # + # Adapted from Michael Klishin's Merb implementation: + # https://github.com/wycats/merb/blob/master/merb-core/lib/merb-core/rack/middleware/conditional_get.rb + class ConditionalGet + def initialize(app) + @app = app + end + + # Return empty 304 response if the response has not been + # modified since the last request. + def call(env) + case env[REQUEST_METHOD] + when "GET", "HEAD" + status, headers, body = response = @app.call(env) + + if status == 200 && fresh?(env, headers) + response[0] = 304 + headers.delete(CONTENT_TYPE) + headers.delete(CONTENT_LENGTH) + response[2] = Rack::BodyProxy.new([]) do + body.close if body.respond_to?(:close) + end + end + response + else + @app.call(env) + end + end + + private + + # Return whether the response has not been modified since the + # last request. + def fresh?(env, headers) + # if-none-match has priority over if-modified-since per RFC 7232 + if none_match = env['HTTP_IF_NONE_MATCH'] + etag_matches?(none_match, headers) + elsif (modified_since = env['HTTP_IF_MODIFIED_SINCE']) && (modified_since = to_rfc2822(modified_since)) + modified_since?(modified_since, headers) + end + end + + # Whether the etag response header matches the if-none-match request header. + # If so, the request has not been modified. + def etag_matches?(none_match, headers) + headers[ETAG] == none_match + end + + # Whether the last-modified response header matches the if-modified-since + # request header. If so, the request has not been modified. + def modified_since?(modified_since, headers) + last_modified = to_rfc2822(headers['last-modified']) and + modified_since >= last_modified + end + + # Return a Time object for the given string (which should be in RFC2822 + # format), or nil if the string cannot be parsed. + def to_rfc2822(since) + # shortest possible valid date is the obsolete: 1 Nov 97 09:55 A + # anything shorter is invalid, this avoids exceptions for common cases + # most common being the empty string + if since && since.length >= 16 + # NOTE: there is no trivial way to write this in a non exception way + # _rfc2822 returns a hash but is not that usable + Time.rfc2822(since) rescue nil + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/config.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/config.rb new file mode 100644 index 0000000..41f6f7d --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/config.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Rack + # Rack::Config modifies the environment using the block given during + # initialization. + # + # Example: + # use Rack::Config do |env| + # env['my-key'] = 'some-value' + # end + class Config + def initialize(app, &block) + @app = app + @block = block + end + + def call(env) + @block.call(env) + @app.call(env) + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/constants.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/constants.rb new file mode 100644 index 0000000..e9b6e10 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/constants.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Rack + # Request env keys + HTTP_HOST = 'HTTP_HOST' + HTTP_PORT = 'HTTP_PORT' + HTTPS = 'HTTPS' + PATH_INFO = 'PATH_INFO' + REQUEST_METHOD = 'REQUEST_METHOD' + REQUEST_PATH = 'REQUEST_PATH' + SCRIPT_NAME = 'SCRIPT_NAME' + QUERY_STRING = 'QUERY_STRING' + SERVER_PROTOCOL = 'SERVER_PROTOCOL' + SERVER_NAME = 'SERVER_NAME' + SERVER_PORT = 'SERVER_PORT' + HTTP_COOKIE = 'HTTP_COOKIE' + + # Response Header Keys + CACHE_CONTROL = 'cache-control' + CONTENT_LENGTH = 'content-length' + CONTENT_TYPE = 'content-type' + ETAG = 'etag' + EXPIRES = 'expires' + SET_COOKIE = 'set-cookie' + TRANSFER_ENCODING = 'transfer-encoding' + + # HTTP method verbs + GET = 'GET' + POST = 'POST' + PUT = 'PUT' + PATCH = 'PATCH' + DELETE = 'DELETE' + HEAD = 'HEAD' + OPTIONS = 'OPTIONS' + CONNECT = 'CONNECT' + LINK = 'LINK' + UNLINK = 'UNLINK' + TRACE = 'TRACE' + + # Rack environment variables + RACK_VERSION = 'rack.version' + RACK_TEMPFILES = 'rack.tempfiles' + RACK_EARLY_HINTS = 'rack.early_hints' + RACK_ERRORS = 'rack.errors' + RACK_LOGGER = 'rack.logger' + RACK_INPUT = 'rack.input' + RACK_SESSION = 'rack.session' + RACK_SESSION_OPTIONS = 'rack.session.options' + RACK_SHOWSTATUS_DETAIL = 'rack.showstatus.detail' + RACK_URL_SCHEME = 'rack.url_scheme' + RACK_HIJACK = 'rack.hijack' + RACK_IS_HIJACK = 'rack.hijack?' + RACK_RECURSIVE_INCLUDE = 'rack.recursive.include' + RACK_MULTIPART_BUFFER_SIZE = 'rack.multipart.buffer_size' + RACK_MULTIPART_TEMPFILE_FACTORY = 'rack.multipart.tempfile_factory' + RACK_RESPONSE_FINISHED = 'rack.response_finished' + RACK_REQUEST_FORM_INPUT = 'rack.request.form_input' + RACK_REQUEST_FORM_HASH = 'rack.request.form_hash' + RACK_REQUEST_FORM_PAIRS = 'rack.request.form_pairs' + RACK_REQUEST_FORM_VARS = 'rack.request.form_vars' + RACK_REQUEST_FORM_ERROR = 'rack.request.form_error' + RACK_REQUEST_COOKIE_HASH = 'rack.request.cookie_hash' + RACK_REQUEST_COOKIE_STRING = 'rack.request.cookie_string' + RACK_REQUEST_QUERY_HASH = 'rack.request.query_hash' + RACK_REQUEST_QUERY_STRING = 'rack.request.query_string' + RACK_METHODOVERRIDE_ORIGINAL_METHOD = 'rack.methodoverride.original_method' +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_length.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_length.rb new file mode 100644 index 0000000..cbac93a --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_length.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' + +module Rack + + # Sets the content-length header on responses that do not specify + # a content-length or transfer-encoding header. Note that this + # does not fix responses that have an invalid content-length + # header specified. + class ContentLength + include Rack::Utils + + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = response = @app.call(env) + + if !STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) && + !headers[CONTENT_LENGTH] && + !headers[TRANSFER_ENCODING] && + body.respond_to?(:to_ary) + + response[2] = body = body.to_ary + headers[CONTENT_LENGTH] = body.sum(&:bytesize).to_s + end + + response + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_type.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_type.rb new file mode 100644 index 0000000..19f0782 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_type.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' + +module Rack + + # Sets the content-type header on responses which don't have one. + # + # Builder Usage: + # use Rack::ContentType, "text/plain" + # + # When no content type argument is provided, "text/html" is the + # default. + class ContentType + include Rack::Utils + + def initialize(app, content_type = "text/html") + @app = app + @content_type = content_type + end + + def call(env) + status, headers, _ = response = @app.call(env) + + unless STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) + headers[CONTENT_TYPE] ||= @content_type + end + + response + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/deflater.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/deflater.rb new file mode 100644 index 0000000..cc01c32 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/deflater.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require "zlib" +require "time" # for Time.httpdate + +require_relative 'constants' +require_relative 'utils' +require_relative 'request' +require_relative 'body_proxy' + +module Rack + # This middleware enables content encoding of http responses, + # usually for purposes of compression. + # + # Currently supported encodings: + # + # * gzip + # * identity (no transformation) + # + # This middleware automatically detects when encoding is supported + # and allowed. For example no encoding is made when a cache + # directive of 'no-transform' is present, when the response status + # code is one that doesn't allow an entity body, or when the body + # is empty. + # + # Note that despite the name, Deflater does not support the +deflate+ + # encoding. + class Deflater + # Creates Rack::Deflater middleware. Options: + # + # :if :: a lambda enabling / disabling deflation based on returned boolean value + # (e.g use Rack::Deflater, :if => lambda { |*, body| sum=0; body.each { |i| sum += i.length }; sum > 512 }). + # However, be aware that calling `body.each` inside the block will break cases where `body.each` is not idempotent, + # such as when it is an +IO+ instance. + # :include :: a list of content types that should be compressed. By default, all content types are compressed. + # :sync :: determines if the stream is going to be flushed after every chunk. Flushing after every chunk reduces + # latency for time-sensitive streaming applications, but hurts compression and throughput. + # Defaults to +true+. + def initialize(app, options = {}) + @app = app + @condition = options[:if] + @compressible_types = options[:include] + @sync = options.fetch(:sync, true) + end + + def call(env) + status, headers, body = response = @app.call(env) + + unless should_deflate?(env, status, headers, body) + return response + end + + request = Request.new(env) + + encoding = Utils.select_best_encoding(%w(gzip identity), + request.accept_encoding) + + # Set the Vary HTTP header. + vary = headers["vary"].to_s.split(",").map(&:strip) + unless vary.include?("*") || vary.any?{|v| v.downcase == 'accept-encoding'} + headers["vary"] = vary.push("Accept-Encoding").join(",") + end + + case encoding + when "gzip" + headers['content-encoding'] = "gzip" + headers.delete(CONTENT_LENGTH) + mtime = headers["last-modified"] + mtime = Time.httpdate(mtime).to_i if mtime + response[2] = GzipStream.new(body, mtime, @sync) + response + when "identity" + response + else # when nil + # Only possible encoding values here are 'gzip', 'identity', and nil + message = "An acceptable encoding for the requested resource #{request.fullpath} could not be found." + bp = Rack::BodyProxy.new([message]) { body.close if body.respond_to?(:close) } + [406, { CONTENT_TYPE => "text/plain", CONTENT_LENGTH => message.length.to_s }, bp] + end + end + + # Body class used for gzip encoded responses. + class GzipStream + + BUFFER_LENGTH = 128 * 1_024 + + # Initialize the gzip stream. Arguments: + # body :: Response body to compress with gzip + # mtime :: The modification time of the body, used to set the + # modification time in the gzip header. + # sync :: Whether to flush each gzip chunk as soon as it is ready. + def initialize(body, mtime, sync) + @body = body + @mtime = mtime + @sync = sync + end + + # Yield gzip compressed strings to the given block. + def each(&block) + @writer = block + gzip = ::Zlib::GzipWriter.new(self) + gzip.mtime = @mtime if @mtime + # @body.each is equivalent to @body.gets (slow) + if @body.is_a? ::File # XXX: Should probably be ::IO + while part = @body.read(BUFFER_LENGTH) + gzip.write(part) + gzip.flush if @sync + end + else + @body.each { |part| + # Skip empty strings, as they would result in no output, + # and flushing empty parts would raise Zlib::BufError. + next if part.empty? + gzip.write(part) + gzip.flush if @sync + } + end + ensure + gzip.finish + end + + # Call the block passed to #each with the gzipped data. + def write(data) + @writer.call(data) + end + + # Close the original body if possible. + def close + @body.close if @body.respond_to?(:close) + end + end + + private + + # Whether the body should be compressed. + def should_deflate?(env, status, headers, body) + # Skip compressing empty entity body responses and responses with + # no-transform set. + if Utils::STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) || + /\bno-transform\b/.match?(headers[CACHE_CONTROL].to_s) || + headers['content-encoding']&.!~(/\bidentity\b/) + return false + end + + # Skip if @compressible_types are given and does not include request's content type + return false if @compressible_types && !(headers.has_key?(CONTENT_TYPE) && @compressible_types.include?(headers[CONTENT_TYPE][/[^;]*/])) + + # Skip if @condition lambda is given and evaluates to false + return false if @condition && !@condition.call(env, status, headers, body) + + # No point in compressing empty body, also handles usage with + # Rack::Sendfile. + return false if headers[CONTENT_LENGTH] == '0' + + true + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/directory.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/directory.rb new file mode 100644 index 0000000..089623f --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/directory.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'time' + +require_relative 'constants' +require_relative 'utils' +require_relative 'head' +require_relative 'mime' +require_relative 'files' + +module Rack + # Rack::Directory serves entries below the +root+ given, according to the + # path info of the Rack request. If a directory is found, the file's contents + # will be presented in an html based index. If a file is found, the env will + # be passed to the specified +app+. + # + # If +app+ is not specified, a Rack::Files of the same +root+ will be used. + + class Directory + DIR_FILE = "%s%s%s%s\n" + DIR_PAGE_HEADER = <<-PAGE + + %s + + + +

%s

+
+ + + + + + + + PAGE + DIR_PAGE_FOOTER = <<-PAGE +
NameSizeTypeLast Modified
+
+ + PAGE + + # Body class for directory entries, showing an index page with links + # to each file. + class DirectoryBody < Struct.new(:root, :path, :files) + # Yield strings for each part of the directory entry + def each + show_path = Utils.escape_html(path.sub(/^#{root}/, '')) + yield(DIR_PAGE_HEADER % [ show_path, show_path ]) + + unless path.chomp('/') == root + yield(DIR_FILE % DIR_FILE_escape(files.call('..'))) + end + + Dir.foreach(path) do |basename| + next if basename.start_with?('.') + next unless f = files.call(basename) + yield(DIR_FILE % DIR_FILE_escape(f)) + end + + yield(DIR_PAGE_FOOTER) + end + + private + + # Escape each element in the array of html strings. + def DIR_FILE_escape(htmls) + htmls.map { |e| Utils.escape_html(e) } + end + end + + # The root of the directory hierarchy. Only requests for files and + # directories inside of the root directory are supported. + attr_reader :root + + # Set the root directory and application for serving files. + def initialize(root, app = nil) + @root = ::File.expand_path(root) + @app = app || Files.new(@root) + @head = Head.new(method(:get)) + end + + def call(env) + # strip body if this is a HEAD call + @head.call env + end + + # Internals of request handling. Similar to call but does + # not remove body for HEAD requests. + def get(env) + script_name = env[SCRIPT_NAME] + path_info = Utils.unescape_path(env[PATH_INFO]) + + if client_error_response = check_bad_request(path_info) || check_forbidden(path_info) + client_error_response + else + path = ::File.join(@root, path_info) + list_path(env, path, path_info, script_name) + end + end + + # Rack response to use for requests with invalid paths, or nil if path is valid. + def check_bad_request(path_info) + return if Utils.valid_path?(path_info) + + body = "Bad Request\n" + [400, { CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.bytesize.to_s, + "x-cascade" => "pass" }, [body]] + end + + # Rack response to use for requests with paths outside the root, or nil if path is inside the root. + def check_forbidden(path_info) + return unless path_info.include? ".." + return if ::File.expand_path(::File.join(@root, path_info)).start_with?(@root) + + body = "Forbidden\n" + [403, { CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.bytesize.to_s, + "x-cascade" => "pass" }, [body]] + end + + # Rack response to use for directories under the root. + def list_directory(path_info, path, script_name) + url_head = (script_name.split('/') + path_info.split('/')).map do |part| + Utils.escape_path part + end + + # Globbing not safe as path could contain glob metacharacters + body = DirectoryBody.new(@root, path, ->(basename) do + stat = stat(::File.join(path, basename)) + next unless stat + + url = ::File.join(*url_head + [Utils.escape_path(basename)]) + mtime = stat.mtime.httpdate + if stat.directory? + type = 'directory' + size = '-' + url << '/' + if basename == '..' + basename = 'Parent Directory' + else + basename << '/' + end + else + type = Mime.mime_type(::File.extname(basename)) + size = filesize_format(stat.size) + end + + [ url, basename, size, type, mtime ] + end) + + [ 200, { CONTENT_TYPE => 'text/html; charset=utf-8' }, body ] + end + + # File::Stat for the given path, but return nil for missing/bad entries. + def stat(path) + ::File.stat(path) + rescue Errno::ENOENT, Errno::ELOOP + return nil + end + + # Rack response to use for files and directories under the root. + # Unreadable and non-file, non-directory entries will get a 404 response. + def list_path(env, path, path_info, script_name) + if (stat = stat(path)) && stat.readable? + return @app.call(env) if stat.file? + return list_directory(path_info, path, script_name) if stat.directory? + end + + entity_not_found(path_info) + end + + # Rack response to use for unreadable and non-file, non-directory entries. + def entity_not_found(path_info) + body = "Entity not found: #{path_info}\n" + [404, { CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.bytesize.to_s, + "x-cascade" => "pass" }, [body]] + end + + # Stolen from Ramaze + FILESIZE_FORMAT = [ + ['%.1fT', 1 << 40], + ['%.1fG', 1 << 30], + ['%.1fM', 1 << 20], + ['%.1fK', 1 << 10], + ] + + # Provide human readable file sizes + def filesize_format(int) + FILESIZE_FORMAT.each do |format, size| + return format % (int.to_f / size) if int >= size + end + + "#{int}B" + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/etag.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/etag.rb new file mode 100644 index 0000000..fa78b47 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/etag.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'digest/sha2' + +require_relative 'constants' +require_relative 'utils' + +module Rack + # Automatically sets the etag header on all String bodies. + # + # The etag header is skipped if etag or last-modified headers are sent or if + # a sendfile body (body.responds_to :to_path) is given (since such cases + # should be handled by apache/nginx). + # + # On initialization, you can pass two parameters: a cache-control directive + # used when etag is absent and a directive when it is present. The first + # defaults to nil, while the second defaults to "max-age=0, private, must-revalidate" + class ETag + ETAG_STRING = Rack::ETAG + DEFAULT_CACHE_CONTROL = "max-age=0, private, must-revalidate" + + def initialize(app, no_cache_control = nil, cache_control = DEFAULT_CACHE_CONTROL) + @app = app + @cache_control = cache_control + @no_cache_control = no_cache_control + end + + def call(env) + status, headers, body = response = @app.call(env) + + if etag_status?(status) && body.respond_to?(:to_ary) && !skip_caching?(headers) + body = body.to_ary + digest = digest_body(body) + headers[ETAG_STRING] = %(W/"#{digest}") if digest + end + + unless headers[CACHE_CONTROL] + if digest + headers[CACHE_CONTROL] = @cache_control if @cache_control + else + headers[CACHE_CONTROL] = @no_cache_control if @no_cache_control + end + end + + response + end + + private + + def etag_status?(status) + status == 200 || status == 201 + end + + def skip_caching?(headers) + headers.key?(ETAG_STRING) || headers.key?('last-modified') + end + + def digest_body(body) + digest = nil + + body.each do |part| + (digest ||= Digest::SHA256.new) << part unless part.empty? + end + + digest && digest.hexdigest.byteslice(0,32) + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/events.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/events.rb new file mode 100644 index 0000000..c7bb201 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/events.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require_relative 'body_proxy' +require_relative 'request' +require_relative 'response' + +module Rack + ### This middleware provides hooks to certain places in the request / + # response lifecycle. This is so that middleware that don't need to filter + # the response data can safely leave it alone and not have to send messages + # down the traditional "rack stack". + # + # The events are: + # + # * on_start(request, response) + # + # This event is sent at the start of the request, before the next + # middleware in the chain is called. This method is called with a request + # object, and a response object. Right now, the response object is always + # nil, but in the future it may actually be a real response object. + # + # * on_commit(request, response) + # + # The response has been committed. The application has returned, but the + # response has not been sent to the webserver yet. This method is always + # called with a request object and the response object. The response + # object is constructed from the rack triple that the application returned. + # Changes may still be made to the response object at this point. + # + # * on_send(request, response) + # + # The webserver has started iterating over the response body and presumably + # has started sending data over the wire. This method is always called with + # a request object and the response object. The response object is + # constructed from the rack triple that the application returned. Changes + # SHOULD NOT be made to the response object as the webserver has already + # started sending data. Any mutations will likely result in an exception. + # + # * on_finish(request, response) + # + # The webserver has closed the response, and all data has been written to + # the response socket. The request and response object should both be + # read-only at this point. The body MAY NOT be available on the response + # object as it may have been flushed to the socket. + # + # * on_error(request, response, error) + # + # An exception has occurred in the application or an `on_commit` event. + # This method will get the request, the response (if available) and the + # exception that was raised. + # + # ## Order + # + # `on_start` is called on the handlers in the order that they were passed to + # the constructor. `on_commit`, on_send`, `on_finish`, and `on_error` are + # called in the reverse order. `on_finish` handlers are called inside an + # `ensure` block, so they are guaranteed to be called even if something + # raises an exception. If something raises an exception in a `on_finish` + # method, then nothing is guaranteed. + + class Events + module Abstract + def on_start(req, res) + end + + def on_commit(req, res) + end + + def on_send(req, res) + end + + def on_finish(req, res) + end + + def on_error(req, res, e) + end + end + + class EventedBodyProxy < Rack::BodyProxy # :nodoc: + attr_reader :request, :response + + def initialize(body, request, response, handlers, &block) + super(body, &block) + @request = request + @response = response + @handlers = handlers + end + + def each + @handlers.reverse_each { |handler| handler.on_send request, response } + super + end + end + + class BufferedResponse < Rack::Response::Raw # :nodoc: + attr_reader :body + + def initialize(status, headers, body) + super(status, headers) + @body = body + end + + def to_a; [status, headers, body]; end + end + + def initialize(app, handlers) + @app = app + @handlers = handlers + end + + def call(env) + request = make_request env + on_start request, nil + + begin + status, headers, body = @app.call request.env + response = make_response status, headers, body + on_commit request, response + rescue StandardError => e + on_error request, response, e + on_finish request, response + raise + end + + body = EventedBodyProxy.new(body, request, response, @handlers) do + on_finish request, response + end + [response.status, response.headers, body] + end + + private + + def on_error(request, response, e) + @handlers.reverse_each { |handler| handler.on_error request, response, e } + end + + def on_commit(request, response) + @handlers.reverse_each { |handler| handler.on_commit request, response } + end + + def on_start(request, response) + @handlers.each { |handler| handler.on_start request, nil } + end + + def on_finish(request, response) + @handlers.reverse_each { |handler| handler.on_finish request, response } + end + + def make_request(env) + Rack::Request.new env + end + + def make_response(status, headers, body) + BufferedResponse.new status, headers, body + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/files.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/files.rb new file mode 100644 index 0000000..5b8353f --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/files.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require 'time' + +require_relative 'constants' +require_relative 'head' +require_relative 'utils' +require_relative 'request' +require_relative 'mime' + +module Rack + # Rack::Files serves files below the +root+ directory given, according to the + # path info of the Rack request. + # e.g. when Rack::Files.new("/etc") is used, you can access 'passwd' file + # as http://localhost:9292/passwd + # + # Handlers can detect if bodies are a Rack::Files, and use mechanisms + # like sendfile on the +path+. + + class Files + ALLOWED_VERBS = %w[GET HEAD OPTIONS] + ALLOW_HEADER = ALLOWED_VERBS.join(', ') + MULTIPART_BOUNDARY = 'AaB03x' + + attr_reader :root + + def initialize(root, headers = {}, default_mime = 'text/plain') + @root = (::File.expand_path(root) if root) + @headers = headers + @default_mime = default_mime + @head = Rack::Head.new(lambda { |env| get env }) + end + + def call(env) + # HEAD requests drop the response body, including 4xx error messages. + @head.call env + end + + def get(env) + request = Rack::Request.new env + unless ALLOWED_VERBS.include? request.request_method + return fail(405, "Method Not Allowed", { 'allow' => ALLOW_HEADER }) + end + + path_info = Utils.unescape_path request.path_info + return fail(400, "Bad Request") unless Utils.valid_path?(path_info) + + clean_path_info = Utils.clean_path_info(path_info) + path = ::File.join(@root, clean_path_info) + + available = begin + ::File.file?(path) && ::File.readable?(path) + rescue SystemCallError + # Not sure in what conditions this exception can occur, but this + # is a safe way to handle such an error. + # :nocov: + false + # :nocov: + end + + if available + serving(request, path) + else + fail(404, "File not found: #{path_info}") + end + end + + def serving(request, path) + if request.options? + return [200, { 'allow' => ALLOW_HEADER, CONTENT_LENGTH => '0' }, []] + end + last_modified = ::File.mtime(path).httpdate + return [304, {}, []] if request.get_header('HTTP_IF_MODIFIED_SINCE') == last_modified + + headers = { "last-modified" => last_modified } + mime_type = mime_type path, @default_mime + headers[CONTENT_TYPE] = mime_type if mime_type + + # Set custom headers + headers.merge!(@headers) if @headers + + status = 200 + size = filesize path + + ranges = Rack::Utils.get_byte_ranges(request.get_header('HTTP_RANGE'), size) + if ranges.nil? + # No ranges: + ranges = [0..size - 1] + elsif ranges.empty? + # Unsatisfiable. Return error, and file size: + response = fail(416, "Byte range unsatisfiable") + response[1]["content-range"] = "bytes */#{size}" + return response + else + # Partial content + partial_content = true + + if ranges.size == 1 + range = ranges[0] + headers["content-range"] = "bytes #{range.begin}-#{range.end}/#{size}" + else + headers[CONTENT_TYPE] = "multipart/byteranges; boundary=#{MULTIPART_BOUNDARY}" + end + + status = 206 + body = BaseIterator.new(path, ranges, mime_type: mime_type, size: size) + size = body.bytesize + end + + headers[CONTENT_LENGTH] = size.to_s + + if request.head? + body = [] + elsif !partial_content + body = Iterator.new(path, ranges, mime_type: mime_type, size: size) + end + + [status, headers, body] + end + + class BaseIterator + attr_reader :path, :ranges, :options + + def initialize(path, ranges, options) + @path = path + @ranges = ranges + @options = options + end + + def each + ::File.open(path, "rb") do |file| + ranges.each do |range| + yield multipart_heading(range) if multipart? + + each_range_part(file, range) do |part| + yield part + end + end + + yield "\r\n--#{MULTIPART_BOUNDARY}--\r\n" if multipart? + end + end + + def bytesize + size = ranges.inject(0) do |sum, range| + sum += multipart_heading(range).bytesize if multipart? + sum += range.size + end + size += "\r\n--#{MULTIPART_BOUNDARY}--\r\n".bytesize if multipart? + size + end + + def close; end + + private + + def multipart? + ranges.size > 1 + end + + def multipart_heading(range) +<<-EOF +\r +--#{MULTIPART_BOUNDARY}\r +content-type: #{options[:mime_type]}\r +content-range: bytes #{range.begin}-#{range.end}/#{options[:size]}\r +\r +EOF + end + + def each_range_part(file, range) + file.seek(range.begin) + remaining_len = range.end - range.begin + 1 + while remaining_len > 0 + part = file.read([8192, remaining_len].min) + break unless part + remaining_len -= part.length + + yield part + end + end + end + + class Iterator < BaseIterator + alias :to_path :path + end + + private + + def fail(status, body, headers = {}) + body += "\n" + + [ + status, + { + CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.size.to_s, + "x-cascade" => "pass" + }.merge!(headers), + [body] + ] + end + + # The MIME type for the contents of the file located at @path + def mime_type(path, default_mime) + Mime.mime_type(::File.extname(path), default_mime) + end + + def filesize(path) + # We check via File::size? whether this file provides size info + # via stat (e.g. /proc files often don't), otherwise we have to + # figure it out by reading the whole file into memory. + ::File.size?(path) || ::File.read(path).bytesize + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/head.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/head.rb new file mode 100644 index 0000000..c1c430f --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/head.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'body_proxy' + +module Rack + # Rack::Head returns an empty body for all HEAD requests. It leaves + # all other requests unchanged. + class Head + def initialize(app) + @app = app + end + + def call(env) + _, _, body = response = @app.call(env) + + if env[REQUEST_METHOD] == HEAD + response[2] = Rack::BodyProxy.new([]) do + body.close if body.respond_to? :close + end + end + + response + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/headers.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/headers.rb new file mode 100644 index 0000000..cedf3a8 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/headers.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +module Rack + # Rack::Headers is a Hash subclass that downcases all keys. It's designed + # to be used by rack applications that don't implement the Rack 3 SPEC + # (by using non-lowercase response header keys), automatically handling + # the downcasing of keys. + class Headers < Hash + KNOWN_HEADERS = {} + %w( + Accept-CH + Accept-Patch + Accept-Ranges + Access-Control-Allow-Credentials + Access-Control-Allow-Headers + Access-Control-Allow-Methods + Access-Control-Allow-Origin + Access-Control-Expose-Headers + Access-Control-Max-Age + Age + Allow + Alt-Svc + Cache-Control + Connection + Content-Disposition + Content-Encoding + Content-Language + Content-Length + Content-Location + Content-MD5 + Content-Range + Content-Security-Policy + Content-Security-Policy-Report-Only + Content-Type + Date + Delta-Base + ETag + Expect-CT + Expires + Feature-Policy + IM + Last-Modified + Link + Location + NEL + P3P + Permissions-Policy + Pragma + Preference-Applied + Proxy-Authenticate + Public-Key-Pins + Referrer-Policy + Refresh + Report-To + Retry-After + Server + Set-Cookie + Status + Strict-Transport-Security + Timing-Allow-Origin + Tk + Trailer + Transfer-Encoding + Upgrade + Vary + Via + WWW-Authenticate + Warning + X-Cascade + X-Content-Duration + X-Content-Security-Policy + X-Content-Type-Options + X-Correlation-ID + X-Correlation-Id + X-Download-Options + X-Frame-Options + X-Permitted-Cross-Domain-Policies + X-Powered-By + X-Redirect-By + X-Request-ID + X-Request-Id + X-Runtime + X-UA-Compatible + X-WebKit-CS + X-XSS-Protection + ).each do |str| + downcased = str.downcase.freeze + KNOWN_HEADERS[str] = KNOWN_HEADERS[downcased] = downcased + end + + def self.[](*items) + if items.length % 2 != 0 + if items.length == 1 && items.first.is_a?(Hash) + new.merge!(items.first) + else + raise ArgumentError, "odd number of arguments for Rack::Headers" + end + else + hash = new + loop do + break if items.length == 0 + key = items.shift + value = items.shift + hash[key] = value + end + hash + end + end + + def [](key) + super(downcase_key(key)) + end + + def []=(key, value) + super(KNOWN_HEADERS[key] || key.downcase.freeze, value) + end + alias store []= + + def assoc(key) + super(downcase_key(key)) + end + + def compare_by_identity + raise TypeError, "Rack::Headers cannot compare by identity, use regular Hash" + end + + def delete(key) + super(downcase_key(key)) + end + + def dig(key, *a) + super(downcase_key(key), *a) + end + + def fetch(key, *default, &block) + key = downcase_key(key) + super + end + + def fetch_values(*a) + super(*a.map!{|key| downcase_key(key)}) + end + + def has_key?(key) + super(downcase_key(key)) + end + alias include? has_key? + alias key? has_key? + alias member? has_key? + + def invert + hash = self.class.new + each{|key, value| hash[value] = key} + hash + end + + def merge(hash, &block) + dup.merge!(hash, &block) + end + + def reject(&block) + hash = dup + hash.reject!(&block) + hash + end + + def replace(hash) + clear + update(hash) + end + + def select(&block) + hash = dup + hash.select!(&block) + hash + end + + def to_proc + lambda{|x| self[x]} + end + + def transform_values(&block) + dup.transform_values!(&block) + end + + def update(hash, &block) + hash.each do |key, value| + self[key] = if block_given? && include?(key) + block.call(key, self[key], value) + else + value + end + end + self + end + alias merge! update + + def values_at(*keys) + keys.map{|key| self[key]} + end + + # :nocov: + if RUBY_VERSION >= '2.5' + # :nocov: + def slice(*a) + h = self.class.new + a.each{|k| h[k] = self[k] if has_key?(k)} + h + end + + def transform_keys(&block) + dup.transform_keys!(&block) + end + + def transform_keys! + hash = self.class.new + each do |k, v| + hash[yield k] = v + end + replace(hash) + end + end + + # :nocov: + if RUBY_VERSION >= '3.0' + # :nocov: + def except(*a) + super(*a.map!{|key| downcase_key(key)}) + end + end + + private + + def downcase_key(key) + key.is_a?(String) ? KNOWN_HEADERS[key] || key.downcase : key + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lint.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lint.rb new file mode 100644 index 0000000..4f36c2e --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lint.rb @@ -0,0 +1,991 @@ +# frozen_string_literal: true + +require 'forwardable' +require 'uri' + +require_relative 'constants' +require_relative 'utils' + +module Rack + # Rack::Lint validates your application and the requests and + # responses according to the Rack spec. + + class Lint + REQUEST_PATH_ORIGIN_FORM = /\A\/[^#]*\z/ + REQUEST_PATH_ABSOLUTE_FORM = /\A#{Utils::URI_PARSER.make_regexp}\z/ + REQUEST_PATH_AUTHORITY_FORM = /\A[^\/:]+:\d+\z/ + REQUEST_PATH_ASTERISK_FORM = '*' + + def initialize(app) + @app = app + end + + # :stopdoc: + + class LintError < RuntimeError; end + # AUTHORS: n.b. The trailing whitespace between paragraphs is important and + # should not be removed. The whitespace creates paragraphs in the RDoc + # output. + # + ## This specification aims to formalize the Rack protocol. You + ## can (and should) use Rack::Lint to enforce it. + ## + ## When you develop middleware, be sure to add a Lint before and + ## after to catch all mistakes. + ## + ## = Rack applications + ## + ## A Rack application is a Ruby object (not a class) that + ## responds to +call+. + def call(env = nil) + Wrapper.new(@app, env).response + end + + class Wrapper + def initialize(app, env) + @app = app + @env = env + @response = nil + @head_request = false + + @status = nil + @headers = nil + @body = nil + @invoked = nil + @content_length = nil + @closed = false + @size = 0 + end + + def response + ## It takes exactly one argument, the *environment* + raise LintError, "No env given" unless @env + check_environment(@env) + + ## and returns a non-frozen Array of exactly three values: + @response = @app.call(@env) + raise LintError, "response is not an Array, but #{@response.class}" unless @response.kind_of? Array + raise LintError, "response is frozen" if @response.frozen? + raise LintError, "response array has #{@response.size} elements instead of 3" unless @response.size == 3 + + @status, @headers, @body = @response + ## The *status*, + check_status(@status) + + ## the *headers*, + check_headers(@headers) + + hijack_proc = check_hijack_response(@headers, @env) + if hijack_proc + @headers[RACK_HIJACK] = hijack_proc + end + + ## and the *body*. + check_content_type_header(@status, @headers) + check_content_length_header(@status, @headers) + check_rack_protocol_header(@status, @headers) + @head_request = @env[REQUEST_METHOD] == HEAD + + @lint = (@env['rack.lint'] ||= []) << self + + if (@env['rack.lint.body_iteration'] ||= 0) > 0 + raise LintError, "Middleware must not call #each directly" + end + + return [@status, @headers, self] + end + + ## + ## == The Environment + ## + def check_environment(env) + ## The environment must be an unfrozen instance of Hash that includes + ## CGI-like headers. The Rack application is free to modify the + ## environment. + raise LintError, "env #{env.inspect} is not a Hash, but #{env.class}" unless env.kind_of? Hash + raise LintError, "env should not be frozen, but is" if env.frozen? + + ## + ## The environment is required to include these variables + ## (adopted from {PEP 333}[https://peps.python.org/pep-0333/]), except when they'd be empty, but see + ## below. + + ## REQUEST_METHOD:: The HTTP request method, such as + ## "GET" or "POST". This cannot ever + ## be an empty string, and so is + ## always required. + + ## SCRIPT_NAME:: The initial portion of the request + ## URL's "path" that corresponds to the + ## application object, so that the + ## application knows its virtual + ## "location". This may be an empty + ## string, if the application corresponds + ## to the "root" of the server. + + ## PATH_INFO:: The remainder of the request URL's + ## "path", designating the virtual + ## "location" of the request's target + ## within the application. This may be an + ## empty string, if the request URL targets + ## the application root and does not have a + ## trailing slash. This value may be + ## percent-encoded when originating from + ## a URL. + + ## QUERY_STRING:: The portion of the request URL that + ## follows the ?, if any. May be + ## empty, but is always required! + + ## SERVER_NAME:: When combined with SCRIPT_NAME and + ## PATH_INFO, these variables can be + ## used to complete the URL. Note, however, + ## that HTTP_HOST, if present, + ## should be used in preference to + ## SERVER_NAME for reconstructing + ## the request URL. + ## SERVER_NAME can never be an empty + ## string, and so is always required. + + ## SERVER_PORT:: An optional +Integer+ which is the port the + ## server is running on. Should be specified if + ## the server is running on a non-standard port. + + ## SERVER_PROTOCOL:: A string representing the HTTP version used + ## for the request. + + ## HTTP_ Variables:: Variables corresponding to the + ## client-supplied HTTP request + ## headers (i.e., variables whose + ## names begin with HTTP_). The + ## presence or absence of these + ## variables should correspond with + ## the presence or absence of the + ## appropriate HTTP header in the + ## request. See + ## {RFC3875 section 4.1.18}[https://tools.ietf.org/html/rfc3875#section-4.1.18] + ## for specific behavior. + + ## In addition to this, the Rack environment must include these + ## Rack-specific variables: + + ## rack.url_scheme:: +http+ or +https+, depending on the + ## request URL. + + ## rack.input:: See below, the input stream. + + ## rack.errors:: See below, the error stream. + + ## rack.hijack?:: See below, if present and true, indicates + ## that the server supports partial hijacking. + + ## rack.hijack:: See below, if present, an object responding + ## to +call+ that is used to perform a full + ## hijack. + + ## rack.protocol:: An optional +Array+ of +String+, containing + ## the protocols advertised by the client in + ## the +upgrade+ header (HTTP/1) or the + ## +:protocol+ pseudo-header (HTTP/2). + if protocols = @env['rack.protocol'] + unless protocols.is_a?(Array) && protocols.all?{|protocol| protocol.is_a?(String)} + raise LintError, "rack.protocol must be an Array of Strings" + end + end + + ## Additional environment specifications have approved to + ## standardized middleware APIs. None of these are required to + ## be implemented by the server. + + ## rack.session:: A hash-like interface for storing + ## request session data. + ## The store must implement: + if session = env[RACK_SESSION] + ## store(key, value) (aliased as []=); + unless session.respond_to?(:store) && session.respond_to?(:[]=) + raise LintError, "session #{session.inspect} must respond to store and []=" + end + + ## fetch(key, default = nil) (aliased as []); + unless session.respond_to?(:fetch) && session.respond_to?(:[]) + raise LintError, "session #{session.inspect} must respond to fetch and []" + end + + ## delete(key); + unless session.respond_to?(:delete) + raise LintError, "session #{session.inspect} must respond to delete" + end + + ## clear; + unless session.respond_to?(:clear) + raise LintError, "session #{session.inspect} must respond to clear" + end + + ## to_hash (returning unfrozen Hash instance); + unless session.respond_to?(:to_hash) && session.to_hash.kind_of?(Hash) && !session.to_hash.frozen? + raise LintError, "session #{session.inspect} must respond to to_hash and return unfrozen Hash instance" + end + end + + ## rack.logger:: A common object interface for logging messages. + ## The object must implement: + if logger = env[RACK_LOGGER] + ## info(message, &block) + unless logger.respond_to?(:info) + raise LintError, "logger #{logger.inspect} must respond to info" + end + + ## debug(message, &block) + unless logger.respond_to?(:debug) + raise LintError, "logger #{logger.inspect} must respond to debug" + end + + ## warn(message, &block) + unless logger.respond_to?(:warn) + raise LintError, "logger #{logger.inspect} must respond to warn" + end + + ## error(message, &block) + unless logger.respond_to?(:error) + raise LintError, "logger #{logger.inspect} must respond to error" + end + + ## fatal(message, &block) + unless logger.respond_to?(:fatal) + raise LintError, "logger #{logger.inspect} must respond to fatal" + end + end + + ## rack.multipart.buffer_size:: An Integer hint to the multipart parser as to what chunk size to use for reads and writes. + if bufsize = env[RACK_MULTIPART_BUFFER_SIZE] + unless bufsize.is_a?(Integer) && bufsize > 0 + raise LintError, "rack.multipart.buffer_size must be an Integer > 0 if specified" + end + end + + ## rack.multipart.tempfile_factory:: An object responding to #call with two arguments, the filename and content_type given for the multipart form field, and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate the tempfile for each multipart form file upload field, rather than the default class of Tempfile. + if tempfile_factory = env[RACK_MULTIPART_TEMPFILE_FACTORY] + raise LintError, "rack.multipart.tempfile_factory must respond to #call" unless tempfile_factory.respond_to?(:call) + env[RACK_MULTIPART_TEMPFILE_FACTORY] = lambda do |filename, content_type| + io = tempfile_factory.call(filename, content_type) + raise LintError, "rack.multipart.tempfile_factory return value must respond to #<<" unless io.respond_to?(:<<) + io + end + end + + ## The server or the application can store their own data in the + ## environment, too. The keys must contain at least one dot, + ## and should be prefixed uniquely. The prefix rack. + ## is reserved for use with the Rack core distribution and other + ## accepted specifications and must not be used otherwise. + ## + %w[REQUEST_METHOD SERVER_NAME QUERY_STRING SERVER_PROTOCOL rack.errors].each do |header| + raise LintError, "env missing required key #{header}" unless env.include? header + end + + ## The SERVER_PORT must be an Integer if set. + server_port = env["SERVER_PORT"] + unless server_port.nil? || (Integer(server_port) rescue false) + raise LintError, "env[SERVER_PORT] is not an Integer" + end + + ## The SERVER_NAME must be a valid authority as defined by RFC7540. + unless (URI.parse("http://#{env[SERVER_NAME]}/") rescue false) + raise LintError, "#{env[SERVER_NAME]} must be a valid authority" + end + + ## The HTTP_HOST must be a valid authority as defined by RFC7540. + unless (URI.parse("http://#{env[HTTP_HOST]}/") rescue false) + raise LintError, "#{env[HTTP_HOST]} must be a valid authority" + end + + ## The SERVER_PROTOCOL must match the regexp HTTP/\d(\.\d)?. + server_protocol = env['SERVER_PROTOCOL'] + unless %r{HTTP/\d(\.\d)?}.match?(server_protocol) + raise LintError, "env[SERVER_PROTOCOL] does not match HTTP/\\d(\\.\\d)?" + end + + ## The environment must not contain the keys + ## HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH + ## (use the versions without HTTP_). + %w[HTTP_CONTENT_TYPE HTTP_CONTENT_LENGTH].each { |header| + if env.include? header + raise LintError, "env contains #{header}, must use #{header[5..-1]}" + end + } + + ## The CGI keys (named without a period) must have String values. + ## If the string values for CGI keys contain non-ASCII characters, + ## they should use ASCII-8BIT encoding. + env.each { |key, value| + next if key.include? "." # Skip extensions + unless value.kind_of? String + raise LintError, "env variable #{key} has non-string value #{value.inspect}" + end + next if value.encoding == Encoding::ASCII_8BIT + unless value.b !~ /[\x80-\xff]/n + raise LintError, "env variable #{key} has value containing non-ASCII characters and has non-ASCII-8BIT encoding #{value.inspect} encoding: #{value.encoding}" + end + } + + ## There are the following restrictions: + + ## * rack.url_scheme must either be +http+ or +https+. + unless %w[http https].include?(env[RACK_URL_SCHEME]) + raise LintError, "rack.url_scheme unknown: #{env[RACK_URL_SCHEME].inspect}" + end + + ## * There may be a valid input stream in rack.input. + if rack_input = env[RACK_INPUT] + check_input_stream(rack_input) + @env[RACK_INPUT] = InputWrapper.new(rack_input) + end + + ## * There must be a valid error stream in rack.errors. + rack_errors = env[RACK_ERRORS] + check_error_stream(rack_errors) + @env[RACK_ERRORS] = ErrorWrapper.new(rack_errors) + + ## * There may be a valid hijack callback in rack.hijack + check_hijack env + ## * There may be a valid early hints callback in rack.early_hints + check_early_hints env + + ## * The REQUEST_METHOD must be a valid token. + unless env[REQUEST_METHOD] =~ /\A[0-9A-Za-z!\#$%&'*+.^_`|~-]+\z/ + raise LintError, "REQUEST_METHOD unknown: #{env[REQUEST_METHOD].dump}" + end + + ## * The SCRIPT_NAME, if non-empty, must start with / + if env.include?(SCRIPT_NAME) && env[SCRIPT_NAME] != "" && env[SCRIPT_NAME] !~ /\A\// + raise LintError, "SCRIPT_NAME must start with /" + end + + ## * The PATH_INFO, if provided, must be a valid request target or an empty string. + if env.include?(PATH_INFO) + case env[PATH_INFO] + when REQUEST_PATH_ASTERISK_FORM + ## * Only OPTIONS requests may have PATH_INFO set to * (asterisk-form). + unless env[REQUEST_METHOD] == OPTIONS + raise LintError, "Only OPTIONS requests may have PATH_INFO set to '*' (asterisk-form)" + end + when REQUEST_PATH_AUTHORITY_FORM + ## * Only CONNECT requests may have PATH_INFO set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. + unless env[REQUEST_METHOD] == CONNECT + raise LintError, "Only CONNECT requests may have PATH_INFO set to an authority (authority-form)" + end + when REQUEST_PATH_ABSOLUTE_FORM + ## * CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form). + if env[REQUEST_METHOD] == CONNECT || env[REQUEST_METHOD] == OPTIONS + raise LintError, "CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form)" + end + when REQUEST_PATH_ORIGIN_FORM + ## * Otherwise, PATH_INFO must start with a / and must not include a fragment part starting with '#' (origin-form). + when "" + # Empty string is okay. + else + raise LintError, "PATH_INFO must start with a '/' and must not include a fragment part starting with '#' (origin-form)" + end + end + + ## * The CONTENT_LENGTH, if given, must consist of digits only. + if env.include?("CONTENT_LENGTH") && env["CONTENT_LENGTH"] !~ /\A\d+\z/ + raise LintError, "Invalid CONTENT_LENGTH: #{env["CONTENT_LENGTH"]}" + end + + ## * One of SCRIPT_NAME or PATH_INFO must be + ## set. PATH_INFO should be / if + ## SCRIPT_NAME is empty. + unless env[SCRIPT_NAME] || env[PATH_INFO] + raise LintError, "One of SCRIPT_NAME or PATH_INFO must be set (make PATH_INFO '/' if SCRIPT_NAME is empty)" + end + ## SCRIPT_NAME never should be /, but instead be empty. + unless env[SCRIPT_NAME] != "/" + raise LintError, "SCRIPT_NAME cannot be '/', make it '' and PATH_INFO '/'" + end + + ## rack.response_finished:: An array of callables run by the server after the response has been + ## processed. This would typically be invoked after sending the response to the client, but it could also be + ## invoked if an error occurs while generating the response or sending the response; in that case, the error + ## argument will be a subclass of +Exception+. + ## The callables are invoked with +env, status, headers, error+ arguments and should not raise any + ## exceptions. They should be invoked in reverse order of registration. + if callables = env[RACK_RESPONSE_FINISHED] + raise LintError, "rack.response_finished must be an array of callable objects" unless callables.is_a?(Array) + + callables.each do |callable| + raise LintError, "rack.response_finished values must respond to call(env, status, headers, error)" unless callable.respond_to?(:call) + end + end + end + + ## + ## === The Input Stream + ## + ## The input stream is an IO-like object which contains the raw HTTP + ## POST data. + def check_input_stream(input) + ## When applicable, its external encoding must be "ASCII-8BIT" and it + ## must be opened in binary mode. + if input.respond_to?(:external_encoding) && input.external_encoding != Encoding::ASCII_8BIT + raise LintError, "rack.input #{input} does not have ASCII-8BIT as its external encoding" + end + if input.respond_to?(:binmode?) && !input.binmode? + raise LintError, "rack.input #{input} is not opened in binary mode" + end + + ## The input stream must respond to +gets+, +each+, and +read+. + [:gets, :each, :read].each { |method| + unless input.respond_to? method + raise LintError, "rack.input #{input} does not respond to ##{method}" + end + } + end + + class InputWrapper + def initialize(input) + @input = input + end + + ## * +gets+ must be called without arguments and return a string, + ## or +nil+ on EOF. + def gets(*args) + raise LintError, "rack.input#gets called with arguments" unless args.size == 0 + v = @input.gets + unless v.nil? or v.kind_of? String + raise LintError, "rack.input#gets didn't return a String" + end + v + end + + ## * +read+ behaves like IO#read. + ## Its signature is read([length, [buffer]]). + ## + ## If given, +length+ must be a non-negative Integer (>= 0) or +nil+, + ## and +buffer+ must be a String and may not be nil. + ## + ## If +length+ is given and not nil, then this method reads at most + ## +length+ bytes from the input stream. + ## + ## If +length+ is not given or nil, then this method reads + ## all data until EOF. + ## + ## When EOF is reached, this method returns nil if +length+ is given + ## and not nil, or "" if +length+ is not given or is nil. + ## + ## If +buffer+ is given, then the read data will be placed + ## into +buffer+ instead of a newly created String object. + def read(*args) + unless args.size <= 2 + raise LintError, "rack.input#read called with too many arguments" + end + if args.size >= 1 + unless args.first.kind_of?(Integer) || args.first.nil? + raise LintError, "rack.input#read called with non-integer and non-nil length" + end + unless args.first.nil? || args.first >= 0 + raise LintError, "rack.input#read called with a negative length" + end + end + if args.size >= 2 + unless args[1].kind_of?(String) + raise LintError, "rack.input#read called with non-String buffer" + end + end + + v = @input.read(*args) + + unless v.nil? or v.kind_of? String + raise LintError, "rack.input#read didn't return nil or a String" + end + if args[0].nil? + unless !v.nil? + raise LintError, "rack.input#read(nil) returned nil on EOF" + end + end + + v + end + + ## * +each+ must be called without arguments and only yield Strings. + def each(*args) + raise LintError, "rack.input#each called with arguments" unless args.size == 0 + @input.each { |line| + unless line.kind_of? String + raise LintError, "rack.input#each didn't yield a String" + end + yield line + } + end + + ## * +close+ can be called on the input stream to indicate that + ## any remaining input is not needed. + def close(*args) + @input.close(*args) + end + end + + ## + ## === The Error Stream + ## + def check_error_stream(error) + ## The error stream must respond to +puts+, +write+ and +flush+. + [:puts, :write, :flush].each { |method| + unless error.respond_to? method + raise LintError, "rack.error #{error} does not respond to ##{method}" + end + } + end + + class ErrorWrapper + def initialize(error) + @error = error + end + + ## * +puts+ must be called with a single argument that responds to +to_s+. + def puts(str) + @error.puts str + end + + ## * +write+ must be called with a single argument that is a String. + def write(str) + raise LintError, "rack.errors#write not called with a String" unless str.kind_of? String + @error.write str + end + + ## * +flush+ must be called without arguments and must be called + ## in order to make the error appear for sure. + def flush + @error.flush + end + + ## * +close+ must never be called on the error stream. + def close(*args) + raise LintError, "rack.errors#close must not be called" + end + end + + ## + ## === Hijacking + ## + ## The hijacking interfaces provides a means for an application to take + ## control of the HTTP connection. There are two distinct hijack + ## interfaces: full hijacking where the application takes over the raw + ## connection, and partial hijacking where the application takes over + ## just the response body stream. In both cases, the application is + ## responsible for closing the hijacked stream. + ## + ## Full hijacking only works with HTTP/1. Partial hijacking is functionally + ## equivalent to streaming bodies, and is still optionally supported for + ## backwards compatibility with older Rack versions. + ## + ## ==== Full Hijack + ## + ## Full hijack is used to completely take over an HTTP/1 connection. It + ## occurs before any headers are written and causes the request to + ## ignores any response generated by the application. + ## + ## It is intended to be used when applications need access to raw HTTP/1 + ## connection. + ## + def check_hijack(env) + ## If +rack.hijack+ is present in +env+, it must respond to +call+ + if original_hijack = env[RACK_HIJACK] + raise LintError, "rack.hijack must respond to call" unless original_hijack.respond_to?(:call) + + env[RACK_HIJACK] = proc do + io = original_hijack.call + + ## and return an +IO+ instance which can be used to read and write + ## to the underlying connection using HTTP/1 semantics and + ## formatting. + raise LintError, "rack.hijack must return an IO instance" unless io.is_a?(IO) + + io + end + end + end + + ## + ## ==== Partial Hijack + ## + ## Partial hijack is used for bi-directional streaming of the request and + ## response body. It occurs after the status and headers are written by + ## the server and causes the server to ignore the Body of the response. + ## + ## It is intended to be used when applications need bi-directional + ## streaming. + ## + def check_hijack_response(headers, env) + ## If +rack.hijack?+ is present in +env+ and truthy, + if env[RACK_IS_HIJACK] + ## an application may set the special response header +rack.hijack+ + if original_hijack = headers[RACK_HIJACK] + ## to an object that responds to +call+, + unless original_hijack.respond_to?(:call) + raise LintError, 'rack.hijack header must respond to #call' + end + ## accepting a +stream+ argument. + return proc do |io| + original_hijack.call StreamWrapper.new(io) + end + end + ## + ## After the response status and headers have been sent, this hijack + ## callback will be invoked with a +stream+ argument which follows the + ## same interface as outlined in "Streaming Body". Servers must + ## ignore the +body+ part of the response tuple when the + ## +rack.hijack+ response header is present. Using an empty +Array+ + ## instance is recommended. + else + ## + ## The special response header +rack.hijack+ must only be set + ## if the request +env+ has a truthy +rack.hijack?+. + if headers.key?(RACK_HIJACK) + raise LintError, 'rack.hijack header must not be present if server does not support hijacking' + end + end + + nil + end + + ## + ## === Early Hints + ## + ## The application or any middleware may call the rack.early_hints + ## with an object which would be valid as the headers of a Rack response. + def check_early_hints(env) + if env[RACK_EARLY_HINTS] + ## + ## If rack.early_hints is present, it must respond to #call. + unless env[RACK_EARLY_HINTS].respond_to?(:call) + raise LintError, "rack.early_hints must respond to call" + end + + original_callback = env[RACK_EARLY_HINTS] + env[RACK_EARLY_HINTS] = lambda do |headers| + ## If rack.early_hints is called, it must be called with + ## valid Rack response headers. + check_headers(headers) + original_callback.call(headers) + end + end + end + + ## + ## == The Response + ## + ## === The Status + ## + def check_status(status) + ## This is an HTTP status. It must be an Integer greater than or equal to + ## 100. + unless status.is_a?(Integer) && status >= 100 + raise LintError, "Status must be an Integer >=100" + end + end + + ## + ## === The Headers + ## + def check_headers(headers) + ## The headers must be a unfrozen Hash. + unless headers.kind_of?(Hash) + raise LintError, "headers object should be a hash, but isn't (got #{headers.class} as headers)" + end + + if headers.frozen? + raise LintError, "headers object should not be frozen, but is" + end + + headers.each do |key, value| + ## The header keys must be Strings. + unless key.kind_of? String + raise LintError, "header key must be a string, was #{key.class}" + end + + ## Special headers starting "rack." are for communicating with the + ## server, and must not be sent back to the client. + next if key.start_with?("rack.") + + ## The header must not contain a +Status+ key. + raise LintError, "header must not contain status" if key == "status" + ## Header keys must conform to RFC7230 token specification, i.e. cannot + ## contain non-printable ASCII, DQUOTE or "(),/:;<=>?@[\]{}". + raise LintError, "invalid header name: #{key}" if key =~ /[\(\),\/:;<=>\?@\[\\\]{}[:cntrl:]]/ + ## Header keys must not contain uppercase ASCII characters (A-Z). + raise LintError, "uppercase character in header name: #{key}" if key =~ /[A-Z]/ + + ## Header values must be either a String instance, + if value.kind_of?(String) + check_header_value(key, value) + elsif value.kind_of?(Array) + ## or an Array of String instances, + value.each{|value| check_header_value(key, value)} + else + raise LintError, "a header value must be a String or Array of Strings, but the value of '#{key}' is a #{value.class}" + end + end + end + + def check_header_value(key, value) + ## such that each String instance must not contain characters below 037. + if value =~ /[\000-\037]/ + raise LintError, "invalid header value #{key}: #{value.inspect}" + end + end + + ## + ## ==== The +content-type+ Header + ## + def check_content_type_header(status, headers) + headers.each { |key, value| + ## There must not be a content-type header key when the +Status+ is 1xx, + ## 204, or 304. + if key == "content-type" + if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i + raise LintError, "content-type header found in #{status} response, not allowed" + end + return + end + } + end + + ## + ## ==== The +content-length+ Header + ## + def check_content_length_header(status, headers) + headers.each { |key, value| + if key == 'content-length' + ## There must not be a content-length header key when the + ## +Status+ is 1xx, 204, or 304. + if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i + raise LintError, "content-length header found in #{status} response, not allowed" + end + @content_length = value + end + } + end + + def verify_content_length(size) + if @head_request + unless size == 0 + raise LintError, "Response body was given for HEAD request, but should be empty" + end + elsif @content_length + unless @content_length == size.to_s + raise LintError, "content-length header was #{@content_length}, but should be #{size}" + end + end + end + + ## + ## ==== The +rack.protocol+ Header + ## + def check_rack_protocol_header(status, headers) + ## If the +rack.protocol+ header is present, it must be a +String+, and + ## must be one of the values from the +rack.protocol+ array from the + ## environment. + protocol = headers['rack.protocol'] + + if protocol + request_protocols = @env['rack.protocol'] + + if request_protocols.nil? + raise LintError, "rack.protocol header is #{protocol.inspect}, but rack.protocol was not set in request!" + elsif !request_protocols.include?(protocol) + raise LintError, "rack.protocol header is #{protocol.inspect}, but should be one of #{request_protocols.inspect} from the request!" + end + end + end + ## + ## Setting this value informs the server that it should perform a + ## connection upgrade. In HTTP/1, this is done using the +upgrade+ + ## header. In HTTP/2, this is done by accepting the request. + ## + ## === The Body + ## + ## The Body is typically an +Array+ of +String+ instances, an enumerable + ## that yields +String+ instances, a +Proc+ instance, or a File-like + ## object. + ## + ## The Body must respond to +each+ or +call+. It may optionally respond + ## to +to_path+ or +to_ary+. A Body that responds to +each+ is considered + ## to be an Enumerable Body. A Body that responds to +call+ is considered + ## to be a Streaming Body. + ## + ## A Body that responds to both +each+ and +call+ must be treated as an + ## Enumerable Body, not a Streaming Body. If it responds to +each+, you + ## must call +each+ and not +call+. If the Body doesn't respond to + ## +each+, then you can assume it responds to +call+. + ## + ## The Body must either be consumed or returned. The Body is consumed by + ## optionally calling either +each+ or +call+. + ## Then, if the Body responds to +close+, it must be called to release + ## any resources associated with the generation of the body. + ## In other words, +close+ must always be called at least once; typically + ## after the web server has sent the response to the client, but also in + ## cases where the Rack application makes internal/virtual requests and + ## discards the response. + ## + def close + ## + ## After calling +close+, the Body is considered closed and should not + ## be consumed again. + @closed = true + + ## If the original Body is replaced by a new Body, the new Body must + ## also consume the original Body by calling +close+ if possible. + @body.close if @body.respond_to?(:close) + + index = @lint.index(self) + unless @env['rack.lint'][0..index].all? {|lint| lint.instance_variable_get(:@closed)} + raise LintError, "Body has not been closed" + end + end + + def verify_to_path + ## + ## If the Body responds to +to_path+, it must return a +String+ + ## path for the local file system whose contents are identical + ## to that produced by calling +each+; this may be used by the + ## server as an alternative, possibly more efficient way to + ## transport the response. The +to_path+ method does not consume + ## the body. + if @body.respond_to?(:to_path) + unless ::File.exist? @body.to_path + raise LintError, "The file identified by body.to_path does not exist" + end + end + end + + ## + ## ==== Enumerable Body + ## + def each + ## The Enumerable Body must respond to +each+. + raise LintError, "Enumerable Body must respond to each" unless @body.respond_to?(:each) + + ## It must only be called once. + raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? + + ## It must not be called after being closed, + raise LintError, "Response body is already closed" if @closed + + @invoked = :each + + @body.each do |chunk| + ## and must only yield String values. + unless chunk.kind_of? String + raise LintError, "Body yielded non-string value #{chunk.inspect}" + end + + ## + ## Middleware must not call +each+ directly on the Body. + ## Instead, middleware can return a new Body that calls +each+ on the + ## original Body, yielding at least once per iteration. + if @lint[0] == self + @env['rack.lint.body_iteration'] += 1 + else + if (@env['rack.lint.body_iteration'] -= 1) > 0 + raise LintError, "New body must yield at least once per iteration of old body" + end + end + + @size += chunk.bytesize + yield chunk + end + + verify_content_length(@size) + + verify_to_path + end + + BODY_METHODS = {to_ary: true, each: true, call: true, to_path: true} + + def to_path + @body.to_path + end + + def respond_to?(name, *) + if BODY_METHODS.key?(name) + @body.respond_to?(name) + else + super + end + end + + ## + ## If the Body responds to +to_ary+, it must return an +Array+ whose + ## contents are identical to that produced by calling +each+. + ## Middleware may call +to_ary+ directly on the Body and return a new + ## Body in its place. In other words, middleware can only process the + ## Body directly if it responds to +to_ary+. If the Body responds to both + ## +to_ary+ and +close+, its implementation of +to_ary+ must call + ## +close+. + def to_ary + @body.to_ary.tap do |content| + unless content == @body.enum_for.to_a + raise LintError, "#to_ary not identical to contents produced by calling #each" + end + end + ensure + close + end + + ## + ## ==== Streaming Body + ## + def call(stream) + ## The Streaming Body must respond to +call+. + raise LintError, "Streaming Body must respond to call" unless @body.respond_to?(:call) + + ## It must only be called once. + raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? + + ## It must not be called after being closed. + raise LintError, "Response body is already closed" if @closed + + @invoked = :call + + ## It takes a +stream+ argument. + ## + ## The +stream+ argument must implement: + ## read, write, <<, flush, close, close_read, close_write, closed? + ## + @body.call(StreamWrapper.new(stream)) + end + + class StreamWrapper + extend Forwardable + + ## The semantics of these IO methods must be a best effort match to + ## those of a normal Ruby IO or Socket object, using standard arguments + ## and raising standard exceptions. Servers are encouraged to simply + ## pass on real IO objects, although it is recognized that this approach + ## is not directly compatible with HTTP/2. + REQUIRED_METHODS = [ + :read, :write, :<<, :flush, :close, + :close_read, :close_write, :closed? + ] + + def_delegators :@stream, *REQUIRED_METHODS + + def initialize(stream) + @stream = stream + + REQUIRED_METHODS.each do |method_name| + raise LintError, "Stream must respond to #{method_name}" unless stream.respond_to?(method_name) + end + end + end + + # :startdoc: + end + end +end + +## +## == Thanks +## Some parts of this specification are adopted from {PEP 333 – Python Web Server Gateway Interface v1.0}[https://peps.python.org/pep-0333/] +## I'd like to thank everyone involved in that effort. diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lock.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lock.rb new file mode 100644 index 0000000..342123a --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lock.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative 'body_proxy' + +module Rack + # Rack::Lock locks every request inside a mutex, so that every request + # will effectively be executed synchronously. + class Lock + def initialize(app, mutex = Mutex.new) + @app, @mutex = app, mutex + end + + def call(env) + @mutex.lock + begin + response = @app.call(env) + returned = response << BodyProxy.new(response.pop) { unlock } + ensure + unlock unless returned + end + end + + private + + def unlock + @mutex.unlock + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/logger.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/logger.rb new file mode 100644 index 0000000..081212d --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/logger.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'logger' +require_relative 'constants' + +warn "Rack::Logger is deprecated and will be removed in Rack 3.2.", uplevel: 1 + +module Rack + # Sets up rack.logger to write to rack.errors stream + class Logger + def initialize(app, level = ::Logger::INFO) + @app, @level = app, level + end + + def call(env) + logger = ::Logger.new(env[RACK_ERRORS]) + logger.level = @level + + env[RACK_LOGGER] = logger + @app.call(env) + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/media_type.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/media_type.rb new file mode 100644 index 0000000..7fc1e39 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/media_type.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Rack + # Rack::MediaType parse media type and parameters out of content_type string + + class MediaType + SPLIT_PATTERN = /[;,]/ + + class << self + # The media type (type/subtype) portion of the CONTENT_TYPE header + # without any media type parameters. e.g., when CONTENT_TYPE is + # "text/plain;charset=utf-8", the media-type is "text/plain". + # + # For more information on the use of media types in HTTP, see: + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 + def type(content_type) + return nil unless content_type + if type = content_type.split(SPLIT_PATTERN, 2).first + type.rstrip! + type.downcase! + type + end + end + + # The media type parameters provided in CONTENT_TYPE as a Hash, or + # an empty Hash if no CONTENT_TYPE or media-type parameters were + # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", + # this method responds with the following Hash: + # { 'charset' => 'utf-8' } + def params(content_type) + return {} if content_type.nil? + + content_type.split(SPLIT_PATTERN)[1..-1].each_with_object({}) do |s, hsh| + s.strip! + k, v = s.split('=', 2) + k.downcase! + hsh[k] = strip_doublequotes(v) + end + end + + private + + def strip_doublequotes(str) + (str.start_with?('"') && str.end_with?('"')) ? str[1..-2] : str + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/method_override.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/method_override.rb new file mode 100644 index 0000000..6125b19 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/method_override.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'request' +require_relative 'utils' + +module Rack + class MethodOverride + HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK] + + METHOD_OVERRIDE_PARAM_KEY = "_method" + HTTP_METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE" + ALLOWED_METHODS = %w[POST] + + def initialize(app) + @app = app + end + + def call(env) + if allowed_methods.include?(env[REQUEST_METHOD]) + method = method_override(env) + if HTTP_METHODS.include?(method) + env[RACK_METHODOVERRIDE_ORIGINAL_METHOD] = env[REQUEST_METHOD] + env[REQUEST_METHOD] = method + end + end + + @app.call(env) + end + + def method_override(env) + req = Request.new(env) + method = method_override_param(req) || + env[HTTP_METHOD_OVERRIDE_HEADER] + begin + method.to_s.upcase + rescue ArgumentError + env[RACK_ERRORS].puts "Invalid string for method" + end + end + + private + + def allowed_methods + ALLOWED_METHODS + end + + def method_override_param(req) + req.POST[METHOD_OVERRIDE_PARAM_KEY] if req.form_data? || req.parseable_data? + rescue Utils::InvalidParameterError, Utils::ParameterTypeError, QueryParser::ParamsTooDeepError + req.get_header(RACK_ERRORS).puts "Invalid or incomplete POST params" + rescue EOFError + req.get_header(RACK_ERRORS).puts "Bad request content body" + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mime.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mime.rb new file mode 100644 index 0000000..0272968 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mime.rb @@ -0,0 +1,694 @@ +# frozen_string_literal: true + +module Rack + module Mime + # Returns String with mime type if found, otherwise use +fallback+. + # +ext+ should be filename extension in the '.ext' format that + # File.extname(file) returns. + # +fallback+ may be any object + # + # Also see the documentation for MIME_TYPES + # + # Usage: + # Rack::Mime.mime_type('.foo') + # + # This is a shortcut for: + # Rack::Mime::MIME_TYPES.fetch('.foo', 'application/octet-stream') + + def mime_type(ext, fallback = 'application/octet-stream') + MIME_TYPES.fetch(ext.to_s.downcase, fallback) + end + module_function :mime_type + + # Returns true if the given value is a mime match for the given mime match + # specification, false otherwise. + # + # Rack::Mime.match?('text/html', 'text/*') => true + # Rack::Mime.match?('text/plain', '*') => true + # Rack::Mime.match?('text/html', 'application/json') => false + + def match?(value, matcher) + v1, v2 = value.split('/', 2) + m1, m2 = matcher.split('/', 2) + + (m1 == '*' || v1 == m1) && (m2.nil? || m2 == '*' || m2 == v2) + end + module_function :match? + + # List of most common mime-types, selected various sources + # according to their usefulness in a webserving scope for Ruby + # users. + # + # To amend this list with your local mime.types list you can use: + # + # require 'webrick/httputils' + # list = WEBrick::HTTPUtils.load_mime_types('/etc/mime.types') + # Rack::Mime::MIME_TYPES.merge!(list) + # + # N.B. On Ubuntu the mime.types file does not include the leading period, so + # users may need to modify the data before merging into the hash. + + MIME_TYPES = { + ".123" => "application/vnd.lotus-1-2-3", + ".3dml" => "text/vnd.in3d.3dml", + ".3g2" => "video/3gpp2", + ".3gp" => "video/3gpp", + ".a" => "application/octet-stream", + ".acc" => "application/vnd.americandynamics.acc", + ".ace" => "application/x-ace-compressed", + ".acu" => "application/vnd.acucobol", + ".aep" => "application/vnd.audiograph", + ".afp" => "application/vnd.ibm.modcap", + ".ai" => "application/postscript", + ".aif" => "audio/x-aiff", + ".aiff" => "audio/x-aiff", + ".ami" => "application/vnd.amiga.ami", + ".apng" => "image/apng", + ".appcache" => "text/cache-manifest", + ".apr" => "application/vnd.lotus-approach", + ".asc" => "application/pgp-signature", + ".asf" => "video/x-ms-asf", + ".asm" => "text/x-asm", + ".aso" => "application/vnd.accpac.simply.aso", + ".asx" => "video/x-ms-asf", + ".atc" => "application/vnd.acucorp", + ".atom" => "application/atom+xml", + ".atomcat" => "application/atomcat+xml", + ".atomsvc" => "application/atomsvc+xml", + ".atx" => "application/vnd.antix.game-component", + ".au" => "audio/basic", + ".avi" => "video/x-msvideo", + ".avif" => "image/avif", + ".bat" => "application/x-msdownload", + ".bcpio" => "application/x-bcpio", + ".bdm" => "application/vnd.syncml.dm+wbxml", + ".bh2" => "application/vnd.fujitsu.oasysprs", + ".bin" => "application/octet-stream", + ".bmi" => "application/vnd.bmi", + ".bmp" => "image/bmp", + ".box" => "application/vnd.previewsystems.box", + ".btif" => "image/prs.btif", + ".bz" => "application/x-bzip", + ".bz2" => "application/x-bzip2", + ".c" => "text/x-c", + ".c4g" => "application/vnd.clonk.c4group", + ".cab" => "application/vnd.ms-cab-compressed", + ".cc" => "text/x-c", + ".ccxml" => "application/ccxml+xml", + ".cdbcmsg" => "application/vnd.contact.cmsg", + ".cdkey" => "application/vnd.mediastation.cdkey", + ".cdx" => "chemical/x-cdx", + ".cdxml" => "application/vnd.chemdraw+xml", + ".cdy" => "application/vnd.cinderella", + ".cer" => "application/pkix-cert", + ".cgm" => "image/cgm", + ".chat" => "application/x-chat", + ".chm" => "application/vnd.ms-htmlhelp", + ".chrt" => "application/vnd.kde.kchart", + ".cif" => "chemical/x-cif", + ".cii" => "application/vnd.anser-web-certificate-issue-initiation", + ".cil" => "application/vnd.ms-artgalry", + ".cla" => "application/vnd.claymore", + ".class" => "application/octet-stream", + ".clkk" => "application/vnd.crick.clicker.keyboard", + ".clkp" => "application/vnd.crick.clicker.palette", + ".clkt" => "application/vnd.crick.clicker.template", + ".clkw" => "application/vnd.crick.clicker.wordbank", + ".clkx" => "application/vnd.crick.clicker", + ".clp" => "application/x-msclip", + ".cmc" => "application/vnd.cosmocaller", + ".cmdf" => "chemical/x-cmdf", + ".cml" => "chemical/x-cml", + ".cmp" => "application/vnd.yellowriver-custom-menu", + ".cmx" => "image/x-cmx", + ".com" => "application/x-msdownload", + ".conf" => "text/plain", + ".cpio" => "application/x-cpio", + ".cpp" => "text/x-c", + ".cpt" => "application/mac-compactpro", + ".crd" => "application/x-mscardfile", + ".crl" => "application/pkix-crl", + ".crt" => "application/x-x509-ca-cert", + ".csh" => "application/x-csh", + ".csml" => "chemical/x-csml", + ".csp" => "application/vnd.commonspace", + ".css" => "text/css", + ".csv" => "text/csv", + ".curl" => "application/vnd.curl", + ".cww" => "application/prs.cww", + ".cxx" => "text/x-c", + ".daf" => "application/vnd.mobius.daf", + ".davmount" => "application/davmount+xml", + ".dcr" => "application/x-director", + ".dd2" => "application/vnd.oma.dd2+xml", + ".ddd" => "application/vnd.fujixerox.ddd", + ".deb" => "application/x-debian-package", + ".der" => "application/x-x509-ca-cert", + ".dfac" => "application/vnd.dreamfactory", + ".diff" => "text/x-diff", + ".dis" => "application/vnd.mobius.dis", + ".djv" => "image/vnd.djvu", + ".djvu" => "image/vnd.djvu", + ".dll" => "application/x-msdownload", + ".dmg" => "application/octet-stream", + ".dna" => "application/vnd.dna", + ".doc" => "application/msword", + ".docm" => "application/vnd.ms-word.document.macroEnabled.12", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".dot" => "application/msword", + ".dotm" => "application/vnd.ms-word.template.macroEnabled.12", + ".dotx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + ".dp" => "application/vnd.osgi.dp", + ".dpg" => "application/vnd.dpgraph", + ".dsc" => "text/prs.lines.tag", + ".dtd" => "application/xml-dtd", + ".dts" => "audio/vnd.dts", + ".dtshd" => "audio/vnd.dts.hd", + ".dv" => "video/x-dv", + ".dvi" => "application/x-dvi", + ".dwf" => "model/vnd.dwf", + ".dwg" => "image/vnd.dwg", + ".dxf" => "image/vnd.dxf", + ".dxp" => "application/vnd.spotfire.dxp", + ".ear" => "application/java-archive", + ".ecelp4800" => "audio/vnd.nuera.ecelp4800", + ".ecelp7470" => "audio/vnd.nuera.ecelp7470", + ".ecelp9600" => "audio/vnd.nuera.ecelp9600", + ".ecma" => "application/ecmascript", + ".edm" => "application/vnd.novadigm.edm", + ".edx" => "application/vnd.novadigm.edx", + ".efif" => "application/vnd.picsel", + ".ei6" => "application/vnd.pg.osasli", + ".eml" => "message/rfc822", + ".eol" => "audio/vnd.digital-winds", + ".eot" => "application/vnd.ms-fontobject", + ".eps" => "application/postscript", + ".es3" => "application/vnd.eszigno3+xml", + ".esf" => "application/vnd.epson.esf", + ".etx" => "text/x-setext", + ".exe" => "application/x-msdownload", + ".ext" => "application/vnd.novadigm.ext", + ".ez" => "application/andrew-inset", + ".ez2" => "application/vnd.ezpix-album", + ".ez3" => "application/vnd.ezpix-package", + ".f" => "text/x-fortran", + ".f77" => "text/x-fortran", + ".f90" => "text/x-fortran", + ".fbs" => "image/vnd.fastbidsheet", + ".fdf" => "application/vnd.fdf", + ".fe_launch" => "application/vnd.denovo.fcselayout-link", + ".fg5" => "application/vnd.fujitsu.oasysgp", + ".fli" => "video/x-fli", + ".flif" => "image/flif", + ".flo" => "application/vnd.micrografx.flo", + ".flv" => "video/x-flv", + ".flw" => "application/vnd.kde.kivio", + ".flx" => "text/vnd.fmi.flexstor", + ".fly" => "text/vnd.fly", + ".fm" => "application/vnd.framemaker", + ".fnc" => "application/vnd.frogans.fnc", + ".for" => "text/x-fortran", + ".fpx" => "image/vnd.fpx", + ".fsc" => "application/vnd.fsc.weblaunch", + ".fst" => "image/vnd.fst", + ".ftc" => "application/vnd.fluxtime.clip", + ".fti" => "application/vnd.anser-web-funds-transfer-initiation", + ".fvt" => "video/vnd.fvt", + ".fzs" => "application/vnd.fuzzysheet", + ".g3" => "image/g3fax", + ".gac" => "application/vnd.groove-account", + ".gdl" => "model/vnd.gdl", + ".gem" => "application/octet-stream", + ".gemspec" => "text/x-script.ruby", + ".ghf" => "application/vnd.groove-help", + ".gif" => "image/gif", + ".gim" => "application/vnd.groove-identity-message", + ".gmx" => "application/vnd.gmx", + ".gph" => "application/vnd.flographit", + ".gqf" => "application/vnd.grafeq", + ".gram" => "application/srgs", + ".grv" => "application/vnd.groove-injector", + ".grxml" => "application/srgs+xml", + ".gtar" => "application/x-gtar", + ".gtm" => "application/vnd.groove-tool-message", + ".gtw" => "model/vnd.gtw", + ".gv" => "text/vnd.graphviz", + ".gz" => "application/x-gzip", + ".h" => "text/x-c", + ".h261" => "video/h261", + ".h263" => "video/h263", + ".h264" => "video/h264", + ".hbci" => "application/vnd.hbci", + ".hdf" => "application/x-hdf", + ".heic" => "image/heic", + ".heics" => "image/heic-sequence", + ".heif" => "image/heif", + ".heifs" => "image/heif-sequence", + ".hh" => "text/x-c", + ".hlp" => "application/winhlp", + ".hpgl" => "application/vnd.hp-hpgl", + ".hpid" => "application/vnd.hp-hpid", + ".hps" => "application/vnd.hp-hps", + ".hqx" => "application/mac-binhex40", + ".htc" => "text/x-component", + ".htke" => "application/vnd.kenameaapp", + ".htm" => "text/html", + ".html" => "text/html", + ".hvd" => "application/vnd.yamaha.hv-dic", + ".hvp" => "application/vnd.yamaha.hv-voice", + ".hvs" => "application/vnd.yamaha.hv-script", + ".icc" => "application/vnd.iccprofile", + ".ice" => "x-conference/x-cooltalk", + ".ico" => "image/vnd.microsoft.icon", + ".ics" => "text/calendar", + ".ief" => "image/ief", + ".ifb" => "text/calendar", + ".ifm" => "application/vnd.shana.informed.formdata", + ".igl" => "application/vnd.igloader", + ".igs" => "model/iges", + ".igx" => "application/vnd.micrografx.igx", + ".iif" => "application/vnd.shana.informed.interchange", + ".imp" => "application/vnd.accpac.simply.imp", + ".ims" => "application/vnd.ms-ims", + ".ipk" => "application/vnd.shana.informed.package", + ".irm" => "application/vnd.ibm.rights-management", + ".irp" => "application/vnd.irepository.package+xml", + ".iso" => "application/octet-stream", + ".itp" => "application/vnd.shana.informed.formtemplate", + ".ivp" => "application/vnd.immervision-ivp", + ".ivu" => "application/vnd.immervision-ivu", + ".jad" => "text/vnd.sun.j2me.app-descriptor", + ".jam" => "application/vnd.jam", + ".jar" => "application/java-archive", + ".java" => "text/x-java-source", + ".jisp" => "application/vnd.jisp", + ".jlt" => "application/vnd.hp-jlyt", + ".jnlp" => "application/x-java-jnlp-file", + ".joda" => "application/vnd.joost.joda-archive", + ".jp2" => "image/jp2", + ".jpeg" => "image/jpeg", + ".jpg" => "image/jpeg", + ".jpgv" => "video/jpeg", + ".jpm" => "video/jpm", + ".js" => "text/javascript", + ".json" => "application/json", + ".karbon" => "application/vnd.kde.karbon", + ".kfo" => "application/vnd.kde.kformula", + ".kia" => "application/vnd.kidspiration", + ".kml" => "application/vnd.google-earth.kml+xml", + ".kmz" => "application/vnd.google-earth.kmz", + ".kne" => "application/vnd.kinar", + ".kon" => "application/vnd.kde.kontour", + ".kpr" => "application/vnd.kde.kpresenter", + ".ksp" => "application/vnd.kde.kspread", + ".ktz" => "application/vnd.kahootz", + ".kwd" => "application/vnd.kde.kword", + ".latex" => "application/x-latex", + ".lbd" => "application/vnd.llamagraphics.life-balance.desktop", + ".lbe" => "application/vnd.llamagraphics.life-balance.exchange+xml", + ".les" => "application/vnd.hhe.lesson-player", + ".link66" => "application/vnd.route66.link66+xml", + ".log" => "text/plain", + ".lostxml" => "application/lost+xml", + ".lrm" => "application/vnd.ms-lrm", + ".ltf" => "application/vnd.frogans.ltf", + ".lvp" => "audio/vnd.lucent.voice", + ".lwp" => "application/vnd.lotus-wordpro", + ".m3u" => "audio/x-mpegurl", + ".m3u8" => "application/x-mpegurl", + ".m4a" => "audio/mp4a-latm", + ".m4v" => "video/mp4", + ".ma" => "application/mathematica", + ".mag" => "application/vnd.ecowin.chart", + ".man" => "text/troff", + ".manifest" => "text/cache-manifest", + ".mathml" => "application/mathml+xml", + ".mbk" => "application/vnd.mobius.mbk", + ".mbox" => "application/mbox", + ".mc1" => "application/vnd.medcalcdata", + ".mcd" => "application/vnd.mcd", + ".mdb" => "application/x-msaccess", + ".mdi" => "image/vnd.ms-modi", + ".mdoc" => "text/troff", + ".me" => "text/troff", + ".mfm" => "application/vnd.mfmp", + ".mgz" => "application/vnd.proteus.magazine", + ".mid" => "audio/midi", + ".midi" => "audio/midi", + ".mif" => "application/vnd.mif", + ".mime" => "message/rfc822", + ".mj2" => "video/mj2", + ".mjs" => "text/javascript", + ".mlp" => "application/vnd.dolby.mlp", + ".mmd" => "application/vnd.chipnuts.karaoke-mmd", + ".mmf" => "application/vnd.smaf", + ".mml" => "application/mathml+xml", + ".mmr" => "image/vnd.fujixerox.edmics-mmr", + ".mng" => "video/x-mng", + ".mny" => "application/x-msmoney", + ".mov" => "video/quicktime", + ".movie" => "video/x-sgi-movie", + ".mp3" => "audio/mpeg", + ".mp4" => "video/mp4", + ".mp4a" => "audio/mp4", + ".mp4s" => "application/mp4", + ".mp4v" => "video/mp4", + ".mpc" => "application/vnd.mophun.certificate", + ".mpd" => "application/dash+xml", + ".mpeg" => "video/mpeg", + ".mpg" => "video/mpeg", + ".mpga" => "audio/mpeg", + ".mpkg" => "application/vnd.apple.installer+xml", + ".mpm" => "application/vnd.blueice.multipass", + ".mpn" => "application/vnd.mophun.application", + ".mpp" => "application/vnd.ms-project", + ".mpy" => "application/vnd.ibm.minipay", + ".mqy" => "application/vnd.mobius.mqy", + ".mrc" => "application/marc", + ".ms" => "text/troff", + ".mscml" => "application/mediaservercontrol+xml", + ".mseq" => "application/vnd.mseq", + ".msf" => "application/vnd.epson.msf", + ".msh" => "model/mesh", + ".msi" => "application/x-msdownload", + ".msl" => "application/vnd.mobius.msl", + ".msty" => "application/vnd.muvee.style", + ".mts" => "model/vnd.mts", + ".mus" => "application/vnd.musician", + ".mvb" => "application/x-msmediaview", + ".mwf" => "application/vnd.mfer", + ".mxf" => "application/mxf", + ".mxl" => "application/vnd.recordare.musicxml", + ".mxml" => "application/xv+xml", + ".mxs" => "application/vnd.triscape.mxs", + ".mxu" => "video/vnd.mpegurl", + ".n" => "application/vnd.nokia.n-gage.symbian.install", + ".nc" => "application/x-netcdf", + ".ngdat" => "application/vnd.nokia.n-gage.data", + ".nlu" => "application/vnd.neurolanguage.nlu", + ".nml" => "application/vnd.enliven", + ".nnd" => "application/vnd.noblenet-directory", + ".nns" => "application/vnd.noblenet-sealer", + ".nnw" => "application/vnd.noblenet-web", + ".npx" => "image/vnd.net-fpx", + ".nsf" => "application/vnd.lotus-notes", + ".oa2" => "application/vnd.fujitsu.oasys2", + ".oa3" => "application/vnd.fujitsu.oasys3", + ".oas" => "application/vnd.fujitsu.oasys", + ".obd" => "application/x-msbinder", + ".oda" => "application/oda", + ".odc" => "application/vnd.oasis.opendocument.chart", + ".odf" => "application/vnd.oasis.opendocument.formula", + ".odg" => "application/vnd.oasis.opendocument.graphics", + ".odi" => "application/vnd.oasis.opendocument.image", + ".odp" => "application/vnd.oasis.opendocument.presentation", + ".ods" => "application/vnd.oasis.opendocument.spreadsheet", + ".odt" => "application/vnd.oasis.opendocument.text", + ".oga" => "audio/ogg", + ".ogg" => "application/ogg", + ".ogv" => "video/ogg", + ".ogx" => "application/ogg", + ".org" => "application/vnd.lotus-organizer", + ".otc" => "application/vnd.oasis.opendocument.chart-template", + ".otf" => "font/otf", + ".otg" => "application/vnd.oasis.opendocument.graphics-template", + ".oth" => "application/vnd.oasis.opendocument.text-web", + ".oti" => "application/vnd.oasis.opendocument.image-template", + ".otm" => "application/vnd.oasis.opendocument.text-master", + ".ots" => "application/vnd.oasis.opendocument.spreadsheet-template", + ".ott" => "application/vnd.oasis.opendocument.text-template", + ".oxt" => "application/vnd.openofficeorg.extension", + ".p" => "text/x-pascal", + ".p10" => "application/pkcs10", + ".p12" => "application/x-pkcs12", + ".p7b" => "application/x-pkcs7-certificates", + ".p7m" => "application/pkcs7-mime", + ".p7r" => "application/x-pkcs7-certreqresp", + ".p7s" => "application/pkcs7-signature", + ".pas" => "text/x-pascal", + ".pbd" => "application/vnd.powerbuilder6", + ".pbm" => "image/x-portable-bitmap", + ".pcl" => "application/vnd.hp-pcl", + ".pclxl" => "application/vnd.hp-pclxl", + ".pcx" => "image/x-pcx", + ".pdb" => "chemical/x-pdb", + ".pdf" => "application/pdf", + ".pem" => "application/x-x509-ca-cert", + ".pfr" => "application/font-tdpfr", + ".pgm" => "image/x-portable-graymap", + ".pgn" => "application/x-chess-pgn", + ".pgp" => "application/pgp-encrypted", + ".pic" => "image/x-pict", + ".pict" => "image/pict", + ".pkg" => "application/octet-stream", + ".pki" => "application/pkixcmp", + ".pkipath" => "application/pkix-pkipath", + ".pl" => "text/x-script.perl", + ".plb" => "application/vnd.3gpp.pic-bw-large", + ".plc" => "application/vnd.mobius.plc", + ".plf" => "application/vnd.pocketlearn", + ".pls" => "application/pls+xml", + ".pm" => "text/x-script.perl-module", + ".pml" => "application/vnd.ctc-posml", + ".png" => "image/png", + ".pnm" => "image/x-portable-anymap", + ".pntg" => "image/x-macpaint", + ".portpkg" => "application/vnd.macports.portpkg", + ".pot" => "application/vnd.ms-powerpoint", + ".potm" => "application/vnd.ms-powerpoint.template.macroEnabled.12", + ".potx" => "application/vnd.openxmlformats-officedocument.presentationml.template", + ".ppa" => "application/vnd.ms-powerpoint", + ".ppam" => "application/vnd.ms-powerpoint.addin.macroEnabled.12", + ".ppd" => "application/vnd.cups-ppd", + ".ppm" => "image/x-portable-pixmap", + ".pps" => "application/vnd.ms-powerpoint", + ".ppsm" => "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", + ".ppsx" => "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + ".ppt" => "application/vnd.ms-powerpoint", + ".pptm" => "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".prc" => "application/vnd.palm", + ".pre" => "application/vnd.lotus-freelance", + ".prf" => "application/pics-rules", + ".ps" => "application/postscript", + ".psb" => "application/vnd.3gpp.pic-bw-small", + ".psd" => "image/vnd.adobe.photoshop", + ".ptid" => "application/vnd.pvi.ptid1", + ".pub" => "application/x-mspublisher", + ".pvb" => "application/vnd.3gpp.pic-bw-var", + ".pwn" => "application/vnd.3m.post-it-notes", + ".py" => "text/x-script.python", + ".pya" => "audio/vnd.ms-playready.media.pya", + ".pyv" => "video/vnd.ms-playready.media.pyv", + ".qam" => "application/vnd.epson.quickanime", + ".qbo" => "application/vnd.intu.qbo", + ".qfx" => "application/vnd.intu.qfx", + ".qps" => "application/vnd.publishare-delta-tree", + ".qt" => "video/quicktime", + ".qtif" => "image/x-quicktime", + ".qxd" => "application/vnd.quark.quarkxpress", + ".ra" => "audio/x-pn-realaudio", + ".rake" => "text/x-script.ruby", + ".ram" => "audio/x-pn-realaudio", + ".rar" => "application/x-rar-compressed", + ".ras" => "image/x-cmu-raster", + ".rb" => "text/x-script.ruby", + ".rcprofile" => "application/vnd.ipunplugged.rcprofile", + ".rdf" => "application/rdf+xml", + ".rdz" => "application/vnd.data-vision.rdz", + ".rep" => "application/vnd.businessobjects", + ".rgb" => "image/x-rgb", + ".rif" => "application/reginfo+xml", + ".rl" => "application/resource-lists+xml", + ".rlc" => "image/vnd.fujixerox.edmics-rlc", + ".rld" => "application/resource-lists-diff+xml", + ".rm" => "application/vnd.rn-realmedia", + ".rmp" => "audio/x-pn-realaudio-plugin", + ".rms" => "application/vnd.jcp.javame.midlet-rms", + ".rnc" => "application/relax-ng-compact-syntax", + ".roff" => "text/troff", + ".rpm" => "application/x-redhat-package-manager", + ".rpss" => "application/vnd.nokia.radio-presets", + ".rpst" => "application/vnd.nokia.radio-preset", + ".rq" => "application/sparql-query", + ".rs" => "application/rls-services+xml", + ".rsd" => "application/rsd+xml", + ".rss" => "application/rss+xml", + ".rtf" => "application/rtf", + ".rtx" => "text/richtext", + ".ru" => "text/x-script.ruby", + ".s" => "text/x-asm", + ".saf" => "application/vnd.yamaha.smaf-audio", + ".sbml" => "application/sbml+xml", + ".sc" => "application/vnd.ibm.secure-container", + ".scd" => "application/x-msschedule", + ".scm" => "application/vnd.lotus-screencam", + ".scq" => "application/scvp-cv-request", + ".scs" => "application/scvp-cv-response", + ".sdkm" => "application/vnd.solent.sdkm+xml", + ".sdp" => "application/sdp", + ".see" => "application/vnd.seemail", + ".sema" => "application/vnd.sema", + ".semd" => "application/vnd.semd", + ".semf" => "application/vnd.semf", + ".setpay" => "application/set-payment-initiation", + ".setreg" => "application/set-registration-initiation", + ".sfd" => "application/vnd.hydrostatix.sof-data", + ".sfs" => "application/vnd.spotfire.sfs", + ".sgm" => "text/sgml", + ".sgml" => "text/sgml", + ".sh" => "application/x-sh", + ".shar" => "application/x-shar", + ".shf" => "application/shf+xml", + ".sig" => "application/pgp-signature", + ".sit" => "application/x-stuffit", + ".sitx" => "application/x-stuffitx", + ".skp" => "application/vnd.koan", + ".slt" => "application/vnd.epson.salt", + ".smi" => "application/smil+xml", + ".snd" => "audio/basic", + ".so" => "application/octet-stream", + ".spf" => "application/vnd.yamaha.smaf-phrase", + ".spl" => "application/x-futuresplash", + ".spot" => "text/vnd.in3d.spot", + ".spp" => "application/scvp-vp-response", + ".spq" => "application/scvp-vp-request", + ".src" => "application/x-wais-source", + ".srt" => "text/srt", + ".srx" => "application/sparql-results+xml", + ".sse" => "application/vnd.kodak-descriptor", + ".ssf" => "application/vnd.epson.ssf", + ".ssml" => "application/ssml+xml", + ".stf" => "application/vnd.wt.stf", + ".stk" => "application/hyperstudio", + ".str" => "application/vnd.pg.format", + ".sus" => "application/vnd.sus-calendar", + ".sv4cpio" => "application/x-sv4cpio", + ".sv4crc" => "application/x-sv4crc", + ".svd" => "application/vnd.svd", + ".svg" => "image/svg+xml", + ".svgz" => "image/svg+xml", + ".swf" => "application/x-shockwave-flash", + ".swi" => "application/vnd.arastra.swi", + ".t" => "text/troff", + ".tao" => "application/vnd.tao.intent-module-archive", + ".tar" => "application/x-tar", + ".tbz" => "application/x-bzip-compressed-tar", + ".tcap" => "application/vnd.3gpp2.tcap", + ".tcl" => "application/x-tcl", + ".tex" => "application/x-tex", + ".texi" => "application/x-texinfo", + ".texinfo" => "application/x-texinfo", + ".text" => "text/plain", + ".tif" => "image/tiff", + ".tiff" => "image/tiff", + ".tmo" => "application/vnd.tmobile-livetv", + ".torrent" => "application/x-bittorrent", + ".tpl" => "application/vnd.groove-tool-template", + ".tpt" => "application/vnd.trid.tpt", + ".tr" => "text/troff", + ".tra" => "application/vnd.trueapp", + ".trm" => "application/x-msterminal", + ".ts" => "video/mp2t", + ".tsv" => "text/tab-separated-values", + ".ttf" => "font/ttf", + ".twd" => "application/vnd.simtech-mindmapper", + ".txd" => "application/vnd.genomatix.tuxedo", + ".txf" => "application/vnd.mobius.txf", + ".txt" => "text/plain", + ".ufd" => "application/vnd.ufdl", + ".umj" => "application/vnd.umajin", + ".unityweb" => "application/vnd.unity", + ".uoml" => "application/vnd.uoml+xml", + ".uri" => "text/uri-list", + ".ustar" => "application/x-ustar", + ".utz" => "application/vnd.uiq.theme", + ".uu" => "text/x-uuencode", + ".vcd" => "application/x-cdlink", + ".vcf" => "text/x-vcard", + ".vcg" => "application/vnd.groove-vcard", + ".vcs" => "text/x-vcalendar", + ".vcx" => "application/vnd.vcx", + ".vis" => "application/vnd.visionary", + ".viv" => "video/vnd.vivo", + ".vrml" => "model/vrml", + ".vsd" => "application/vnd.visio", + ".vsf" => "application/vnd.vsf", + ".vtt" => "text/vtt", + ".vtu" => "model/vnd.vtu", + ".vxml" => "application/voicexml+xml", + ".war" => "application/java-archive", + ".wasm" => "application/wasm", + ".wav" => "audio/x-wav", + ".wax" => "audio/x-ms-wax", + ".wbmp" => "image/vnd.wap.wbmp", + ".wbs" => "application/vnd.criticaltools.wbs+xml", + ".wbxml" => "application/vnd.wap.wbxml", + ".webm" => "video/webm", + ".webp" => "image/webp", + ".wm" => "video/x-ms-wm", + ".wma" => "audio/x-ms-wma", + ".wmd" => "application/x-ms-wmd", + ".wmf" => "application/x-msmetafile", + ".wml" => "text/vnd.wap.wml", + ".wmlc" => "application/vnd.wap.wmlc", + ".wmls" => "text/vnd.wap.wmlscript", + ".wmlsc" => "application/vnd.wap.wmlscriptc", + ".wmv" => "video/x-ms-wmv", + ".wmx" => "video/x-ms-wmx", + ".wmz" => "application/x-ms-wmz", + ".woff" => "font/woff", + ".woff2" => "font/woff2", + ".wpd" => "application/vnd.wordperfect", + ".wpl" => "application/vnd.ms-wpl", + ".wps" => "application/vnd.ms-works", + ".wqd" => "application/vnd.wqd", + ".wri" => "application/x-mswrite", + ".wrl" => "model/vrml", + ".wsdl" => "application/wsdl+xml", + ".wspolicy" => "application/wspolicy+xml", + ".wtb" => "application/vnd.webturbo", + ".wvx" => "video/x-ms-wvx", + ".x3d" => "application/vnd.hzn-3d-crossword", + ".xar" => "application/vnd.xara", + ".xbd" => "application/vnd.fujixerox.docuworks.binder", + ".xbm" => "image/x-xbitmap", + ".xdm" => "application/vnd.syncml.dm+xml", + ".xdp" => "application/vnd.adobe.xdp+xml", + ".xdw" => "application/vnd.fujixerox.docuworks", + ".xenc" => "application/xenc+xml", + ".xer" => "application/patch-ops-error+xml", + ".xfdf" => "application/vnd.adobe.xfdf", + ".xfdl" => "application/vnd.xfdl", + ".xhtml" => "application/xhtml+xml", + ".xif" => "image/vnd.xiff", + ".xla" => "application/vnd.ms-excel", + ".xlam" => "application/vnd.ms-excel.addin.macroEnabled.12", + ".xls" => "application/vnd.ms-excel", + ".xlsb" => "application/vnd.ms-excel.sheet.binary.macroEnabled.12", + ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xlsm" => "application/vnd.ms-excel.sheet.macroEnabled.12", + ".xlt" => "application/vnd.ms-excel", + ".xltx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + ".xml" => "application/xml", + ".xo" => "application/vnd.olpc-sugar", + ".xop" => "application/xop+xml", + ".xpm" => "image/x-xpixmap", + ".xpr" => "application/vnd.is-xpr", + ".xps" => "application/vnd.ms-xpsdocument", + ".xpw" => "application/vnd.intercon.formnet", + ".xsl" => "application/xml", + ".xslt" => "application/xslt+xml", + ".xsm" => "application/vnd.syncml+xml", + ".xspf" => "application/xspf+xml", + ".xul" => "application/vnd.mozilla.xul+xml", + ".xwd" => "image/x-xwindowdump", + ".xyz" => "chemical/x-xyz", + ".yaml" => "text/yaml", + ".yml" => "text/yaml", + ".zaz" => "application/vnd.zzazz.deck+xml", + ".zip" => "application/zip", + ".zmm" => "application/vnd.handheld-entertainment+xml", + } + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock.rb new file mode 100644 index 0000000..5e5c457 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative 'mock_request' diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_request.rb new file mode 100644 index 0000000..7c87bea --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_request.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'uri' +require 'stringio' + +require_relative 'constants' +require_relative 'mock_response' + +module Rack + # Rack::MockRequest helps testing your Rack application without + # actually using HTTP. + # + # After performing a request on a URL with get/post/put/patch/delete, it + # returns a MockResponse with useful helper methods for effective + # testing. + # + # You can pass a hash with additional configuration to the + # get/post/put/patch/delete. + # :input:: A String or IO-like to be used as rack.input. + # :fatal:: Raise a FatalWarning if the app writes to rack.errors. + # :lint:: If true, wrap the application in a Rack::Lint. + + class MockRequest + class FatalWarning < RuntimeError + end + + class FatalWarner + def puts(warning) + raise FatalWarning, warning + end + + def write(warning) + raise FatalWarning, warning + end + + def flush + end + + def string + "" + end + end + + def initialize(app) + @app = app + end + + # Make a GET request and return a MockResponse. See #request. + def get(uri, opts = {}) request(GET, uri, opts) end + # Make a POST request and return a MockResponse. See #request. + def post(uri, opts = {}) request(POST, uri, opts) end + # Make a PUT request and return a MockResponse. See #request. + def put(uri, opts = {}) request(PUT, uri, opts) end + # Make a PATCH request and return a MockResponse. See #request. + def patch(uri, opts = {}) request(PATCH, uri, opts) end + # Make a DELETE request and return a MockResponse. See #request. + def delete(uri, opts = {}) request(DELETE, uri, opts) end + # Make a HEAD request and return a MockResponse. See #request. + def head(uri, opts = {}) request(HEAD, uri, opts) end + # Make an OPTIONS request and return a MockResponse. See #request. + def options(uri, opts = {}) request(OPTIONS, uri, opts) end + + # Make a request using the given request method for the given + # uri to the rack application and return a MockResponse. + # Options given are passed to MockRequest.env_for. + def request(method = GET, uri = "", opts = {}) + env = self.class.env_for(uri, opts.merge(method: method)) + + if opts[:lint] + app = Rack::Lint.new(@app) + else + app = @app + end + + errors = env[RACK_ERRORS] + status, headers, body = app.call(env) + MockResponse.new(status, headers, body, errors) + ensure + body.close if body.respond_to?(:close) + end + + # For historical reasons, we're pinning to RFC 2396. + # URI::Parser = URI::RFC2396_Parser + def self.parse_uri_rfc2396(uri) + @parser ||= URI::Parser.new + @parser.parse(uri) + end + + # Return the Rack environment used for a request to +uri+. + # All options that are strings are added to the returned environment. + # Options: + # :fatal :: Whether to raise an exception if request outputs to rack.errors + # :input :: The rack.input to set + # :http_version :: The SERVER_PROTOCOL to set + # :method :: The HTTP request method to use + # :params :: The params to use + # :script_name :: The SCRIPT_NAME to set + def self.env_for(uri = "", opts = {}) + uri = parse_uri_rfc2396(uri) + uri.path = "/#{uri.path}" unless uri.path[0] == ?/ + + env = {} + + env[REQUEST_METHOD] = (opts[:method] ? opts[:method].to_s.upcase : GET).b + env[SERVER_NAME] = (uri.host || "example.org").b + env[SERVER_PORT] = (uri.port ? uri.port.to_s : "80").b + env[SERVER_PROTOCOL] = opts[:http_version] || 'HTTP/1.1' + env[QUERY_STRING] = (uri.query.to_s).b + env[PATH_INFO] = (uri.path).b + env[RACK_URL_SCHEME] = (uri.scheme || "http").b + env[HTTPS] = (env[RACK_URL_SCHEME] == "https" ? "on" : "off").b + + env[SCRIPT_NAME] = opts[:script_name] || "" + + if opts[:fatal] + env[RACK_ERRORS] = FatalWarner.new + else + env[RACK_ERRORS] = StringIO.new + end + + if params = opts[:params] + if env[REQUEST_METHOD] == GET + params = Utils.parse_nested_query(params) if params.is_a?(String) + params.update(Utils.parse_nested_query(env[QUERY_STRING])) + env[QUERY_STRING] = Utils.build_nested_query(params) + elsif !opts.has_key?(:input) + opts["CONTENT_TYPE"] = "application/x-www-form-urlencoded" + if params.is_a?(Hash) + if data = Rack::Multipart.build_multipart(params) + opts[:input] = data + opts["CONTENT_LENGTH"] ||= data.length.to_s + opts["CONTENT_TYPE"] = "multipart/form-data; boundary=#{Rack::Multipart::MULTIPART_BOUNDARY}" + else + opts[:input] = Utils.build_nested_query(params) + end + else + opts[:input] = params + end + end + end + + rack_input = opts[:input] + if String === rack_input + rack_input = StringIO.new(rack_input) + end + + if rack_input + rack_input.set_encoding(Encoding::BINARY) if rack_input.respond_to?(:set_encoding) + env[RACK_INPUT] = rack_input + + env["CONTENT_LENGTH"] ||= env[RACK_INPUT].size.to_s if env[RACK_INPUT].respond_to?(:size) + end + + opts.each { |field, value| + env[field] = value if String === field + } + + env + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_response.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_response.rb new file mode 100644 index 0000000..9af8079 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_response.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'cgi/cookie' +require 'time' + +require_relative 'response' + +module Rack + # Rack::MockResponse provides useful helpers for testing your apps. + # Usually, you don't create the MockResponse on your own, but use + # MockRequest. + + class MockResponse < Rack::Response + class << self + alias [] new + end + + # Headers + attr_reader :original_headers, :cookies + + # Errors + attr_accessor :errors + + def initialize(status, headers, body, errors = nil) + @original_headers = headers + + if errors + @errors = errors.string if errors.respond_to?(:string) + else + @errors = "" + end + + super(body, status, headers) + + @cookies = parse_cookies_from_header + buffered_body! + end + + def =~(other) + body =~ other + end + + def match(other) + body.match other + end + + def body + return @buffered_body if defined?(@buffered_body) + + # FIXME: apparently users of MockResponse expect the return value of + # MockResponse#body to be a string. However, the real response object + # returns the body as a list. + # + # See spec_showstatus.rb: + # + # should "not replace existing messages" do + # ... + # res.body.should == "foo!" + # end + buffer = @buffered_body = String.new + + @body.each do |chunk| + buffer << chunk + end + + return buffer + end + + def empty? + [201, 204, 304].include? status + end + + def cookie(name) + cookies.fetch(name, nil) + end + + private + + def parse_cookies_from_header + cookies = Hash.new + set_cookie_header = headers['set-cookie'] + if set_cookie_header && !set_cookie_header.empty? + Array(set_cookie_header).each do |cookie| + cookie_name, cookie_filling = cookie.split('=', 2) + cookie_attributes = identify_cookie_attributes cookie_filling + parsed_cookie = CGI::Cookie.new( + 'name' => cookie_name.strip, + 'value' => cookie_attributes.fetch('value'), + 'path' => cookie_attributes.fetch('path', nil), + 'domain' => cookie_attributes.fetch('domain', nil), + 'expires' => cookie_attributes.fetch('expires', nil), + 'secure' => cookie_attributes.fetch('secure', false) + ) + cookies.store(cookie_name, parsed_cookie) + end + end + cookies + end + + def identify_cookie_attributes(cookie_filling) + cookie_bits = cookie_filling.split(';') + cookie_attributes = Hash.new + cookie_attributes.store('value', cookie_bits[0].strip) + cookie_bits.drop(1).each do |bit| + if bit.include? '=' + cookie_attribute, attribute_value = bit.split('=', 2) + cookie_attributes.store(cookie_attribute.strip.downcase, attribute_value.strip) + end + if bit.include? 'secure' + cookie_attributes.store('secure', true) + end + end + + if cookie_attributes.key? 'max-age' + cookie_attributes.store('expires', Time.now + cookie_attributes['max-age'].to_i) + elsif cookie_attributes.key? 'expires' + cookie_attributes.store('expires', Time.httpdate(cookie_attributes['expires'])) + end + + cookie_attributes + end + + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart.rb new file mode 100644 index 0000000..4b02fb3 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' + +require_relative 'multipart/parser' +require_relative 'multipart/generator' + +require_relative 'bad_request' + +module Rack + # A multipart form data parser, adapted from IOWA. + # + # Usually, Rack::Request#POST takes care of calling this. + module Multipart + MULTIPART_BOUNDARY = "AaB03x" + + class MissingInputError < StandardError + include BadRequest + end + + # Accumulator for multipart form data, conforming to the QueryParser API. + # In future, the Parser could return the pair list directly, but that would + # change its API. + class ParamList # :nodoc: + def self.make_params + new + end + + def self.normalize_params(params, key, value) + params << [key, value] + end + + def initialize + @pairs = [] + end + + def <<(pair) + @pairs << pair + end + + def to_params_hash + @pairs + end + end + + class << self + def parse_multipart(env, params = Rack::Utils.default_query_parser) + unless io = env[RACK_INPUT] + raise MissingInputError, "Missing input stream!" + end + + if content_length = env['CONTENT_LENGTH'] + content_length = content_length.to_i + end + + content_type = env['CONTENT_TYPE'] + + tempfile = env[RACK_MULTIPART_TEMPFILE_FACTORY] || Parser::TEMPFILE_FACTORY + bufsize = env[RACK_MULTIPART_BUFFER_SIZE] || Parser::BUFSIZE + + info = Parser.parse(io, content_length, content_type, tempfile, bufsize, params) + env[RACK_TEMPFILES] = info.tmp_files + + return info.params + end + + def extract_multipart(request, params = Rack::Utils.default_query_parser) + parse_multipart(request.env) + end + + def build_multipart(params, first = true) + Generator.new(params, first).dump + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb new file mode 100644 index 0000000..30d7f51 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require_relative 'uploaded_file' + +module Rack + module Multipart + class Generator + def initialize(params, first = true) + @params, @first = params, first + + if @first && !@params.is_a?(Hash) + raise ArgumentError, "value must be a Hash" + end + end + + def dump + return nil if @first && !multipart? + return flattened_params unless @first + + flattened_params.map do |name, file| + if file.respond_to?(:original_filename) + if file.path + ::File.open(file.path, 'rb') do |f| + f.set_encoding(Encoding::BINARY) + content_for_tempfile(f, file, name) + end + else + content_for_tempfile(file, file, name) + end + else + content_for_other(file, name) + end + end.join << "--#{MULTIPART_BOUNDARY}--\r" + end + + private + def multipart? + query = lambda { |value| + case value + when Array + value.any?(&query) + when Hash + value.values.any?(&query) + when Rack::Multipart::UploadedFile + true + end + } + + @params.values.any?(&query) + end + + def flattened_params + @flattened_params ||= begin + h = Hash.new + @params.each do |key, value| + k = @first ? key.to_s : "[#{key}]" + + case value + when Array + value.map { |v| + Multipart.build_multipart(v, false).each { |subkey, subvalue| + h["#{k}[]#{subkey}"] = subvalue + } + } + when Hash + Multipart.build_multipart(value, false).each { |subkey, subvalue| + h[k + subkey] = subvalue + } + else + h[k] = value + end + end + h + end + end + + def content_for_tempfile(io, file, name) + length = ::File.stat(file.path).size if file.path + filename = "; filename=\"#{Utils.escape_path(file.original_filename)}\"" +<<-EOF +--#{MULTIPART_BOUNDARY}\r +content-disposition: form-data; name="#{name}"#{filename}\r +content-type: #{file.content_type}\r +#{"content-length: #{length}\r\n" if length}\r +#{io.read}\r +EOF + end + + def content_for_other(file, name) +<<-EOF +--#{MULTIPART_BOUNDARY}\r +content-disposition: form-data; name="#{name}"\r +\r +#{file}\r +EOF + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb new file mode 100644 index 0000000..3960b37 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb @@ -0,0 +1,502 @@ +# frozen_string_literal: true + +require 'strscan' + +require_relative '../utils' +require_relative '../bad_request' + +module Rack + module Multipart + class MultipartPartLimitError < Errno::EMFILE + include BadRequest + end + + class MultipartTotalPartLimitError < StandardError + include BadRequest + end + + # Use specific error class when parsing multipart request + # that ends early. + class EmptyContentError < ::EOFError + include BadRequest + end + + # Base class for multipart exceptions that do not subclass from + # other exception classes for backwards compatibility. + class BoundaryTooLongError < StandardError + include BadRequest + end + + # Prefer to use the BoundaryTooLongError class or Rack::BadRequest. + Error = BoundaryTooLongError + + EOL = "\r\n" + MULTIPART = %r|\Amultipart/.*boundary=\"?([^\";,]+)\"?|ni + MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{EOL}/ni + MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:(.*)(?=#{EOL}(\S|\z))/ni + MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{EOL}]*)/ni + + class Parser + BUFSIZE = 1_048_576 + TEXT_PLAIN = "text/plain" + TEMPFILE_FACTORY = lambda { |filename, content_type| + extension = ::File.extname(filename.gsub("\0", '%00'))[0, 129] + + Tempfile.new(["RackMultipart", extension]) + } + + class BoundedIO # :nodoc: + def initialize(io, content_length) + @io = io + @content_length = content_length + @cursor = 0 + end + + def read(size, outbuf = nil) + return if @cursor >= @content_length + + left = @content_length - @cursor + + str = if left < size + @io.read left, outbuf + else + @io.read size, outbuf + end + + if str + @cursor += str.bytesize + else + # Raise an error for mismatching content-length and actual contents + raise EOFError, "bad content body" + end + + str + end + end + + MultipartInfo = Struct.new :params, :tmp_files + EMPTY = MultipartInfo.new(nil, []) + + def self.parse_boundary(content_type) + return unless content_type + data = content_type.match(MULTIPART) + return unless data + data[1] + end + + def self.parse(io, content_length, content_type, tmpfile, bufsize, qp) + return EMPTY if 0 == content_length + + boundary = parse_boundary content_type + return EMPTY unless boundary + + if boundary.length > 70 + # RFC 1521 Section 7.2.1 imposes a 70 character maximum for the boundary. + # Most clients use no more than 55 characters. + raise BoundaryTooLongError, "multipart boundary size too large (#{boundary.length} characters)" + end + + io = BoundedIO.new(io, content_length) if content_length + + parser = new(boundary, tmpfile, bufsize, qp) + parser.parse(io) + + parser.result + end + + class Collector + class MimePart < Struct.new(:body, :head, :filename, :content_type, :name) + def get_data + data = body + if filename == "" + # filename is blank which means no file has been selected + return + elsif filename + body.rewind if body.respond_to?(:rewind) + + # Take the basename of the upload's original filename. + # This handles the full Windows paths given by Internet Explorer + # (and perhaps other broken user agents) without affecting + # those which give the lone filename. + fn = filename.split(/[\/\\]/).last + + data = { filename: fn, type: content_type, + name: name, tempfile: body, head: head } + end + + yield data + end + end + + class BufferPart < MimePart + def file?; false; end + def close; end + end + + class TempfilePart < MimePart + def file?; true; end + def close; body.close; end + end + + include Enumerable + + def initialize(tempfile) + @tempfile = tempfile + @mime_parts = [] + @open_files = 0 + end + + def each + @mime_parts.each { |part| yield part } + end + + def on_mime_head(mime_index, head, filename, content_type, name) + if filename + body = @tempfile.call(filename, content_type) + body.binmode if body.respond_to?(:binmode) + klass = TempfilePart + @open_files += 1 + else + body = String.new + klass = BufferPart + end + + @mime_parts[mime_index] = klass.new(body, head, filename, content_type, name) + + check_part_limits + end + + def on_mime_body(mime_index, content) + @mime_parts[mime_index].body << content + end + + def on_mime_finish(mime_index) + end + + private + + def check_part_limits + file_limit = Utils.multipart_file_limit + part_limit = Utils.multipart_total_part_limit + + if file_limit && file_limit > 0 + if @open_files >= file_limit + @mime_parts.each(&:close) + raise MultipartPartLimitError, 'Maximum file multiparts in content reached' + end + end + + if part_limit && part_limit > 0 + if @mime_parts.size >= part_limit + @mime_parts.each(&:close) + raise MultipartTotalPartLimitError, 'Maximum total multiparts in content reached' + end + end + end + end + + attr_reader :state + + def initialize(boundary, tempfile, bufsize, query_parser) + @query_parser = query_parser + @params = query_parser.make_params + @bufsize = bufsize + + @state = :FAST_FORWARD + @mime_index = 0 + @collector = Collector.new tempfile + + @sbuf = StringScanner.new("".dup) + @body_regex = /(?:#{EOL}|\A)--#{Regexp.quote(boundary)}(?:#{EOL}|--)/m + @body_regex_at_end = /#{@body_regex}\z/m + @end_boundary_size = boundary.bytesize + 4 # (-- at start, -- at finish) + @rx_max_size = boundary.bytesize + 6 # (\r\n-- at start, either \r\n or -- at finish) + @head_regex = /(.*?#{EOL})#{EOL}/m + end + + def parse(io) + outbuf = String.new + read_data(io, outbuf) + + loop do + status = + case @state + when :FAST_FORWARD + handle_fast_forward + when :CONSUME_TOKEN + handle_consume_token + when :MIME_HEAD + handle_mime_head + when :MIME_BODY + handle_mime_body + else # when :DONE + return + end + + read_data(io, outbuf) if status == :want_read + end + end + + def result + @collector.each do |part| + part.get_data do |data| + tag_multipart_encoding(part.filename, part.content_type, part.name, data) + @query_parser.normalize_params(@params, part.name, data) + end + end + MultipartInfo.new @params.to_params_hash, @collector.find_all(&:file?).map(&:body) + end + + private + + def dequote(str) # From WEBrick::HTTPUtils + ret = (/\A"(.*)"\Z/ =~ str) ? $1 : str.dup + ret.gsub!(/\\(.)/, "\\1") + ret + end + + def read_data(io, outbuf) + content = io.read(@bufsize, outbuf) + handle_empty_content!(content) + @sbuf.concat(content) + end + + # This handles the initial parser state. We read until we find the starting + # boundary, then we can transition to the next state. If we find the ending + # boundary, this is an invalid multipart upload, but keep scanning for opening + # boundary in that case. If no boundary found, we need to keep reading data + # and retry. It's highly unlikely the initial read will not consume the + # boundary. The client would have to deliberately craft a response + # with the opening boundary beyond the buffer size for that to happen. + def handle_fast_forward + while true + case consume_boundary + when :BOUNDARY + # found opening boundary, transition to next state + @state = :MIME_HEAD + return + when :END_BOUNDARY + # invalid multipart upload + if @sbuf.pos == @end_boundary_size && @sbuf.rest == EOL + # stop parsing a buffer if a buffer is only an end boundary. + @state = :DONE + return + end + + # retry for opening boundary + else + # no boundary found, keep reading data + return :want_read + end + end + end + + def handle_consume_token + tok = consume_boundary + # break if we're at the end of a buffer, but not if it is the end of a field + @state = if tok == :END_BOUNDARY || (@sbuf.eos? && tok != :BOUNDARY) + :DONE + else + :MIME_HEAD + end + end + + CONTENT_DISPOSITION_MAX_PARAMS = 16 + CONTENT_DISPOSITION_MAX_BYTES = 1536 + def handle_mime_head + if @sbuf.scan_until(@head_regex) + head = @sbuf[1] + content_type = head[MULTIPART_CONTENT_TYPE, 1] + if (disposition = head[MULTIPART_CONTENT_DISPOSITION, 1]) && + disposition.bytesize <= CONTENT_DISPOSITION_MAX_BYTES + + # ignore actual content-disposition value (should always be form-data) + i = disposition.index(';') + disposition.slice!(0, i+1) + param = nil + num_params = 0 + + # Parse parameter list + while i = disposition.index('=') + # Only parse up to max parameters, to avoid potential denial of service + num_params += 1 + break if num_params > CONTENT_DISPOSITION_MAX_PARAMS + + # Found end of parameter name, ensure forward progress in loop + param = disposition.slice!(0, i+1) + + # Remove ending equals and preceding whitespace from parameter name + param.chomp!('=') + param.lstrip! + + if disposition[0] == '"' + # Parameter value is quoted, parse it, handling backslash escapes + disposition.slice!(0, 1) + value = String.new + + while i = disposition.index(/(["\\])/) + c = $1 + + # Append all content until ending quote or escape + value << disposition.slice!(0, i) + + # Remove either backslash or ending quote, + # ensures forward progress in loop + disposition.slice!(0, 1) + + # stop parsing parameter value if found ending quote + break if c == '"' + + escaped_char = disposition.slice!(0, 1) + if param == 'filename' && escaped_char != '"' + # Possible IE uploaded filename, append both escape backslash and value + value << c << escaped_char + else + # Other only append escaped value + value << escaped_char + end + end + else + if i = disposition.index(';') + # Parameter value unquoted (which may be invalid), value ends at semicolon + value = disposition.slice!(0, i) + else + # If no ending semicolon, assume remainder of line is value and stop + # parsing + disposition.strip! + value = disposition + disposition = '' + end + end + + case param + when 'name' + name = value + when 'filename' + filename = value + when 'filename*' + filename_star = value + # else + # ignore other parameters + end + + # skip trailing semicolon, to proceed to next parameter + if i = disposition.index(';') + disposition.slice!(0, i+1) + end + end + else + name = head[MULTIPART_CONTENT_ID, 1] + end + + if filename_star + encoding, _, filename = filename_star.split("'", 3) + filename = normalize_filename(filename || '') + filename.force_encoding(find_encoding(encoding)) + elsif filename + filename = normalize_filename(filename) + end + + if name.nil? || name.empty? + name = filename || "#{content_type || TEXT_PLAIN}[]".dup + end + + @collector.on_mime_head @mime_index, head, filename, content_type, name + @state = :MIME_BODY + else + :want_read + end + end + + def handle_mime_body + if (body_with_boundary = @sbuf.check_until(@body_regex)) # check but do not advance the pointer yet + body = body_with_boundary.sub(@body_regex_at_end, '') # remove the boundary from the string + @collector.on_mime_body @mime_index, body + @sbuf.pos += body.length + 2 # skip \r\n after the content + @state = :CONSUME_TOKEN + @mime_index += 1 + else + # Save what we have so far + if @rx_max_size < @sbuf.rest_size + delta = @sbuf.rest_size - @rx_max_size + @collector.on_mime_body @mime_index, @sbuf.peek(delta) + @sbuf.pos += delta + @sbuf.string = @sbuf.rest + end + :want_read + end + end + + # Scan until the we find the start or end of the boundary. + # If we find it, return the appropriate symbol for the start or + # end of the boundary. If we don't find the start or end of the + # boundary, clear the buffer and return nil. + def consume_boundary + if read_buffer = @sbuf.scan_until(@body_regex) + read_buffer.end_with?(EOL) ? :BOUNDARY : :END_BOUNDARY + else + @sbuf.terminate + nil + end + end + + def normalize_filename(filename) + if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) } + filename = Utils.unescape_path(filename) + end + + filename.scrub! + + filename.split(/[\/\\]/).last || String.new + end + + CHARSET = "charset" + deprecate_constant :CHARSET + + def tag_multipart_encoding(filename, content_type, name, body) + name = name.to_s + encoding = Encoding::UTF_8 + + name.force_encoding(encoding) + + return if filename + + if content_type + list = content_type.split(';') + type_subtype = list.first + type_subtype.strip! + if TEXT_PLAIN == type_subtype + rest = list.drop 1 + rest.each do |param| + k, v = param.split('=', 2) + k.strip! + v.strip! + v = v[1..-2] if v.start_with?('"') && v.end_with?('"') + if k == "charset" + encoding = find_encoding(v) + end + end + end + end + + name.force_encoding(encoding) + body.force_encoding(encoding) + end + + # Return the related Encoding object. However, because + # enc is submitted by the user, it may be invalid, so + # use a binary encoding in that case. + def find_encoding(enc) + Encoding.find enc + rescue ArgumentError + Encoding::BINARY + end + + def handle_empty_content!(content) + if content.nil? || content.empty? + raise EmptyContentError + end + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb new file mode 100644 index 0000000..2782e44 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'tempfile' +require 'fileutils' + +module Rack + module Multipart + class UploadedFile + + # The filename, *not* including the path, of the "uploaded" file + attr_reader :original_filename + + # The content type of the "uploaded" file + attr_accessor :content_type + + def initialize(filepath = nil, ct = "text/plain", bin = false, + path: filepath, content_type: ct, binary: bin, filename: nil, io: nil) + if io + @tempfile = io + @original_filename = filename + else + raise "#{path} file does not exist" unless ::File.exist?(path) + @original_filename = filename || ::File.basename(path) + @tempfile = Tempfile.new([@original_filename, ::File.extname(path)], encoding: Encoding::BINARY) + @tempfile.binmode if binary + FileUtils.copy_file(path, @tempfile.path) + end + @content_type = content_type + end + + def path + @tempfile.path if @tempfile.respond_to?(:path) + end + alias_method :local_path, :path + + def respond_to?(*args) + super or @tempfile.respond_to?(*args) + end + + def method_missing(method_name, *args, &block) #:nodoc: + @tempfile.__send__(method_name, *args, &block) + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/null_logger.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/null_logger.rb new file mode 100644 index 0000000..52fc125 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/null_logger.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative 'constants' + +module Rack + class NullLogger + def initialize(app) + @app = app + end + + def call(env) + env[RACK_LOGGER] = self + @app.call(env) + end + + def info(progname = nil, &block); end + def debug(progname = nil, &block); end + def warn(progname = nil, &block); end + def error(progname = nil, &block); end + def fatal(progname = nil, &block); end + def unknown(progname = nil, &block); end + def info? ; end + def debug? ; end + def warn? ; end + def error? ; end + def fatal? ; end + def debug! ; end + def error! ; end + def fatal! ; end + def info! ; end + def warn! ; end + def level ; end + def progname ; end + def datetime_format ; end + def formatter ; end + def sev_threshold ; end + def level=(level); end + def progname=(progname); end + def datetime_format=(datetime_format); end + def formatter=(formatter); end + def sev_threshold=(sev_threshold); end + def close ; end + def add(severity, message = nil, progname = nil, &block); end + def log(severity, message = nil, progname = nil, &block); end + def <<(msg); end + def reopen(logdev = nil); end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/query_parser.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/query_parser.rb new file mode 100644 index 0000000..28cbce1 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/query_parser.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require_relative 'bad_request' +require 'uri' + +module Rack + class QueryParser + DEFAULT_SEP = /& */n + COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n } + + # ParameterTypeError is the error that is raised when incoming structural + # parameters (parsed by parse_nested_query) contain conflicting types. + class ParameterTypeError < TypeError + include BadRequest + end + + # InvalidParameterError is the error that is raised when incoming structural + # parameters (parsed by parse_nested_query) contain invalid format or byte + # sequence. + class InvalidParameterError < ArgumentError + include BadRequest + end + + # ParamsTooDeepError is the error that is raised when params are recursively + # nested over the specified limit. + class ParamsTooDeepError < RangeError + include BadRequest + end + + def self.make_default(param_depth_limit) + new Params, param_depth_limit + end + + attr_reader :param_depth_limit + + def initialize(params_class, param_depth_limit) + @params_class = params_class + @param_depth_limit = param_depth_limit + end + + # Stolen from Mongrel, with some small modifications: + # Parses a query string by breaking it up at the '&'. You can also use this + # to parse cookies by changing the characters used in the second parameter + # (which defaults to '&'). + def parse_query(qs, separator = nil, &unescaper) + unescaper ||= method(:unescape) + + params = make_params + + (qs || '').split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p| + next if p.empty? + k, v = p.split('=', 2).map!(&unescaper) + + if cur = params[k] + if cur.class == Array + params[k] << v + else + params[k] = [cur, v] + end + else + params[k] = v + end + end + + return params.to_h + end + + # parse_nested_query expands a query string into structural types. Supported + # types are Arrays, Hashes and basic value types. It is possible to supply + # query strings with parameters of conflicting types, in this case a + # ParameterTypeError is raised. Users are encouraged to return a 400 in this + # case. + def parse_nested_query(qs, separator = nil) + params = make_params + + unless qs.nil? || qs.empty? + (qs || '').split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p| + k, v = p.split('=', 2).map! { |s| unescape(s) } + + _normalize_params(params, k, v, 0) + end + end + + return params.to_h + rescue ArgumentError => e + raise InvalidParameterError, e.message, e.backtrace + end + + # normalize_params recursively expands parameters into structural types. If + # the structural types represented by two different parameter names are in + # conflict, a ParameterTypeError is raised. The depth argument is deprecated + # and should no longer be used, it is kept for backwards compatibility with + # earlier versions of rack. + def normalize_params(params, name, v, _depth=nil) + _normalize_params(params, name, v, 0) + end + + private def _normalize_params(params, name, v, depth) + raise ParamsTooDeepError if depth >= param_depth_limit + + if !name + # nil name, treat same as empty string (required by tests) + k = after = '' + elsif depth == 0 + # Start of parsing, don't treat [] or [ at start of string specially + if start = name.index('[', 1) + # Start of parameter nesting, use part before brackets as key + k = name[0, start] + after = name[start, name.length] + else + # Plain parameter with no nesting + k = name + after = '' + end + elsif name.start_with?('[]') + # Array nesting + k = '[]' + after = name[2, name.length] + elsif name.start_with?('[') && (start = name.index(']', 1)) + # Hash nesting, use the part inside brackets as the key + k = name[1, start-1] + after = name[start+1, name.length] + else + # Probably malformed input, nested but not starting with [ + # treat full name as key for backwards compatibility. + k = name + after = '' + end + + return if k.empty? + + if after == '' + if k == '[]' && depth != 0 + return [v] + else + params[k] = v + end + elsif after == "[" + params[name] = v + elsif after == "[]" + params[k] ||= [] + raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) + params[k] << v + elsif after.start_with?('[]') + # Recognize x[][y] (hash inside array) parameters + unless after[2] == '[' && after.end_with?(']') && (child_key = after[3, after.length-4]) && !child_key.empty? && !child_key.index('[') && !child_key.index(']') + # Handle other nested array parameters + child_key = after[2, after.length] + end + params[k] ||= [] + raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) + if params_hash_type?(params[k].last) && !params_hash_has_key?(params[k].last, child_key) + _normalize_params(params[k].last, child_key, v, depth + 1) + else + params[k] << _normalize_params(make_params, child_key, v, depth + 1) + end + else + params[k] ||= make_params + raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k]) + params[k] = _normalize_params(params[k], after, v, depth + 1) + end + + params + end + + def make_params + @params_class.new + end + + def new_depth_limit(param_depth_limit) + self.class.new @params_class, param_depth_limit + end + + private + + def params_hash_type?(obj) + obj.kind_of?(@params_class) + end + + def params_hash_has_key?(hash, key) + return false if /\[\]/.match?(key) + + key.split(/[\[\]]+/).inject(hash) do |h, part| + next h if part == '' + return false unless params_hash_type?(h) && h.key?(part) + h[part] + end + + true + end + + def unescape(string, encoding = Encoding::UTF_8) + URI.decode_www_form_component(string, encoding) + end + + class Params < Hash + alias_method :to_params_hash, :to_h + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/recursive.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/recursive.rb new file mode 100644 index 0000000..0945d32 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/recursive.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'uri' + +require_relative 'constants' + +module Rack + # Rack::ForwardRequest gets caught by Rack::Recursive and redirects + # the current request to the app at +url+. + # + # raise ForwardRequest.new("/not-found") + # + + class ForwardRequest < Exception + attr_reader :url, :env + + def initialize(url, env = {}) + @url = URI(url) + @env = env + + @env[PATH_INFO] = @url.path + @env[QUERY_STRING] = @url.query if @url.query + @env[HTTP_HOST] = @url.host if @url.host + @env[HTTP_PORT] = @url.port if @url.port + @env[RACK_URL_SCHEME] = @url.scheme if @url.scheme + + super "forwarding to #{url}" + end + end + + # Rack::Recursive allows applications called down the chain to + # include data from other applications (by using + # rack['rack.recursive.include'][...] or raise a + # ForwardRequest to redirect internally. + + class Recursive + def initialize(app) + @app = app + end + + def call(env) + dup._call(env) + end + + def _call(env) + @script_name = env[SCRIPT_NAME] + @app.call(env.merge(RACK_RECURSIVE_INCLUDE => method(:include))) + rescue ForwardRequest => req + call(env.merge(req.env)) + end + + def include(env, path) + unless path.index(@script_name) == 0 && (path[@script_name.size] == ?/ || + path[@script_name.size].nil?) + raise ArgumentError, "can only include below #{@script_name}, not #{path}" + end + + env = env.merge(PATH_INFO => path, + SCRIPT_NAME => @script_name, + REQUEST_METHOD => GET, + "CONTENT_LENGTH" => "0", "CONTENT_TYPE" => "", + RACK_INPUT => StringIO.new("")) + @app.call(env) + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/reloader.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/reloader.rb new file mode 100644 index 0000000..a15064a --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/reloader.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# Copyright (C) 2009-2018 Michael Fellinger +# Rack::Reloader is subject to the terms of an MIT-style license. +# See MIT-LICENSE or https://opensource.org/licenses/MIT. + +require 'pathname' + +module Rack + + # High performant source reloader + # + # This class acts as Rack middleware. + # + # What makes it especially suited for use in a production environment is that + # any file will only be checked once and there will only be made one system + # call stat(2). + # + # Please note that this will not reload files in the background, it does so + # only when actively called. + # + # It is performing a check/reload cycle at the start of every request, but + # also respects a cool down time, during which nothing will be done. + class Reloader + def initialize(app, cooldown = 10, backend = Stat) + @app = app + @cooldown = cooldown + @last = (Time.now - cooldown) + @cache = {} + @mtimes = {} + @reload_mutex = Mutex.new + + extend backend + end + + def call(env) + if @cooldown and Time.now > @last + @cooldown + if Thread.list.size > 1 + @reload_mutex.synchronize{ reload! } + else + reload! + end + + @last = Time.now + end + + @app.call(env) + end + + def reload!(stderr = $stderr) + rotation do |file, mtime| + previous_mtime = @mtimes[file] ||= mtime + safe_load(file, mtime, stderr) if mtime > previous_mtime + end + end + + # A safe Kernel::load, issuing the hooks depending on the results + def safe_load(file, mtime, stderr = $stderr) + load(file) + stderr.puts "#{self.class}: reloaded `#{file}'" + file + rescue LoadError, SyntaxError => ex + stderr.puts ex + ensure + @mtimes[file] = mtime + end + + module Stat + def rotation + files = [$0, *$LOADED_FEATURES].uniq + paths = ['./', *$LOAD_PATH].uniq + + files.map{|file| + next if /\.(so|bundle)$/.match?(file) # cannot reload compiled files + + found, stat = figure_path(file, paths) + next unless found && stat && mtime = stat.mtime + + @cache[file] = found + + yield(found, mtime) + }.compact + end + + # Takes a relative or absolute +file+ name, a couple possible +paths+ that + # the +file+ might reside in. Returns the full path and File::Stat for the + # path. + def figure_path(file, paths) + found = @cache[file] + found = file if !found and Pathname.new(file).absolute? + found, stat = safe_stat(found) + return found, stat if found + + paths.find do |possible_path| + path = ::File.join(possible_path, file) + found, stat = safe_stat(path) + return ::File.expand_path(found), stat if found + end + + return false, false + end + + def safe_stat(file) + return unless file + stat = ::File.stat(file) + return file, stat if stat.file? + rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ESRCH + @cache.delete(file) and false + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/request.rb new file mode 100644 index 0000000..93526a0 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/request.rb @@ -0,0 +1,796 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' +require_relative 'media_type' + +module Rack + # Rack::Request provides a convenient interface to a Rack + # environment. It is stateless, the environment +env+ passed to the + # constructor will be directly modified. + # + # req = Rack::Request.new(env) + # req.post? + # req.params["data"] + + class Request + class << self + attr_accessor :ip_filter + + # The priority when checking forwarded headers. The default + # is [:forwarded, :x_forwarded], which means, check the + # +Forwarded+ header first, followed by the appropriate + # X-Forwarded-* header. You can revert the priority by + # reversing the priority, or remove checking of either + # or both headers by removing elements from the array. + # + # This should be set as appropriate in your environment + # based on what reverse proxies are in use. If you are not + # using reverse proxies, you should probably use an empty + # array. + attr_accessor :forwarded_priority + + # The priority when checking either the X-Forwarded-Proto + # or X-Forwarded-Scheme header for the forwarded protocol. + # The default is [:proto, :scheme], to try the + # X-Forwarded-Proto header before the + # X-Forwarded-Scheme header. Rack 2 had behavior + # similar to [:scheme, :proto]. You can remove either or + # both of the entries in array to ignore that respective header. + attr_accessor :x_forwarded_proto_priority + end + + @forwarded_priority = [:forwarded, :x_forwarded] + @x_forwarded_proto_priority = [:proto, :scheme] + + valid_ipv4_octet = /\.(25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])/ + + trusted_proxies = Regexp.union( + /\A127#{valid_ipv4_octet}{3}\z/, # localhost IPv4 range 127.x.x.x, per RFC-3330 + /\A::1\z/, # localhost IPv6 ::1 + /\Af[cd][0-9a-f]{2}(?::[0-9a-f]{0,4}){0,7}\z/i, # private IPv6 range fc00 .. fdff + /\A10#{valid_ipv4_octet}{3}\z/, # private IPv4 range 10.x.x.x + /\A172\.(1[6-9]|2[0-9]|3[01])#{valid_ipv4_octet}{2}\z/, # private IPv4 range 172.16.0.0 .. 172.31.255.255 + /\A192\.168#{valid_ipv4_octet}{2}\z/, # private IPv4 range 192.168.x.x + /\Alocalhost\z|\Aunix(\z|:)/i, # localhost hostname, and unix domain sockets + ) + + self.ip_filter = lambda { |ip| trusted_proxies.match?(ip) } + + ALLOWED_SCHEMES = %w(https http wss ws).freeze + + def initialize(env) + @env = env + @params = nil + end + + def params + @params ||= super + end + + def update_param(k, v) + super + @params = nil + end + + def delete_param(k) + v = super + @params = nil + v + end + + module Env + # The environment of the request. + attr_reader :env + + def initialize(env) + @env = env + # This module is included at least in `ActionDispatch::Request` + # The call to `super()` allows additional mixed-in initializers are called + super() + end + + # Predicate method to test to see if `name` has been set as request + # specific data + def has_header?(name) + @env.key? name + end + + # Get a request specific value for `name`. + def get_header(name) + @env[name] + end + + # If a block is given, it yields to the block if the value hasn't been set + # on the request. + def fetch_header(name, &block) + @env.fetch(name, &block) + end + + # Loops through each key / value pair in the request specific data. + def each_header(&block) + @env.each(&block) + end + + # Set a request specific value for `name` to `v` + def set_header(name, v) + @env[name] = v + end + + # Add a header that may have multiple values. + # + # Example: + # request.add_header 'Accept', 'image/png' + # request.add_header 'Accept', '*/*' + # + # assert_equal 'image/png,*/*', request.get_header('Accept') + # + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 + def add_header(key, v) + if v.nil? + get_header key + elsif has_header? key + set_header key, "#{get_header key},#{v}" + else + set_header key, v + end + end + + # Delete a request specific value for `name`. + def delete_header(name) + @env.delete name + end + + def initialize_copy(other) + @env = other.env.dup + end + end + + module Helpers + # The set of form-data media-types. Requests that do not indicate + # one of the media types present in this list will not be eligible + # for form-data / param parsing. + FORM_DATA_MEDIA_TYPES = [ + 'application/x-www-form-urlencoded', + 'multipart/form-data' + ] + + # The set of media-types. Requests that do not indicate + # one of the media types present in this list will not be eligible + # for param parsing like soap attachments or generic multiparts + PARSEABLE_DATA_MEDIA_TYPES = [ + 'multipart/related', + 'multipart/mixed' + ] + + # Default ports depending on scheme. Used to decide whether or not + # to include the port in a generated URI. + DEFAULT_PORTS = { 'http' => 80, 'https' => 443, 'coffee' => 80 } + + # The address of the client which connected to the proxy. + HTTP_X_FORWARDED_FOR = 'HTTP_X_FORWARDED_FOR' + + # The contents of the host/:authority header sent to the proxy. + HTTP_X_FORWARDED_HOST = 'HTTP_X_FORWARDED_HOST' + + HTTP_FORWARDED = 'HTTP_FORWARDED' + + # The value of the scheme sent to the proxy. + HTTP_X_FORWARDED_SCHEME = 'HTTP_X_FORWARDED_SCHEME' + + # The protocol used to connect to the proxy. + HTTP_X_FORWARDED_PROTO = 'HTTP_X_FORWARDED_PROTO' + + # The port used to connect to the proxy. + HTTP_X_FORWARDED_PORT = 'HTTP_X_FORWARDED_PORT' + + # Another way for specifying https scheme was used. + HTTP_X_FORWARDED_SSL = 'HTTP_X_FORWARDED_SSL' + + def body; get_header(RACK_INPUT) end + def script_name; get_header(SCRIPT_NAME).to_s end + def script_name=(s); set_header(SCRIPT_NAME, s.to_s) end + + def path_info; get_header(PATH_INFO).to_s end + def path_info=(s); set_header(PATH_INFO, s.to_s) end + + def request_method; get_header(REQUEST_METHOD) end + def query_string; get_header(QUERY_STRING).to_s end + def content_length; get_header('CONTENT_LENGTH') end + def logger; get_header(RACK_LOGGER) end + def user_agent; get_header('HTTP_USER_AGENT') end + + # the referer of the client + def referer; get_header('HTTP_REFERER') end + alias referrer referer + + def session + fetch_header(RACK_SESSION) do |k| + set_header RACK_SESSION, default_session + end + end + + def session_options + fetch_header(RACK_SESSION_OPTIONS) do |k| + set_header RACK_SESSION_OPTIONS, {} + end + end + + # Checks the HTTP request method (or verb) to see if it was of type DELETE + def delete?; request_method == DELETE end + + # Checks the HTTP request method (or verb) to see if it was of type GET + def get?; request_method == GET end + + # Checks the HTTP request method (or verb) to see if it was of type HEAD + def head?; request_method == HEAD end + + # Checks the HTTP request method (or verb) to see if it was of type OPTIONS + def options?; request_method == OPTIONS end + + # Checks the HTTP request method (or verb) to see if it was of type LINK + def link?; request_method == LINK end + + # Checks the HTTP request method (or verb) to see if it was of type PATCH + def patch?; request_method == PATCH end + + # Checks the HTTP request method (or verb) to see if it was of type POST + def post?; request_method == POST end + + # Checks the HTTP request method (or verb) to see if it was of type PUT + def put?; request_method == PUT end + + # Checks the HTTP request method (or verb) to see if it was of type TRACE + def trace?; request_method == TRACE end + + # Checks the HTTP request method (or verb) to see if it was of type UNLINK + def unlink?; request_method == UNLINK end + + def scheme + if get_header(HTTPS) == 'on' + 'https' + elsif get_header(HTTP_X_FORWARDED_SSL) == 'on' + 'https' + elsif forwarded_scheme + forwarded_scheme + else + get_header(RACK_URL_SCHEME) + end + end + + # The authority of the incoming request as defined by RFC3976. + # https://tools.ietf.org/html/rfc3986#section-3.2 + # + # In HTTP/1, this is the `host` header. + # In HTTP/2, this is the `:authority` pseudo-header. + def authority + forwarded_authority || host_authority || server_authority + end + + # The authority as defined by the `SERVER_NAME` and `SERVER_PORT` + # variables. + def server_authority + host = self.server_name + port = self.server_port + + if host + if port + "#{host}:#{port}" + else + host + end + end + end + + def server_name + get_header(SERVER_NAME) + end + + def server_port + get_header(SERVER_PORT) + end + + def cookies + hash = fetch_header(RACK_REQUEST_COOKIE_HASH) do |key| + set_header(key, {}) + end + + string = get_header(HTTP_COOKIE) + + unless string == get_header(RACK_REQUEST_COOKIE_STRING) + hash.replace Utils.parse_cookies_header(string) + set_header(RACK_REQUEST_COOKIE_STRING, string) + end + + hash + end + + def content_type + content_type = get_header('CONTENT_TYPE') + content_type.nil? || content_type.empty? ? nil : content_type + end + + def xhr? + get_header("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" + end + + # The `HTTP_HOST` header. + def host_authority + get_header(HTTP_HOST) + end + + def host_with_port(authority = self.authority) + host, _, port = split_authority(authority) + + if port == DEFAULT_PORTS[self.scheme] + host + else + authority + end + end + + # Returns a formatted host, suitable for being used in a URI. + def host + split_authority(self.authority)[0] + end + + # Returns an address suitable for being to resolve to an address. + # In the case of a domain name or IPv4 address, the result is the same + # as +host+. In the case of IPv6 or future address formats, the square + # brackets are removed. + def hostname + split_authority(self.authority)[1] + end + + def port + if authority = self.authority + _, _, port = split_authority(authority) + end + + port || forwarded_port&.last || DEFAULT_PORTS[scheme] || server_port + end + + def forwarded_for + forwarded_priority.each do |type| + case type + when :forwarded + if forwarded_for = get_http_forwarded(:for) + return(forwarded_for.map! do |authority| + split_authority(authority)[1] + end) + end + when :x_forwarded + if value = get_header(HTTP_X_FORWARDED_FOR) + return(split_header(value).map do |authority| + split_authority(wrap_ipv6(authority))[1] + end) + end + end + end + + nil + end + + def forwarded_port + forwarded_priority.each do |type| + case type + when :forwarded + if forwarded = get_http_forwarded(:for) + return(forwarded.map do |authority| + split_authority(authority)[2] + end.compact) + end + when :x_forwarded + if value = get_header(HTTP_X_FORWARDED_PORT) + return split_header(value).map(&:to_i) + end + end + end + + nil + end + + def forwarded_authority + forwarded_priority.each do |type| + case type + when :forwarded + if forwarded = get_http_forwarded(:host) + return forwarded.last + end + when :x_forwarded + if value = get_header(HTTP_X_FORWARDED_HOST) + return wrap_ipv6(split_header(value).last) + end + end + end + + nil + end + + def ssl? + scheme == 'https' || scheme == 'wss' + end + + def ip + remote_addresses = split_header(get_header('REMOTE_ADDR')) + external_addresses = reject_trusted_ip_addresses(remote_addresses) + + unless external_addresses.empty? + return external_addresses.last + end + + if (forwarded_for = self.forwarded_for) && !forwarded_for.empty? + # The forwarded for addresses are ordered: client, proxy1, proxy2. + # So we reject all the trusted addresses (proxy*) and return the + # last client. Or if we trust everyone, we just return the first + # address. + return reject_trusted_ip_addresses(forwarded_for).last || forwarded_for.first + end + + # If all the addresses are trusted, and we aren't forwarded, just return + # the first remote address, which represents the source of the request. + remote_addresses.first + end + + # The media type (type/subtype) portion of the CONTENT_TYPE header + # without any media type parameters. e.g., when CONTENT_TYPE is + # "text/plain;charset=utf-8", the media-type is "text/plain". + # + # For more information on the use of media types in HTTP, see: + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 + def media_type + MediaType.type(content_type) + end + + # The media type parameters provided in CONTENT_TYPE as a Hash, or + # an empty Hash if no CONTENT_TYPE or media-type parameters were + # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", + # this method responds with the following Hash: + # { 'charset' => 'utf-8' } + def media_type_params + MediaType.params(content_type) + end + + # The character set of the request body if a "charset" media type + # parameter was given, or nil if no "charset" was specified. Note + # that, per RFC2616, text/* media types that specify no explicit + # charset are to be considered ISO-8859-1. + def content_charset + media_type_params['charset'] + end + + # Determine whether the request body contains form-data by checking + # the request content-type for one of the media-types: + # "application/x-www-form-urlencoded" or "multipart/form-data". The + # list of form-data media types can be modified through the + # +FORM_DATA_MEDIA_TYPES+ array. + # + # A request body is also assumed to contain form-data when no + # content-type header is provided and the request_method is POST. + def form_data? + type = media_type + meth = get_header(RACK_METHODOVERRIDE_ORIGINAL_METHOD) || get_header(REQUEST_METHOD) + + (meth == POST && type.nil?) || FORM_DATA_MEDIA_TYPES.include?(type) + end + + # Determine whether the request body contains data by checking + # the request media_type against registered parse-data media-types + def parseable_data? + PARSEABLE_DATA_MEDIA_TYPES.include?(media_type) + end + + # Returns the data received in the query string. + def GET + rr_query_string = get_header(RACK_REQUEST_QUERY_STRING) + query_string = self.query_string + if rr_query_string == query_string + get_header(RACK_REQUEST_QUERY_HASH) + else + if rr_query_string + warn "query string used for GET parsing different from current query string. Starting in Rack 3.2, Rack will used the cached GET value instead of parsing the current query string.", uplevel: 1 + end + query_hash = parse_query(query_string, '&') + set_header(RACK_REQUEST_QUERY_STRING, query_string) + set_header(RACK_REQUEST_QUERY_HASH, query_hash) + end + end + + # Returns the data received in the request body. + # + # This method support both application/x-www-form-urlencoded and + # multipart/form-data. + def POST + if error = get_header(RACK_REQUEST_FORM_ERROR) + raise error.class, error.message, cause: error.cause + end + + begin + rack_input = get_header(RACK_INPUT) + + # If the form hash was already memoized: + if form_hash = get_header(RACK_REQUEST_FORM_HASH) + form_input = get_header(RACK_REQUEST_FORM_INPUT) + # And it was memoized from the same input: + if form_input.equal?(rack_input) + return form_hash + elsif form_input + warn "input stream used for POST parsing different from current input stream. Starting in Rack 3.2, Rack will used the cached POST value instead of parsing the current input stream.", uplevel: 1 + end + end + + # Otherwise, figure out how to parse the input: + if rack_input.nil? + set_header RACK_REQUEST_FORM_INPUT, nil + set_header(RACK_REQUEST_FORM_HASH, {}) + elsif form_data? || parseable_data? + if pairs = Rack::Multipart.parse_multipart(env, Rack::Multipart::ParamList) + set_header RACK_REQUEST_FORM_PAIRS, pairs + set_header RACK_REQUEST_FORM_HASH, expand_param_pairs(pairs) + else + form_vars = get_header(RACK_INPUT).read + + # Fix for Safari Ajax postings that always append \0 + # form_vars.sub!(/\0\z/, '') # performance replacement: + form_vars.slice!(-1) if form_vars.end_with?("\0") + + set_header RACK_REQUEST_FORM_VARS, form_vars + set_header RACK_REQUEST_FORM_HASH, parse_query(form_vars, '&') + end + + set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) + get_header RACK_REQUEST_FORM_HASH + else + set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) + set_header(RACK_REQUEST_FORM_HASH, {}) + end + rescue => error + set_header(RACK_REQUEST_FORM_ERROR, error) + raise + end + end + + # The union of GET and POST data. + # + # Note that modifications will not be persisted in the env. Use update_param or delete_param if you want to destructively modify params. + def params + self.GET.merge(self.POST) + end + + # Destructively update a parameter, whether it's in GET and/or POST. Returns nil. + # + # The parameter is updated wherever it was previous defined, so GET, POST, or both. If it wasn't previously defined, it's inserted into GET. + # + # env['rack.input'] is not touched. + def update_param(k, v) + found = false + if self.GET.has_key?(k) + found = true + self.GET[k] = v + end + if self.POST.has_key?(k) + found = true + self.POST[k] = v + end + unless found + self.GET[k] = v + end + end + + # Destructively delete a parameter, whether it's in GET or POST. Returns the value of the deleted parameter. + # + # If the parameter is in both GET and POST, the POST value takes precedence since that's how #params works. + # + # env['rack.input'] is not touched. + def delete_param(k) + post_value, get_value = self.POST.delete(k), self.GET.delete(k) + post_value || get_value + end + + def base_url + "#{scheme}://#{host_with_port}" + end + + # Tries to return a remake of the original request URL as a string. + def url + base_url + fullpath + end + + def path + script_name + path_info + end + + def fullpath + query_string.empty? ? path : "#{path}?#{query_string}" + end + + def accept_encoding + parse_http_accept_header(get_header("HTTP_ACCEPT_ENCODING")) + end + + def accept_language + parse_http_accept_header(get_header("HTTP_ACCEPT_LANGUAGE")) + end + + def trusted_proxy?(ip) + Rack::Request.ip_filter.call(ip) + end + + # like Hash#values_at + def values_at(*keys) + warn("Request#values_at is deprecated and will be removed in a future version of Rack. Please use request.params.values_at instead", uplevel: 1) + + keys.map { |key| params[key] } + end + + private + + def default_session; {}; end + + # Assist with compatibility when processing `X-Forwarded-For`. + def wrap_ipv6(host) + # Even thought IPv6 addresses should be wrapped in square brackets, + # sometimes this is not done in various legacy/underspecified headers. + # So we try to fix this situation for compatibility reasons. + + # Try to detect IPv6 addresses which aren't escaped yet: + if !host.start_with?('[') && host.count(':') > 1 + "[#{host}]" + else + host + end + end + + def parse_http_accept_header(header) + # It would be nice to use filter_map here, but it's Ruby 2.7+ + parts = header.to_s.split(',') + + parts.map! do |part| + part.strip! + next if part.empty? + + attribute, parameters = part.split(';', 2) + attribute.strip! + parameters&.strip! + quality = 1.0 + if parameters and /\Aq=([\d.]+)/ =~ parameters + quality = $1.to_f + end + [attribute, quality] + end + + parts.compact! + + parts + end + + # Get an array of values set in the RFC 7239 `Forwarded` request header. + def get_http_forwarded(token) + Utils.forwarded_values(get_header(HTTP_FORWARDED))&.[](token) + end + + def query_parser + Utils.default_query_parser + end + + def parse_query(qs, d = '&') + query_parser.parse_nested_query(qs, d) + end + + def parse_multipart + Rack::Multipart.extract_multipart(self, query_parser) + end + + def expand_param_pairs(pairs, query_parser = query_parser()) + params = query_parser.make_params + + pairs.each do |k, v| + query_parser.normalize_params(params, k, v) + end + + params.to_params_hash + end + + def split_header(value) + value ? value.strip.split(/[,\s]+/) : [] + end + + # ipv6 extracted from resolv stdlib, simplified + # to remove numbered match group creation. + ipv6 = Regexp.union( + /(?:[0-9A-Fa-f]{1,4}:){7} + [0-9A-Fa-f]{1,4}/x, + /(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: + (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?/x, + /(?:[0-9A-Fa-f]{1,4}:){6,6} + \d+\.\d+\.\d+\.\d+/x, + /(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: + (?:[0-9A-Fa-f]{1,4}:)* + \d+\.\d+\.\d+\.\d+/x, + /[Ff][Ee]80 + (?::[0-9A-Fa-f]{1,4}){7} + %[-0-9A-Za-z._~]+/x, + /[Ff][Ee]80: + (?: + (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: + (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? + | + :(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? + )? + :[0-9A-Fa-f]{1,4}%[-0-9A-Za-z._~]+/x) + + AUTHORITY = / + \A + (? + # Match IPv6 as a string of hex digits and colons in square brackets + \[(?
#{ipv6})\] + | + # Match any other printable string (except square brackets) as a hostname + (?
[[[:graph:]&&[^\[\]]]]*?) + ) + (:(?\d+))? + \z + /x + + private_constant :AUTHORITY + + def split_authority(authority) + return [] if authority.nil? + return [] unless match = AUTHORITY.match(authority) + return match[:host], match[:address], match[:port]&.to_i + end + + def reject_trusted_ip_addresses(ip_addresses) + ip_addresses.reject { |ip| trusted_proxy?(ip) } + end + + FORWARDED_SCHEME_HEADERS = { + proto: HTTP_X_FORWARDED_PROTO, + scheme: HTTP_X_FORWARDED_SCHEME + }.freeze + private_constant :FORWARDED_SCHEME_HEADERS + def forwarded_scheme + forwarded_priority.each do |type| + case type + when :forwarded + if (forwarded_proto = get_http_forwarded(:proto)) && + (scheme = allowed_scheme(forwarded_proto.last)) + return scheme + end + when :x_forwarded + x_forwarded_proto_priority.each do |x_type| + if header = FORWARDED_SCHEME_HEADERS[x_type] + split_header(get_header(header)).reverse_each do |scheme| + if allowed_scheme(scheme) + return scheme + end + end + end + end + end + end + + nil + end + + def allowed_scheme(header) + header if ALLOWED_SCHEMES.include?(header) + end + + def forwarded_priority + Request.forwarded_priority + end + + def x_forwarded_proto_priority + Request.x_forwarded_proto_priority + end + end + + include Env + include Helpers + end +end + +# :nocov: +require_relative 'multipart' unless defined?(Rack::Multipart) +# :nocov: diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/response.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/response.rb new file mode 100644 index 0000000..ece451d --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/response.rb @@ -0,0 +1,403 @@ +# frozen_string_literal: true + +require 'time' + +require_relative 'constants' +require_relative 'utils' +require_relative 'media_type' +require_relative 'headers' + +module Rack + # Rack::Response provides a convenient interface to create a Rack + # response. + # + # It allows setting of headers and cookies, and provides useful + # defaults (an OK response with empty headers and body). + # + # You can use Response#write to iteratively generate your response, + # but note that this is buffered by Rack::Response until you call + # +finish+. +finish+ however can take a block inside which calls to + # +write+ are synchronous with the Rack response. + # + # Your application's +call+ should end returning Response#finish. + class Response + def self.[](status, headers, body) + self.new(body, status, headers) + end + + CHUNKED = 'chunked' + STATUS_WITH_NO_ENTITY_BODY = Utils::STATUS_WITH_NO_ENTITY_BODY + + attr_accessor :length, :status, :body + attr_reader :headers + + # Initialize the response object with the specified +body+, +status+ + # and +headers+. + # + # If the +body+ is +nil+, construct an empty response object with internal + # buffering. + # + # If the +body+ responds to +to_str+, assume it's a string-like object and + # construct a buffered response object containing using that string as the + # initial contents of the buffer. + # + # Otherwise it is expected +body+ conforms to the normal requirements of a + # Rack response body, typically implementing one of +each+ (enumerable + # body) or +call+ (streaming body). + # + # The +status+ defaults to +200+ which is the "OK" HTTP status code. You + # can provide any other valid status code. + # + # The +headers+ must be a +Hash+ of key-value header pairs which conform to + # the Rack specification for response headers. The key must be a +String+ + # instance and the value can be either a +String+ or +Array+ instance. + def initialize(body = nil, status = 200, headers = {}) + @status = status.to_i + + unless headers.is_a?(Hash) + raise ArgumentError, "Headers must be a Hash!" + end + + @headers = Headers.new + # Convert headers input to a plain hash with lowercase keys. + headers.each do |k, v| + @headers[k] = v + end + + @writer = self.method(:append) + + @block = nil + + # Keep track of whether we have expanded the user supplied body. + if body.nil? + @body = [] + @buffered = true + # Body is unspecified - it may be a buffered response, or it may be a HEAD response. + @length = nil + elsif body.respond_to?(:to_str) + @body = [body] + @buffered = true + @length = body.to_str.bytesize + else + @body = body + @buffered = nil # undetermined as of yet. + @length = nil + end + + yield self if block_given? + end + + def redirect(target, status = 302) + self.status = status + self.location = target + end + + def chunked? + CHUNKED == get_header(TRANSFER_ENCODING) + end + + def no_entity_body? + # The response body is an enumerable body and it is not allowed to have an entity body. + @body.respond_to?(:each) && STATUS_WITH_NO_ENTITY_BODY[@status] + end + + # Generate a response array consistent with the requirements of the SPEC. + # @return [Array] a 3-tuple suitable of `[status, headers, body]` + # which is suitable to be returned from the middleware `#call(env)` method. + def finish(&block) + if no_entity_body? + delete_header CONTENT_TYPE + delete_header CONTENT_LENGTH + close + return [@status, @headers, []] + else + if block_given? + # We don't add the content-length here as the user has provided a block that can #write additional chunks to the body. + @block = block + return [@status, @headers, self] + else + # If we know the length of the body, set the content-length header... except if we are chunked? which is a legacy special case where the body might already be encoded and thus the actual encoded body length and the content-length are likely to be different. + if @length && !chunked? + @headers[CONTENT_LENGTH] = @length.to_s + end + return [@status, @headers, @body] + end + end + end + + alias to_a finish # For *response + + def each(&callback) + @body.each(&callback) + @buffered = true + + if @block + @writer = callback + @block.call(self) + end + end + + # Append a chunk to the response body. + # + # Converts the response into a buffered response if it wasn't already. + # + # NOTE: Do not mix #write and direct #body access! + # + def write(chunk) + buffered_body! + + @writer.call(chunk.to_s) + end + + def close + @body.close if @body.respond_to?(:close) + end + + def empty? + @block == nil && @body.empty? + end + + def has_header?(key) + raise ArgumentError unless key.is_a?(String) + @headers.key?(key) + end + def get_header(key) + raise ArgumentError unless key.is_a?(String) + @headers[key] + end + def set_header(key, value) + raise ArgumentError unless key.is_a?(String) + @headers[key] = value + end + def delete_header(key) + raise ArgumentError unless key.is_a?(String) + @headers.delete key + end + + alias :[] :get_header + alias :[]= :set_header + + module Helpers + def invalid?; status < 100 || status >= 600; end + + def informational?; status >= 100 && status < 200; end + def successful?; status >= 200 && status < 300; end + def redirection?; status >= 300 && status < 400; end + def client_error?; status >= 400 && status < 500; end + def server_error?; status >= 500 && status < 600; end + + def ok?; status == 200; end + def created?; status == 201; end + def accepted?; status == 202; end + def no_content?; status == 204; end + def moved_permanently?; status == 301; end + def bad_request?; status == 400; end + def unauthorized?; status == 401; end + def forbidden?; status == 403; end + def not_found?; status == 404; end + def method_not_allowed?; status == 405; end + def not_acceptable?; status == 406; end + def request_timeout?; status == 408; end + def precondition_failed?; status == 412; end + def unprocessable?; status == 422; end + + def redirect?; [301, 302, 303, 307, 308].include? status; end + + def include?(header) + has_header?(header) + end + + # Add a header that may have multiple values. + # + # Example: + # response.add_header 'vary', 'accept-encoding' + # response.add_header 'vary', 'cookie' + # + # assert_equal 'accept-encoding,cookie', response.get_header('vary') + # + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 + def add_header(key, value) + raise ArgumentError unless key.is_a?(String) + + if value.nil? + return get_header(key) + end + + value = value.to_s + + if header = get_header(key) + if header.is_a?(Array) + header << value + else + set_header(key, [header, value]) + end + else + set_header(key, value) + end + end + + # Get the content type of the response. + def content_type + get_header CONTENT_TYPE + end + + # Set the content type of the response. + def content_type=(content_type) + set_header CONTENT_TYPE, content_type + end + + def media_type + MediaType.type(content_type) + end + + def media_type_params + MediaType.params(content_type) + end + + def content_length + cl = get_header CONTENT_LENGTH + cl ? cl.to_i : cl + end + + def location + get_header "location" + end + + def location=(location) + set_header "location", location + end + + def set_cookie(key, value) + add_header SET_COOKIE, Utils.set_cookie_header(key, value) + end + + def delete_cookie(key, value = {}) + set_header(SET_COOKIE, + Utils.delete_set_cookie_header!( + get_header(SET_COOKIE), key, value + ) + ) + end + + def set_cookie_header + get_header SET_COOKIE + end + + def set_cookie_header=(value) + set_header SET_COOKIE, value + end + + def cache_control + get_header CACHE_CONTROL + end + + def cache_control=(value) + set_header CACHE_CONTROL, value + end + + # Specifies that the content shouldn't be cached. Overrides `cache!` if already called. + def do_not_cache! + set_header CACHE_CONTROL, "no-cache, must-revalidate" + set_header EXPIRES, Time.now.httpdate + end + + # Specify that the content should be cached. + # @param duration [Integer] The number of seconds until the cache expires. + # @option directive [String] The cache control directive, one of "public", "private", "no-cache" or "no-store". + def cache!(duration = 3600, directive: "public") + unless headers[CACHE_CONTROL] =~ /no-cache/ + set_header CACHE_CONTROL, "#{directive}, max-age=#{duration}" + set_header EXPIRES, (Time.now + duration).httpdate + end + end + + def etag + get_header ETAG + end + + def etag=(value) + set_header ETAG, value + end + + protected + + # Convert the body of this response into an internally buffered Array if possible. + # + # `@buffered` is a ternary value which indicates whether the body is buffered. It can be: + # * `nil` - The body has not been buffered yet. + # * `true` - The body is buffered as an Array instance. + # * `false` - The body is not buffered and cannot be buffered. + # + # @return [Boolean] whether the body is buffered as an Array instance. + def buffered_body! + if @buffered.nil? + if @body.is_a?(Array) + # The user supplied body was an array: + @body = @body.compact + @length = @body.sum{|part| part.bytesize} + @buffered = true + elsif @body.respond_to?(:each) + # Turn the user supplied body into a buffered array: + body = @body + @body = Array.new + @buffered = true + + body.each do |part| + @writer.call(part.to_s) + end + + body.close if body.respond_to?(:close) + else + # We don't know how to buffer the user-supplied body: + @buffered = false + end + end + + return @buffered + end + + def append(chunk) + chunk = chunk.dup unless chunk.frozen? + @body << chunk + + if @length + @length += chunk.bytesize + elsif @buffered + @length = chunk.bytesize + end + + return chunk + end + end + + include Helpers + + class Raw + include Helpers + + attr_reader :headers + attr_accessor :status + + def initialize(status, headers) + @status = status + @headers = headers + end + + def has_header?(key) + headers.key?(key) + end + + def get_header(key) + headers[key] + end + + def set_header(key, value) + headers[key] = value + end + + def delete_header(key) + headers.delete(key) + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb new file mode 100644 index 0000000..730c6a2 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb @@ -0,0 +1,113 @@ +# -*- encoding: binary -*- +# frozen_string_literal: true + +require 'tempfile' + +require_relative 'constants' + +module Rack + # Class which can make any IO object rewindable, including non-rewindable ones. It does + # this by buffering the data into a tempfile, which is rewindable. + # + # Don't forget to call #close when you're done. This frees up temporary resources that + # RewindableInput uses, though it does *not* close the original IO object. + class RewindableInput + # Makes rack.input rewindable, for compatibility with applications and middleware + # designed for earlier versions of Rack (where rack.input was required to be + # rewindable). + class Middleware + def initialize(app) + @app = app + end + + def call(env) + env[RACK_INPUT] = RewindableInput.new(env[RACK_INPUT]) + @app.call(env) + end + end + + def initialize(io) + @io = io + @rewindable_io = nil + @unlinked = false + end + + def gets + make_rewindable unless @rewindable_io + @rewindable_io.gets + end + + def read(*args) + make_rewindable unless @rewindable_io + @rewindable_io.read(*args) + end + + def each(&block) + make_rewindable unless @rewindable_io + @rewindable_io.each(&block) + end + + def rewind + make_rewindable unless @rewindable_io + @rewindable_io.rewind + end + + def size + make_rewindable unless @rewindable_io + @rewindable_io.size + end + + # Closes this RewindableInput object without closing the originally + # wrapped IO object. Cleans up any temporary resources that this RewindableInput + # has created. + # + # This method may be called multiple times. It does nothing on subsequent calls. + def close + if @rewindable_io + if @unlinked + @rewindable_io.close + else + @rewindable_io.close! + end + @rewindable_io = nil + end + end + + private + + def make_rewindable + # Buffer all data into a tempfile. Since this tempfile is private to this + # RewindableInput object, we chmod it so that nobody else can read or write + # it. On POSIX filesystems we also unlink the file so that it doesn't + # even have a file entry on the filesystem anymore, though we can still + # access it because we have the file handle open. + @rewindable_io = Tempfile.new('RackRewindableInput') + @rewindable_io.chmod(0000) + @rewindable_io.set_encoding(Encoding::BINARY) + @rewindable_io.binmode + # :nocov: + if filesystem_has_posix_semantics? + raise 'Unlink failed. IO closed.' if @rewindable_io.closed? + @unlinked = true + end + # :nocov: + + buffer = "".dup + while @io.read(1024 * 4, buffer) + entire_buffer_written_out = false + while !entire_buffer_written_out + written = @rewindable_io.write(buffer) + entire_buffer_written_out = written == buffer.bytesize + if !entire_buffer_written_out + buffer.slice!(0 .. written - 1) + end + end + end + @rewindable_io.rewind + end + + def filesystem_has_posix_semantics? + RUBY_PLATFORM !~ /(mswin|mingw|cygwin|java)/ + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/runtime.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/runtime.rb new file mode 100644 index 0000000..a1bfa69 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/runtime.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative 'utils' + +module Rack + # Sets an "x-runtime" response header, indicating the response + # time of the request, in seconds + # + # You can put it right before the application to see the processing + # time, or before all the other middlewares to include time for them, + # too. + class Runtime + FORMAT_STRING = "%0.6f" # :nodoc: + HEADER_NAME = "x-runtime" # :nodoc: + + def initialize(app, name = nil) + @app = app + @header_name = HEADER_NAME + @header_name += "-#{name.to_s.downcase}" if name + end + + def call(env) + start_time = Utils.clock_time + _, headers, _ = response = @app.call(env) + + request_time = Utils.clock_time - start_time + + unless headers.key?(@header_name) + headers[@header_name] = FORMAT_STRING % request_time + end + + response + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/sendfile.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/sendfile.rb new file mode 100644 index 0000000..9c6e0c4 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/sendfile.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' +require_relative 'body_proxy' + +module Rack + + # = Sendfile + # + # The Sendfile middleware intercepts responses whose body is being + # served from a file and replaces it with a server specific x-sendfile + # header. The web server is then responsible for writing the file contents + # to the client. This can dramatically reduce the amount of work required + # by the Ruby backend and takes advantage of the web server's optimized file + # delivery code. + # + # In order to take advantage of this middleware, the response body must + # respond to +to_path+ and the request must include an x-sendfile-type + # header. Rack::Files and other components implement +to_path+ so there's + # rarely anything you need to do in your application. The x-sendfile-type + # header is typically set in your web servers configuration. The following + # sections attempt to document + # + # === Nginx + # + # Nginx supports the x-accel-redirect header. This is similar to x-sendfile + # but requires parts of the filesystem to be mapped into a private URL + # hierarchy. + # + # The following example shows the Nginx configuration required to create + # a private "/files/" area, enable x-accel-redirect, and pass the special + # x-sendfile-type and x-accel-mapping headers to the backend: + # + # location ~ /files/(.*) { + # internal; + # alias /var/www/$1; + # } + # + # location / { + # proxy_redirect off; + # + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # + # proxy_set_header x-sendfile-type x-accel-redirect; + # proxy_set_header x-accel-mapping /var/www/=/files/; + # + # proxy_pass http://127.0.0.1:8080/; + # } + # + # Note that the x-sendfile-type header must be set exactly as shown above. + # The x-accel-mapping header should specify the location on the file system, + # followed by an equals sign (=), followed name of the private URL pattern + # that it maps to. The middleware performs a simple substitution on the + # resulting path. + # + # See Also: https://www.nginx.com/resources/wiki/start/topics/examples/xsendfile + # + # === lighttpd + # + # Lighttpd has supported some variation of the x-sendfile header for some + # time, although only recent version support x-sendfile in a reverse proxy + # configuration. + # + # $HTTP["host"] == "example.com" { + # proxy-core.protocol = "http" + # proxy-core.balancer = "round-robin" + # proxy-core.backends = ( + # "127.0.0.1:8000", + # "127.0.0.1:8001", + # ... + # ) + # + # proxy-core.allow-x-sendfile = "enable" + # proxy-core.rewrite-request = ( + # "x-sendfile-type" => (".*" => "x-sendfile") + # ) + # } + # + # See Also: http://redmine.lighttpd.net/wiki/lighttpd/Docs:ModProxyCore + # + # === Apache + # + # x-sendfile is supported under Apache 2.x using a separate module: + # + # https://tn123.org/mod_xsendfile/ + # + # Once the module is compiled and installed, you can enable it using + # XSendFile config directive: + # + # RequestHeader Set x-sendfile-type x-sendfile + # ProxyPassReverse / http://localhost:8001/ + # XSendFile on + # + # === Mapping parameter + # + # The third parameter allows for an overriding extension of the + # x-accel-mapping header. Mappings should be provided in tuples of internal to + # external. The internal values may contain regular expression syntax, they + # will be matched with case indifference. + + class Sendfile + def initialize(app, variation = nil, mappings = []) + @app = app + @variation = variation + @mappings = mappings.map do |internal, external| + [/^#{internal}/i, external] + end + end + + def call(env) + _, headers, body = response = @app.call(env) + + if body.respond_to?(:to_path) + case type = variation(env) + when /x-accel-redirect/i + path = ::File.expand_path(body.to_path) + if url = map_accel_path(env, path) + headers[CONTENT_LENGTH] = '0' + # '?' must be percent-encoded because it is not query string but a part of path + headers[type.downcase] = ::Rack::Utils.escape_path(url).gsub('?', '%3F') + obody = body + response[2] = Rack::BodyProxy.new([]) do + obody.close if obody.respond_to?(:close) + end + else + env[RACK_ERRORS].puts "x-accel-mapping header missing" + end + when /x-sendfile|x-lighttpd-send-file/i + path = ::File.expand_path(body.to_path) + headers[CONTENT_LENGTH] = '0' + headers[type.downcase] = path + obody = body + response[2] = Rack::BodyProxy.new([]) do + obody.close if obody.respond_to?(:close) + end + when '', nil + else + env[RACK_ERRORS].puts "Unknown x-sendfile variation: '#{type}'.\n" + end + end + response + end + + private + def variation(env) + @variation || + env['sendfile.type'] || + env['HTTP_X_SENDFILE_TYPE'] + end + + def map_accel_path(env, path) + if mapping = @mappings.find { |internal, _| internal =~ path } + path.sub(*mapping) + elsif mapping = env['HTTP_X_ACCEL_MAPPING'] + mapping.split(',').map(&:strip).each do |m| + internal, external = m.split('=', 2).map(&:strip) + new_path = path.sub(/^#{internal}/i, external) + return new_path unless path == new_path + end + path + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb new file mode 100644 index 0000000..9172a4d --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb @@ -0,0 +1,407 @@ +# frozen_string_literal: true + +require 'erb' + +require_relative 'constants' +require_relative 'utils' +require_relative 'request' + +module Rack + # Rack::ShowExceptions catches all exceptions raised from the app it + # wraps. It shows a useful backtrace with the sourcefile and + # clickable context, the whole Rack environment and the request + # data. + # + # Be careful when you use this on public-facing sites as it could + # reveal information helpful to attackers. + + class ShowExceptions + CONTEXT = 7 + + Frame = Struct.new(:filename, :lineno, :function, + :pre_context_lineno, :pre_context, + :context_line, :post_context_lineno, + :post_context) + + def initialize(app) + @app = app + end + + def call(env) + @app.call(env) + rescue StandardError, LoadError, SyntaxError => e + exception_string = dump_exception(e) + + env[RACK_ERRORS].puts(exception_string) + env[RACK_ERRORS].flush + + if accepts_html?(env) + content_type = "text/html" + body = pretty(env, e) + else + content_type = "text/plain" + body = exception_string + end + + [ + 500, + { + CONTENT_TYPE => content_type, + CONTENT_LENGTH => body.bytesize.to_s, + }, + [body], + ] + end + + def prefers_plaintext?(env) + !accepts_html?(env) + end + + def accepts_html?(env) + Rack::Utils.best_q_match(env["HTTP_ACCEPT"], %w[text/html]) + end + private :accepts_html? + + def dump_exception(exception) + if exception.respond_to?(:detailed_message) + message = exception.detailed_message(highlight: false) + else + message = exception.message + end + string = "#{exception.class}: #{message}\n".dup + string << exception.backtrace.map { |l| "\t#{l}" }.join("\n") + string + end + + def pretty(env, exception) + req = Rack::Request.new(env) + + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. + path = path = (req.script_name + req.path_info).squeeze("/") + + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. + frames = frames = exception.backtrace.map { |line| + frame = Frame.new + if line =~ /(.*?):(\d+)(:in `(.*)')?/ + frame.filename = $1 + frame.lineno = $2.to_i + frame.function = $4 + + begin + lineno = frame.lineno - 1 + lines = ::File.readlines(frame.filename) + frame.pre_context_lineno = [lineno - CONTEXT, 0].max + frame.pre_context = lines[frame.pre_context_lineno...lineno] + frame.context_line = lines[lineno].chomp + frame.post_context_lineno = [lineno + CONTEXT, lines.size].min + frame.post_context = lines[lineno + 1..frame.post_context_lineno] + rescue + end + + frame + else + nil + end + }.compact + + template.result(binding) + end + + def template + TEMPLATE + end + + def h(obj) # :nodoc: + case obj + when String + Utils.escape_html(obj) + else + Utils.escape_html(obj.inspect) + end + end + + # :stopdoc: + + # adapted from Django + # Copyright (c) Django Software Foundation and individual contributors. + # Used under the modified BSD license: + # http://www.xfree86.org/3.3.6/COPYRIGHT2.html#5 + TEMPLATE = ERB.new(<<-'HTML'.gsub(/^ /, '')) + + + + + + <%=h exception.class %> at <%=h path %> + + + + + +
+

<%=h exception.class %> at <%=h path %>

+ <% if exception.respond_to?(:detailed_message) %> +

<%=h exception.detailed_message(highlight: false) %>

+ <% else %> +

<%=h exception.message %>

+ <% end %> + + + + + + +
Ruby + <% if first = frames.first %> + <%=h first.filename %>: in <%=h first.function %>, line <%=h frames.first.lineno %> + <% else %> + unknown location + <% end %> +
Web<%=h req.request_method %> <%=h(req.host + path)%>
+ +

Jump to:

+ +
+ +
+

Traceback (innermost first)

+
    + <% frames.each { |frame| %> +
  • + <%=h frame.filename %>: in <%=h frame.function %> + + <% if frame.context_line %> +
    + <% if frame.pre_context %> +
      + <% frame.pre_context.each { |line| %> +
    1. <%=h line %>
    2. + <% } %> +
    + <% end %> + +
      +
    1. <%=h frame.context_line %>...
    + + <% if frame.post_context %> +
      + <% frame.post_context.each { |line| %> +
    1. <%=h line %>
    2. + <% } %> +
    + <% end %> +
    + <% end %> +
  • + <% } %> +
+
+ +
+

Request information

+ +

GET

+ <% if req.GET and not req.GET.empty? %> + + + + + + + + + <% req.GET.sort_by { |k, v| k.to_s }.each { |key, val| %> + + + + + <% } %> + +
VariableValue
<%=h key %>
<%=h val.inspect %>
+ <% else %> +

No GET data.

+ <% end %> + +

POST

+ <% if ((req.POST and not req.POST.empty?) rescue (no_post_data = "Invalid POST data"; nil)) %> + + + + + + + + + <% req.POST.sort_by { |k, v| k.to_s }.each { |key, val| %> + + + + + <% } %> + +
VariableValue
<%=h key %>
<%=h val.inspect %>
+ <% else %> +

<%= no_post_data || "No POST data" %>.

+ <% end %> + + + + <% unless req.cookies.empty? %> + + + + + + + + + <% req.cookies.each { |key, val| %> + + + + + <% } %> + +
VariableValue
<%=h key %>
<%=h val.inspect %>
+ <% else %> +

No cookie data.

+ <% end %> + +

Rack ENV

+ + + + + + + + + <% env.sort_by { |k, v| k.to_s }.each { |key, val| %> + + + + + <% } %> + +
VariableValue
<%=h key %>
<%=h val.inspect %>
+ +
+ +
+

+ You're seeing this error because you use Rack::ShowExceptions. +

+
+ + + + HTML + + # :startdoc: + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_status.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_status.rb new file mode 100644 index 0000000..b6f75a0 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_status.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'erb' + +require_relative 'constants' +require_relative 'utils' +require_relative 'request' +require_relative 'body_proxy' + +module Rack + # Rack::ShowStatus catches all empty responses and replaces them + # with a site explaining the error. + # + # Additional details can be put into rack.showstatus.detail + # and will be shown as HTML. If such details exist, the error page + # is always rendered, even if the reply was not empty. + + class ShowStatus + def initialize(app) + @app = app + @template = ERB.new(TEMPLATE) + end + + def call(env) + status, headers, body = response = @app.call(env) + empty = headers[CONTENT_LENGTH].to_i <= 0 + + # client or server error, or explicit message + if (status.to_i >= 400 && empty) || env[RACK_SHOWSTATUS_DETAIL] + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. + req = req = Rack::Request.new(env) + + message = Rack::Utils::HTTP_STATUS_CODES[status.to_i] || status.to_s + + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. + detail = detail = env[RACK_SHOWSTATUS_DETAIL] || message + + html = @template.result(binding) + size = html.bytesize + + response[2] = Rack::BodyProxy.new([html]) do + body.close if body.respond_to?(:close) + end + + headers[CONTENT_TYPE] = "text/html" + headers[CONTENT_LENGTH] = size.to_s + end + + response + end + + def h(obj) # :nodoc: + case obj + when String + Utils.escape_html(obj) + else + Utils.escape_html(obj.inspect) + end + end + + # :stopdoc: + +# adapted from Django +# Copyright (c) Django Software Foundation and individual contributors. +# Used under the modified BSD license: +# http://www.xfree86.org/3.3.6/COPYRIGHT2.html#5 +TEMPLATE = <<'HTML' + + + + + <%=h message %> at <%=h req.script_name + req.path_info %> + + + + +
+

<%=h message %> (<%= status.to_i %>)

+ + + + + + + + + +
Request Method:<%=h req.request_method %>
Request URL:<%=h req.url %>
+
+
+

<%=h detail %>

+
+ +
+

+ You're seeing this error because you use Rack::ShowStatus. +

+
+ + +HTML + + # :startdoc: + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/static.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/static.rb new file mode 100644 index 0000000..5c9b676 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/static.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'files' +require_relative 'mime' + +module Rack + + # The Rack::Static middleware intercepts requests for static files + # (javascript files, images, stylesheets, etc) based on the url prefixes or + # route mappings passed in the options, and serves them using a Rack::Files + # object. This allows a Rack stack to serve both static and dynamic content. + # + # Examples: + # + # Serve all requests beginning with /media from the "media" folder located + # in the current directory (ie media/*): + # + # use Rack::Static, :urls => ["/media"] + # + # Same as previous, but instead of returning 404 for missing files under + # /media, call the next middleware: + # + # use Rack::Static, :urls => ["/media"], :cascade => true + # + # Serve all requests beginning with /css or /images from the folder "public" + # in the current directory (ie public/css/* and public/images/*): + # + # use Rack::Static, :urls => ["/css", "/images"], :root => "public" + # + # Serve all requests to / with "index.html" from the folder "public" in the + # current directory (ie public/index.html): + # + # use Rack::Static, :urls => {"/" => 'index.html'}, :root => 'public' + # + # Serve all requests normally from the folder "public" in the current + # directory but uses index.html as default route for "/" + # + # use Rack::Static, :urls => [""], :root => 'public', :index => + # 'index.html' + # + # Set custom HTTP Headers for based on rules: + # + # use Rack::Static, :root => 'public', + # :header_rules => [ + # [rule, {header_field => content, header_field => content}], + # [rule, {header_field => content}] + # ] + # + # Rules for selecting files: + # + # 1) All files + # Provide the :all symbol + # :all => Matches every file + # + # 2) Folders + # Provide the folder path as a string + # '/folder' or '/folder/subfolder' => Matches files in a certain folder + # + # 3) File Extensions + # Provide the file extensions as an array + # ['css', 'js'] or %w(css js) => Matches files ending in .css or .js + # + # 4) Regular Expressions / Regexp + # Provide a regular expression + # %r{\.(?:css|js)\z} => Matches files ending in .css or .js + # /\.(?:eot|ttf|otf|woff2|woff|svg)\z/ => Matches files ending in + # the most common web font formats (.eot, .ttf, .otf, .woff2, .woff, .svg) + # Note: This Regexp is available as a shortcut, using the :fonts rule + # + # 5) Font Shortcut + # Provide the :fonts symbol + # :fonts => Uses the Regexp rule stated right above to match all common web font endings + # + # Rule Ordering: + # Rules are applied in the order that they are provided. + # List rather general rules above special ones. + # + # Complete example use case including HTTP header rules: + # + # use Rack::Static, :root => 'public', + # :header_rules => [ + # # Cache all static files in public caches (e.g. Rack::Cache) + # # as well as in the browser + # [:all, {'cache-control' => 'public, max-age=31536000'}], + # + # # Provide web fonts with cross-origin access-control-headers + # # Firefox requires this when serving assets using a Content Delivery Network + # [:fonts, {'access-control-allow-origin' => '*'}] + # ] + # + class Static + def initialize(app, options = {}) + @app = app + @urls = options[:urls] || ["/favicon.ico"] + @index = options[:index] + @gzip = options[:gzip] + @cascade = options[:cascade] + root = options[:root] || Dir.pwd + + # HTTP Headers + @header_rules = options[:header_rules] || [] + # Allow for legacy :cache_control option while prioritizing global header_rules setting + @header_rules.unshift([:all, { CACHE_CONTROL => options[:cache_control] }]) if options[:cache_control] + + @file_server = Rack::Files.new(root) + end + + def add_index_root?(path) + @index && route_file(path) && path.end_with?('/') + end + + def overwrite_file_path(path) + @urls.kind_of?(Hash) && @urls.key?(path) || add_index_root?(path) + end + + def route_file(path) + @urls.kind_of?(Array) && @urls.any? { |url| path.index(url) == 0 } + end + + def can_serve(path) + route_file(path) || overwrite_file_path(path) + end + + def call(env) + path = env[PATH_INFO] + + if can_serve(path) + if overwrite_file_path(path) + env[PATH_INFO] = (add_index_root?(path) ? path + @index : @urls[path]) + elsif @gzip && env['HTTP_ACCEPT_ENCODING'] && /\bgzip\b/.match?(env['HTTP_ACCEPT_ENCODING']) + path = env[PATH_INFO] + env[PATH_INFO] += '.gz' + response = @file_server.call(env) + env[PATH_INFO] = path + + if response[0] == 404 + response = nil + elsif response[0] == 304 + # Do nothing, leave headers as is + else + response[1][CONTENT_TYPE] = Mime.mime_type(::File.extname(path), 'text/plain') + response[1]['content-encoding'] = 'gzip' + end + end + + path = env[PATH_INFO] + response ||= @file_server.call(env) + + if @cascade && response[0] == 404 + return @app.call(env) + end + + headers = response[1] + applicable_rules(path).each do |rule, new_headers| + new_headers.each { |field, content| headers[field] = content } + end + + response + else + @app.call(env) + end + end + + # Convert HTTP header rules to HTTP headers + def applicable_rules(path) + @header_rules.find_all do |rule, new_headers| + case rule + when :all + true + when :fonts + /\.(?:ttf|otf|eot|woff2|woff|svg)\z/.match?(path) + when String + path = ::Rack::Utils.unescape(path) + path.start_with?(rule) || path.start_with?('/' + rule) + when Array + /\.(#{rule.join('|')})\z/.match?(path) + when Regexp + rule.match?(path) + else + false + end + end + end + + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb new file mode 100644 index 0000000..0b94cc7 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'body_proxy' + +module Rack + + # Middleware tracks and cleans Tempfiles created throughout a request (i.e. Rack::Multipart) + # Ideas/strategy based on posts by Eric Wong and Charles Oliver Nutter + # https://groups.google.com/forum/#!searchin/rack-devel/temp/rack-devel/brK8eh-MByw/sw61oJJCGRMJ + class TempfileReaper + def initialize(app) + @app = app + end + + def call(env) + env[RACK_TEMPFILES] ||= [] + + begin + _, _, body = response = @app.call(env) + rescue Exception + env[RACK_TEMPFILES]&.each(&:close!) + raise + end + + response[2] = BodyProxy.new(body) do + env[RACK_TEMPFILES]&.each(&:close!) + end + + response + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/urlmap.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/urlmap.rb new file mode 100644 index 0000000..99c4d82 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/urlmap.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'set' + +require_relative 'constants' + +module Rack + # Rack::URLMap takes a hash mapping urls or paths to apps, and + # dispatches accordingly. Support for HTTP/1.1 host names exists if + # the URLs start with http:// or https://. + # + # URLMap modifies the SCRIPT_NAME and PATH_INFO such that the part + # relevant for dispatch is in the SCRIPT_NAME, and the rest in the + # PATH_INFO. This should be taken care of when you need to + # reconstruct the URL in order to create links. + # + # URLMap dispatches in such a way that the longest paths are tried + # first, since they are most specific. + + class URLMap + def initialize(map = {}) + remap(map) + end + + def remap(map) + @known_hosts = Set[] + @mapping = map.map { |location, app| + if location =~ %r{\Ahttps?://(.*?)(/.*)} + host, location = $1, $2 + @known_hosts << host + else + host = nil + end + + unless location[0] == ?/ + raise ArgumentError, "paths need to start with /" + end + + location = location.chomp('/') + match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", Regexp::NOENCODING) + + [host, location, match, app] + }.sort_by do |(host, location, _, _)| + [host ? -host.size : Float::INFINITY, -location.size] + end + end + + def call(env) + path = env[PATH_INFO] + script_name = env[SCRIPT_NAME] + http_host = env[HTTP_HOST] + server_name = env[SERVER_NAME] + server_port = env[SERVER_PORT] + + is_same_server = casecmp?(http_host, server_name) || + casecmp?(http_host, "#{server_name}:#{server_port}") + + is_host_known = @known_hosts.include? http_host + + @mapping.each do |host, location, match, app| + unless casecmp?(http_host, host) \ + || casecmp?(server_name, host) \ + || (!host && is_same_server) \ + || (!host && !is_host_known) # If we don't have a matching host, default to the first without a specified host + next + end + + next unless m = match.match(path.to_s) + + rest = m[1] + next unless !rest || rest.empty? || rest[0] == ?/ + + env[SCRIPT_NAME] = (script_name + location) + env[PATH_INFO] = rest + + return app.call(env) + end + + [404, { CONTENT_TYPE => "text/plain", "x-cascade" => "pass" }, ["Not Found: #{path}"]] + + ensure + env[PATH_INFO] = path + env[SCRIPT_NAME] = script_name + end + + private + def casecmp?(v1, v2) + # if both nil, or they're the same string + return true if v1 == v2 + + # if either are nil... (but they're not the same) + return false if v1.nil? + return false if v2.nil? + + # otherwise check they're not case-insensitive the same + v1.casecmp(v2).zero? + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/utils.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/utils.rb new file mode 100644 index 0000000..bbf4969 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/utils.rb @@ -0,0 +1,631 @@ +# -*- encoding: binary -*- +# frozen_string_literal: true + +require 'uri' +require 'fileutils' +require 'set' +require 'tempfile' +require 'time' +require 'erb' + +require_relative 'query_parser' +require_relative 'mime' +require_relative 'headers' +require_relative 'constants' + +module Rack + # Rack::Utils contains a grab-bag of useful methods for writing web + # applications adopted from all kinds of Ruby libraries. + + module Utils + ParameterTypeError = QueryParser::ParameterTypeError + InvalidParameterError = QueryParser::InvalidParameterError + ParamsTooDeepError = QueryParser::ParamsTooDeepError + DEFAULT_SEP = QueryParser::DEFAULT_SEP + COMMON_SEP = QueryParser::COMMON_SEP + KeySpaceConstrainedParams = QueryParser::Params + URI_PARSER = defined?(::URI::RFC2396_PARSER) ? ::URI::RFC2396_PARSER : ::URI::DEFAULT_PARSER + + class << self + attr_accessor :default_query_parser + end + # The default amount of nesting to allowed by hash parameters. + # This helps prevent a rogue client from triggering a possible stack overflow + # when parsing parameters. + self.default_query_parser = QueryParser.make_default(32) + + module_function + + # URI escapes. (CGI style space to +) + def escape(s) + URI.encode_www_form_component(s) + end + + # Like URI escaping, but with %20 instead of +. Strictly speaking this is + # true URI escaping. + def escape_path(s) + URI_PARSER.escape s + end + + # Unescapes the **path** component of a URI. See Rack::Utils.unescape for + # unescaping query parameters or form components. + def unescape_path(s) + URI_PARSER.unescape s + end + + # Unescapes a URI escaped string with +encoding+. +encoding+ will be the + # target encoding of the string returned, and it defaults to UTF-8 + def unescape(s, encoding = Encoding::UTF_8) + URI.decode_www_form_component(s, encoding) + end + + class << self + attr_accessor :multipart_total_part_limit + + attr_accessor :multipart_file_limit + + # multipart_part_limit is the original name of multipart_file_limit, but + # the limit only counts parts with filenames. + alias multipart_part_limit multipart_file_limit + alias multipart_part_limit= multipart_file_limit= + end + + # The maximum number of file parts a request can contain. Accepting too + # many parts can lead to the server running out of file handles. + # Set to `0` for no limit. + self.multipart_file_limit = (ENV['RACK_MULTIPART_PART_LIMIT'] || ENV['RACK_MULTIPART_FILE_LIMIT'] || 128).to_i + + # The maximum total number of parts a request can contain. Accepting too + # many can lead to excessive memory use and parsing time. + self.multipart_total_part_limit = (ENV['RACK_MULTIPART_TOTAL_PART_LIMIT'] || 4096).to_i + + def self.param_depth_limit + default_query_parser.param_depth_limit + end + + def self.param_depth_limit=(v) + self.default_query_parser = self.default_query_parser.new_depth_limit(v) + end + + if defined?(Process::CLOCK_MONOTONIC) + def clock_time + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + else + # :nocov: + def clock_time + Time.now.to_f + end + # :nocov: + end + + def parse_query(qs, d = nil, &unescaper) + Rack::Utils.default_query_parser.parse_query(qs, d, &unescaper) + end + + def parse_nested_query(qs, d = nil) + Rack::Utils.default_query_parser.parse_nested_query(qs, d) + end + + def build_query(params) + params.map { |k, v| + if v.class == Array + build_query(v.map { |x| [k, x] }) + else + v.nil? ? escape(k) : "#{escape(k)}=#{escape(v)}" + end + }.join("&") + end + + def build_nested_query(value, prefix = nil) + case value + when Array + value.map { |v| + build_nested_query(v, "#{prefix}[]") + }.join("&") + when Hash + value.map { |k, v| + build_nested_query(v, prefix ? "#{prefix}[#{k}]" : k) + }.delete_if(&:empty?).join('&') + when nil + escape(prefix) + else + raise ArgumentError, "value must be a Hash" if prefix.nil? + "#{escape(prefix)}=#{escape(value)}" + end + end + + def q_values(q_value_header) + q_value_header.to_s.split(',').map do |part| + value, parameters = part.split(';', 2).map(&:strip) + quality = 1.0 + if parameters && (md = /\Aq=([\d.]+)/.match(parameters)) + quality = md[1].to_f + end + [value, quality] + end + end + + def forwarded_values(forwarded_header) + return nil unless forwarded_header + forwarded_header = forwarded_header.to_s.gsub("\n", ";") + + forwarded_header.split(';').each_with_object({}) do |field, values| + field.split(',').each do |pair| + pair = pair.split('=').map(&:strip).join('=') + return nil unless pair =~ /\A(by|for|host|proto)="?([^"]+)"?\Z/i + (values[$1.downcase.to_sym] ||= []) << $2 + end + end + end + module_function :forwarded_values + + # Return best accept value to use, based on the algorithm + # in RFC 2616 Section 14. If there are multiple best + # matches (same specificity and quality), the value returned + # is arbitrary. + def best_q_match(q_value_header, available_mimes) + values = q_values(q_value_header) + + matches = values.map do |req_mime, quality| + match = available_mimes.find { |am| Rack::Mime.match?(am, req_mime) } + next unless match + [match, quality] + end.compact.sort_by do |match, quality| + (match.split('/', 2).count('*') * -10) + quality + end.last + matches&.first + end + + # Introduced in ERB 4.0. ERB::Escape is an alias for ERB::Utils which + # doesn't get monkey-patched by rails + if defined?(ERB::Escape) && ERB::Escape.instance_method(:html_escape) + define_method(:escape_html, ERB::Escape.instance_method(:html_escape)) + else + require 'cgi/escape' + # Escape ampersands, brackets and quotes to their HTML/XML entities. + def escape_html(string) + CGI.escapeHTML(string.to_s) + end + end + + def select_best_encoding(available_encodings, accept_encoding) + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html + + expanded_accept_encoding = [] + + accept_encoding.each do |m, q| + preference = available_encodings.index(m) || available_encodings.size + + if m == "*" + (available_encodings - accept_encoding.map(&:first)).each do |m2| + expanded_accept_encoding << [m2, q, preference] + end + else + expanded_accept_encoding << [m, q, preference] + end + end + + encoding_candidates = expanded_accept_encoding + .sort_by { |_, q, p| [-q, p] } + .map!(&:first) + + unless encoding_candidates.include?("identity") + encoding_candidates.push("identity") + end + + expanded_accept_encoding.each do |m, q| + encoding_candidates.delete(m) if q == 0.0 + end + + (encoding_candidates & available_encodings)[0] + end + + # :call-seq: + # parse_cookies_header(value) -> hash + # + # Parse cookies from the provided header +value+ according to RFC6265. The + # syntax for cookie headers only supports semicolons. Returns a map of + # cookie +key+ to cookie +value+. + # + # parse_cookies_header('myname=myvalue; max-age=0') + # # => {"myname"=>"myvalue", "max-age"=>"0"} + # + def parse_cookies_header(value) + return {} unless value + + value.split(/; */n).each_with_object({}) do |cookie, cookies| + next if cookie.empty? + key, value = cookie.split('=', 2) + cookies[key] = (unescape(value) rescue value) unless cookies.key?(key) + end + end + + # :call-seq: + # parse_cookies(env) -> hash + # + # Parse cookies from the provided request environment using + # parse_cookies_header. Returns a map of cookie +key+ to cookie +value+. + # + # parse_cookies({'HTTP_COOKIE' => 'myname=myvalue'}) + # # => {'myname' => 'myvalue'} + # + def parse_cookies(env) + parse_cookies_header env[HTTP_COOKIE] + end + + # A valid cookie key according to RFC2616. + # A can be any US-ASCII characters, except control characters, spaces, or tabs. It also must not contain a separator character like the following: ( ) < > @ , ; : \ " / [ ] ? = { }. + VALID_COOKIE_KEY = /\A[!#$%&'*+\-\.\^_`|~0-9a-zA-Z]+\z/.freeze + private_constant :VALID_COOKIE_KEY + + private def escape_cookie_key(key) + if key =~ VALID_COOKIE_KEY + key + else + warn "Cookie key #{key.inspect} is not valid according to RFC2616; it will be escaped. This behaviour is deprecated and will be removed in a future version of Rack.", uplevel: 2 + escape(key) + end + end + + # :call-seq: + # set_cookie_header(key, value) -> encoded string + # + # Generate an encoded string using the provided +key+ and +value+ suitable + # for the +set-cookie+ header according to RFC6265. The +value+ may be an + # instance of either +String+ or +Hash+. + # + # If the cookie +value+ is an instance of +Hash+, it considers the following + # cookie attribute keys: +domain+, +max_age+, +expires+ (must be instance + # of +Time+), +secure+, +http_only+, +same_site+ and +value+. For more + # details about the interpretation of these fields, consult + # [RFC6265 Section 5.2](https://datatracker.ietf.org/doc/html/rfc6265#section-5.2). + # + # An extra cookie attribute +escape_key+ can be provided to control whether + # or not the cookie key is URL encoded. If explicitly set to +false+, the + # cookie key name will not be url encoded (escaped). The default is +true+. + # + # set_cookie_header("myname", "myvalue") + # # => "myname=myvalue" + # + # set_cookie_header("myname", {value: "myvalue", max_age: 10}) + # # => "myname=myvalue; max-age=10" + # + def set_cookie_header(key, value) + case value + when Hash + key = escape_cookie_key(key) unless value[:escape_key] == false + domain = "; domain=#{value[:domain]}" if value[:domain] + path = "; path=#{value[:path]}" if value[:path] + max_age = "; max-age=#{value[:max_age]}" if value[:max_age] + expires = "; expires=#{value[:expires].httpdate}" if value[:expires] + secure = "; secure" if value[:secure] + httponly = "; httponly" if (value.key?(:httponly) ? value[:httponly] : value[:http_only]) + same_site = + case value[:same_site] + when false, nil + nil + when :none, 'None', :None + '; samesite=none' + when :lax, 'Lax', :Lax + '; samesite=lax' + when true, :strict, 'Strict', :Strict + '; samesite=strict' + else + raise ArgumentError, "Invalid :same_site value: #{value[:same_site].inspect}" + end + partitioned = "; partitioned" if value[:partitioned] + value = value[:value] + else + key = escape_cookie_key(key) + end + + value = [value] unless Array === value + + return "#{key}=#{value.map { |v| escape v }.join('&')}#{domain}" \ + "#{path}#{max_age}#{expires}#{secure}#{httponly}#{same_site}#{partitioned}" + end + + # :call-seq: + # set_cookie_header!(headers, key, value) -> header value + # + # Append a cookie in the specified headers with the given cookie +key+ and + # +value+ using set_cookie_header. + # + # If the headers already contains a +set-cookie+ key, it will be converted + # to an +Array+ if not already, and appended to. + def set_cookie_header!(headers, key, value) + if header = headers[SET_COOKIE] + if header.is_a?(Array) + header << set_cookie_header(key, value) + else + headers[SET_COOKIE] = [header, set_cookie_header(key, value)] + end + else + headers[SET_COOKIE] = set_cookie_header(key, value) + end + end + + # :call-seq: + # delete_set_cookie_header(key, value = {}) -> encoded string + # + # Generate an encoded string based on the given +key+ and +value+ using + # set_cookie_header for the purpose of causing the specified cookie to be + # deleted. The +value+ may be an instance of +Hash+ and can include + # attributes as outlined by set_cookie_header. The encoded cookie will have + # a +max_age+ of 0 seconds, an +expires+ date in the past and an empty + # +value+. When used with the +set-cookie+ header, it will cause the client + # to *remove* any matching cookie. + # + # delete_set_cookie_header("myname") + # # => "myname=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" + # + def delete_set_cookie_header(key, value = {}) + set_cookie_header(key, value.merge(max_age: '0', expires: Time.at(0), value: '')) + end + + def delete_cookie_header!(headers, key, value = {}) + headers[SET_COOKIE] = delete_set_cookie_header!(headers[SET_COOKIE], key, value) + + return nil + end + + # :call-seq: + # delete_set_cookie_header!(header, key, value = {}) -> header value + # + # Set an expired cookie in the specified headers with the given cookie + # +key+ and +value+ using delete_set_cookie_header. This causes + # the client to immediately delete the specified cookie. + # + # delete_set_cookie_header!(nil, "mycookie") + # # => "mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" + # + # If the header is non-nil, it will be modified in place. + # + # header = [] + # delete_set_cookie_header!(header, "mycookie") + # # => ["mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"] + # header + # # => ["mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"] + # + def delete_set_cookie_header!(header, key, value = {}) + if header + header = Array(header) + header << delete_set_cookie_header(key, value) + else + header = delete_set_cookie_header(key, value) + end + + return header + end + + def rfc2822(time) + time.rfc2822 + end + + # Parses the "Range:" header, if present, into an array of Range objects. + # Returns nil if the header is missing or syntactically invalid. + # Returns an empty array if none of the ranges are satisfiable. + def byte_ranges(env, size) + get_byte_ranges env['HTTP_RANGE'], size + end + + def get_byte_ranges(http_range, size) + # See + # Ignore Range when file size is 0 to avoid a 416 error. + return nil if size.zero? + return nil unless http_range && http_range =~ /bytes=([^;]+)/ + ranges = [] + $1.split(/,\s*/).each do |range_spec| + return nil unless range_spec.include?('-') + range = range_spec.split('-') + r0, r1 = range[0], range[1] + if r0.nil? || r0.empty? + return nil if r1.nil? + # suffix-byte-range-spec, represents trailing suffix of file + r0 = size - r1.to_i + r0 = 0 if r0 < 0 + r1 = size - 1 + else + r0 = r0.to_i + if r1.nil? + r1 = size - 1 + else + r1 = r1.to_i + return nil if r1 < r0 # backwards range is syntactically invalid + r1 = size - 1 if r1 >= size + end + end + ranges << (r0..r1) if r0 <= r1 + end + + return [] if ranges.map(&:size).sum > size + + ranges + end + + # :nocov: + if defined?(OpenSSL.fixed_length_secure_compare) + # Constant time string comparison. + # + # NOTE: the values compared should be of fixed length, such as strings + # that have already been processed by HMAC. This should not be used + # on variable length plaintext strings because it could leak length info + # via timing attacks. + def secure_compare(a, b) + return false unless a.bytesize == b.bytesize + + OpenSSL.fixed_length_secure_compare(a, b) + end + # :nocov: + else + def secure_compare(a, b) + return false unless a.bytesize == b.bytesize + + l = a.unpack("C*") + + r, i = 0, -1 + b.each_byte { |v| r |= v ^ l[i += 1] } + r == 0 + end + end + + # Context allows the use of a compatible middleware at different points + # in a request handling stack. A compatible middleware must define + # #context which should take the arguments env and app. The first of which + # would be the request environment. The second of which would be the rack + # application that the request would be forwarded to. + class Context + attr_reader :for, :app + + def initialize(app_f, app_r) + raise 'running context does not respond to #context' unless app_f.respond_to? :context + @for, @app = app_f, app_r + end + + def call(env) + @for.context(env, @app) + end + + def recontext(app) + self.class.new(@for, app) + end + + def context(env, app = @app) + recontext(app).call(env) + end + end + + # Every standard HTTP code mapped to the appropriate message. + # Generated with: + # curl -s https://www.iana.org/assignments/http-status-codes/http-status-codes-1.csv \ + # | ruby -rcsv -e "puts CSV.parse(STDIN, headers: true) \ + # .reject {|v| v['Description'] == 'Unassigned' or v['Description'].include? '(' } \ + # .map {|v| %Q/#{v['Value']} => '#{v['Description']}'/ }.join(','+?\n)" + HTTP_STATUS_CODES = { + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 103 => 'Early Hints', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', + 208 => 'Already Reported', + 226 => 'IM Used', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Content Too Large', + 414 => 'URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Range Not Satisfiable', + 417 => 'Expectation Failed', + 421 => 'Misdirected Request', + 422 => 'Unprocessable Content', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Too Early', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 511 => 'Network Authentication Required' + } + + # Responses with HTTP status codes that should not have an entity body + STATUS_WITH_NO_ENTITY_BODY = Hash[((100..199).to_a << 204 << 304).product([true])] + + SYMBOL_TO_STATUS_CODE = Hash[*HTTP_STATUS_CODES.map { |code, message| + [message.downcase.gsub(/\s|-/, '_').to_sym, code] + }.flatten] + + OBSOLETE_SYMBOLS_TO_STATUS_CODES = { + payload_too_large: 413, + unprocessable_entity: 422, + bandwidth_limit_exceeded: 509, + not_extended: 510 + }.freeze + private_constant :OBSOLETE_SYMBOLS_TO_STATUS_CODES + + OBSOLETE_SYMBOL_MAPPINGS = { + payload_too_large: :content_too_large, + unprocessable_entity: :unprocessable_content + }.freeze + private_constant :OBSOLETE_SYMBOL_MAPPINGS + + def status_code(status) + if status.is_a?(Symbol) + SYMBOL_TO_STATUS_CODE.fetch(status) do + fallback_code = OBSOLETE_SYMBOLS_TO_STATUS_CODES.fetch(status) { raise ArgumentError, "Unrecognized status code #{status.inspect}" } + message = "Status code #{status.inspect} is deprecated and will be removed in a future version of Rack." + if canonical_symbol = OBSOLETE_SYMBOL_MAPPINGS[status] + # message = "#{message} Please use #{canonical_symbol.inspect} instead." + # For now, let's not emit any warning when there is a mapping. + else + warn message, uplevel: 3 + end + fallback_code + end + else + status.to_i + end + end + + PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact) + + def clean_path_info(path_info) + parts = path_info.split PATH_SEPS + + clean = [] + + parts.each do |part| + next if part.empty? || part == '.' + part == '..' ? clean.pop : clean << part + end + + clean_path = clean.join(::File::SEPARATOR) + clean_path.prepend("/") if parts.empty? || parts.first.empty? + clean_path + end + + NULL_BYTE = "\0" + + def valid_path?(path) + path.valid_encoding? && !path.include?(NULL_BYTE) + end + + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/version.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/version.rb new file mode 100644 index 0000000..5b45e76 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/version.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Copyright (C) 2007-2019 Leah Neukirchen +# +# Rack is freely distributable under the terms of an MIT-style license. +# See MIT-LICENSE or https://opensource.org/licenses/MIT. + +# The Rack main module, serving as a namespace for all core Rack +# modules and classes. +# +# All modules meant for use in your application are autoloaded here, +# so it should be enough just to require 'rack' in your code. + +module Rack + RELEASE = "3.1.8" + + # Return the Rack release as a dotted string. + def self.release + RELEASE + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/rack.gemspec b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/rack.gemspec new file mode 100644 index 0000000..ed37415 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/rack.gemspec @@ -0,0 +1,31 @@ +# -*- encoding: utf-8 -*- +# stub: rack 3.1.8 ruby lib + +Gem::Specification.new do |s| + s.name = "rack".freeze + s.version = "3.1.8".freeze + + s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= + s.metadata = { "bug_tracker_uri" => "https://github.com/rack/rack/issues", "changelog_uri" => "https://github.com/rack/rack/blob/main/CHANGELOG.md", "documentation_uri" => "https://rubydoc.info/github/rack/rack", "source_code_uri" => "https://github.com/rack/rack" } if s.respond_to? :metadata= + s.require_paths = ["lib".freeze] + s.authors = ["Leah Neukirchen".freeze] + s.date = "2024-10-14" + s.description = "Rack provides a minimal, modular and adaptable interface for developing\nweb applications in Ruby. By wrapping HTTP requests and responses in\nthe simplest way possible, it unifies and distills the API for web\nservers, web frameworks, and software in between (the so-called\nmiddleware) into a single method call.\n".freeze + s.email = "leah@vuxu.org".freeze + s.extra_rdoc_files = ["README.md".freeze, "CHANGELOG.md".freeze, "CONTRIBUTING.md".freeze] + s.files = ["CHANGELOG.md".freeze, "CONTRIBUTING.md".freeze, "README.md".freeze] + s.homepage = "https://github.com/rack/rack".freeze + s.licenses = ["MIT".freeze] + s.required_ruby_version = Gem::Requirement.new(">= 2.4.0".freeze) + s.rubygems_version = "3.5.11".freeze + s.summary = "A modular Ruby webserver interface.".freeze + + s.installed_by_version = "3.5.22".freeze + + s.specification_version = 4 + + s.add_development_dependency(%q.freeze, ["~> 5.0".freeze]) + s.add_development_dependency(%q.freeze, [">= 0".freeze]) + s.add_development_dependency(%q.freeze, [">= 0".freeze]) + s.add_development_dependency(%q.freeze, [">= 0".freeze]) +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/.bundle/config b/spikes/gem-checksums/stale-checksum-v1-bug/before/.bundle/config new file mode 100644 index 0000000..6eb400d --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/.bundle/config @@ -0,0 +1,3 @@ +--- +BUNDLE_PATH: "vendor/bundle" +BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile b/spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile new file mode 100644 index 0000000..6d26ec6 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rack", "3.1.8", path: "./vendored/rack-3.1.8" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile.lock b/spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile.lock new file mode 100644 index 0000000..2cbbe92 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile.lock @@ -0,0 +1,21 @@ +PATH + remote: vendored/rack-3.1.8 + specs: + rack (3.1.8) + +GEM + remote: https://rubygems.org/ + specs: + +PLATFORMS + aarch64-linux + ruby + +DEPENDENCIES + rack (= 3.1.8)! + +CHECKSUMS + rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 + +BUNDLED WITH + 2.7.2 diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CHANGELOG.md b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CHANGELOG.md new file mode 100644 index 0000000..18069d3 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CHANGELOG.md @@ -0,0 +1,998 @@ +# Changelog + +All notable changes to this project will be documented in this file. For info on how to format all future additions to this file please reference [Keep A Changelog](https://keepachangelog.com/en/1.0.0/). + +## [3.1.8] - 2024-10-14 + +- Resolve deprecation warnings about uri `DEFAULT_PARSER`. ([#2249](https://github.com/rack/rack/pull/2249), [@earlopain]) + +## [3.1.7] - 2024-07-11 + +### Fixed + +- Do not remove escaped opening/closing quotes for content-disposition filenames. ([#2229](https://github.com/rack/rack/pull/2229), [@jeremyevans]) +- Fix encoding setting for non-binary IO-like objects in MockRequest#env_for. ([#2227](https://github.com/rack/rack/pull/2227), [@jeremyevans]) +- `Rack::Response` should not generate invalid `content-length` header. ([#2219](https://github.com/rack/rack/pull/2219), [@ioquatix]) +- Allow empty PATH_INFO. ([#2214](https://github.com/rack/rack/pull/2214), [@ioquatix]) + +## [3.1.6] - 2024-07-03 + +### Fixed + +- Fix several edge cases in `Rack::Request#parse_http_accept_header`'s implementation. ([#2226](https://github.com/rack/rack/pull/2226), [@ioquatix]) + +## [3.1.5] - 2024-07-02 + +### Security + +- Fix potential ReDoS attack in `Rack::Request#parse_http_accept_header`. ([GHSA-cj83-2ww7-mvq7](https://github.com/rack/rack/security/advisories/GHSA-cj83-2ww7-mvq7), [@dwisiswant0](https://github.com/dwisiswant0)) + +## [3.1.4] - 2024-06-22 + +### Fixed + +- Fix `Rack::Lint` matching some paths incorrectly as authority form. ([#2220](https://github.com/rack/rack/pull/2220), [@ioquatix]) + +## [3.1.3] - 2024-06-12 + +### Fixed + +- Fix passing non-strings to `Rack::Utils.escape_html`. ([#2202](https://github.com/rack/rack/pull/2202), [@earlopain]) +- `Rack::MockResponse` gracefully handles empty cookies ([#2203](https://github.com/rack/rack/pull/2203) [@wynksaiddestroy]) + +## [3.1.2] - 2024-06-11 + +- `Rack::Response` will take in to consideration chunked encoding responses ([#2204](https://github.com/rack/rack/pull/2204), [@tenderlove]) + +## [3.1.1] - 2024-06-11 + +- Oops! I shouldn't have shipped that + +## [3.1.0] - 2024-06-11 + +:warning: **This release includes several breaking changes.** Refer to the **Removed** section below for the list of deprecated methods that have been removed in this release. + +Rack v3.1 is primarily a maintenance release that removes features deprecated in Rack v3.0. Alongside these removals, there are several improvements to the Rack SPEC, mainly focused on enhancing input and output handling. These changes aim to make Rack more efficient and align better with the requirements of server implementations and relevant HTTP specifications. + +### SPEC Changes + +- `rack.input` is now optional. ([#1997](https://github.com/rack/rack/pull/1997), [#2018](https://github.com/rack/rack/pull/2018), [@ioquatix]) +- `PATH_INFO` is now validated according to the HTTP/1.1 specification. ([#2117](https://github.com/rack/rack/pull/2117), [#2181](https://github.com/rack/rack/pull/2181), [@ioquatix]) + - `OPTIONS *` is now accepted. ([#2114](https://github.com/rack/rack/pull/2114), [@doriantaylor](https://github.com/doriantaylor)) +- Introduce optional `rack.protocol` request and response header for handling connection upgrades. ([#1954](https://github.com/rack/rack/pull/1954), [@ioquatix]) + +### Added + +- Introduce `Rack::Multipart::MissingInputError` for improved handling of missing input in `#parse_multipart`. ([#2018](https://github.com/rack/rack/pull/2018), [@ioquatix]) +- Introduce `module Rack::BadRequest` which is included in multipart and query parser errors. ([#2019](https://github.com/rack/rack/pull/2019), [@ioquatix]) +- Add `.mjs` MIME type ([#2057](https://github.com/rack/rack/pull/2057), [@axilleas](https://github.com/axilleas)) +- `set_cookie_header` utility now supports the `partitioned` cookie attribute. This is required by Chrome in some embedded contexts. ([#2131](https://github.com/rack/rack/pull/2131), [@flavio-b](https://github.com/flavio-b)) +- Introduce `rack.early_hints` for sending `103 Early Hints` informational responses. ([#1831](https://github.com/rack/rack/pull/1831), [@casperisfine](https://github.com/casperisfine), [@jeremyevans]) + +### Changed + +- MIME type for JavaScript files (`.js`) changed from `application/javascript` to `text/javascript` ([`1bd0f15`](https://github.com/rack/rack/commit/1bd0f1597d8f4a90d47115f3e156a8ce7870c9c8), [@ioquatix]) +- Update MIME types associated to `.ttf`, `.woff`, `.woff2` and `.otf` extensions to use mondern `font/*` types. ([#2065](https://github.com/rack/rack/pull/2065), [@davidstosik]) +- `Rack::Utils.escape_html` is now delegated to `CGI.escapeHTML`. `'` is escaped to `#39;` instead of `#x27;`. (decimal vs hexadecimal) ([#2099](https://github.com/rack/rack/pull/2099), [@JunichiIto](https://github.com/JunichiIto)) +- Clarify use of `@buffered` and only update `content-length` when `Rack::Response#finish` is invoked. ([#2149](https://github.com/rack/rack/pull/2149), [@ioquatix]) + +### Deprecated + +- Deprecate automatic cache invalidation in `Request#{GET,POST}` ([#2073](https://github.com/rack/rack/pull/2073), [@jeremyevans]) +- Only cookie keys that are not valid according to the HTTP specifications are escaped. We are planning to deprecate this behaviour, so now a deprecation message will be emitted in this case. In the future, invalid cookie keys may not be accepted. ([#2191](https://github.com/rack/rack/pull/2191), [@ioquatix]) +- `Rack::Logger` is deprecated. ([#2197](https://github.com/rack/rack/pull/2197), [@ioquatix]) +- Add fallback lookup and deprecation warning for obsolete status symbols. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn](https://github.com/wtn)) +- Deprecate `Rack::Request#values_at`, use `request.params.values_at` instead ([#2183](https://github.com/rack/rack/pull/2183), [@ioquatix]) + +### Removed + +- Remove deprecated `Rack::Auth::Digest` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::Cascade::NotFound` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::Chunked` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::File`, use `Rack::Files` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::QueryParser` `key_space_limit` parameter with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::Response#header`, use `Rack::Response#headers` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated cookie methods from `Rack::Utils`: `add_cookie_to_header`, `make_delete_cookie_header`, `add_remove_cookie_to_header`. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::Utils::HeaderHash`. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove deprecated `Rack::VERSION`, `Rack::VERSION_STRING`, `Rack.version`, use `Rack.release` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) +- Remove non-standard status codes 306, 509, & 510 and update descriptions for 413, 422, & 451. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn](https://github.com/wtn)) +- Remove any dependency on `transfer-encoding: chunked`. ([#2195](https://github.com/rack/rack/pull/2195), [@ioquatix]) +- Remove deprecated `Rack::Request#[]`, use `request.params[key]` instead ([#2183](https://github.com/rack/rack/pull/2183), [@ioquatix]) + +### Fixed + +- In `Rack::Files`, ignore the `Range` header if served file is 0 bytes. ([#2159](https://github.com/rack/rack/pull/2159), [@zarqman]) + +## [3.0.11] - 2024-05-10 + +- Backport #2062 to 3-0-stable: Do not allow `BodyProxy` to respond to `to_str`, make `to_ary` call close . ([#2062](https://github.com/rack/rack/pull/2062), [@jeremyevans](https://github.com/jeremyevans)) + +## [3.0.10] - 2024-03-21 + +- Backport #2104 to 3-0-stable: Return empty when parsing a multi-part POST with only one end delimiter. ([#2164](https://github.com/rack/rack/pull/2164), [@JoeDupuis](https://github.com/JoeDupuis)) + +## [3.0.9.1] - 2024-02-21 + +### Security + +* [CVE-2024-26146] Fixed ReDoS in Accept header parsing +* [CVE-2024-25126] Fixed ReDoS in Content Type header parsing +* [CVE-2024-26141] Reject Range headers which are too large + +[CVE-2024-26146]: https://github.com/advisories/GHSA-54rr-7fvw-6x8f +[CVE-2024-25126]: https://github.com/advisories/GHSA-22f2-v57c-j9cx +[CVE-2024-26141]: https://github.com/advisories/GHSA-xj5v-6v4g-jfw6 + +## [3.0.9] - 2024-01-31 + +- Fix incorrect content-length header that was emitted when `Rack::Response#write` was used in some situations. ([#2150](https://github.com/rack/rack/pull/2150), [@mattbrictson](https://github.com/mattbrictson)) + +## [3.0.8] - 2023-06-14 + +- Fix some unused variable verbose warnings. ([#2084](https://github.com/rack/rack/pull/2084), [@jeremyevans], [@skipkayhil](https://github.com/skipkayhil)) + +## [3.0.7] - 2023-03-16 + +- Make query parameters without `=` have `nil` values. ([#2059](https://github.com/rack/rack/pull/2059), [@jeremyevans]) + +## [3.0.6.1] - 2023-03-13 + +### Security + +- [CVE-2023-27539] Avoid ReDoS in header parsing + +## [3.0.6] - 2023-03-13 + +- Add `QueryParser#missing_value` for handling missing values + tests. ([#2052](https://github.com/rack/rack/pull/2052), [@ioquatix]) + +## [3.0.5] - 2023-03-13 + +- Split form/query parsing into two steps. ([#2038](https://github.com/rack/rack/pull/2038), [@matthewd](https://github.com/matthewd)) + +## [3.0.4.2] - 2023-03-02 + +### Security + +- [CVE-2023-27530] Introduce multipart_total_part_limit to limit total parts + +## [3.0.4.1] - 2023-01-17 + +### Security + +- [CVE-2022-44571] Fix ReDoS vulnerability in multipart parser +- [CVE-2022-44570] Fix ReDoS in Rack::Utils.get_byte_ranges +- [CVE-2022-44572] Forbid control characters in attributes (also ReDoS) + +## [3.0.4] - 2023-01-17 + +- `Rack::Request#POST` should consistently raise errors. Cache errors that occur when invoking `Rack::Request#POST` so they can be raised again later. ([#2010](https://github.com/rack/rack/pull/2010), [@ioquatix]) +- Fix `Rack::Lint` error message for `HTTP_CONTENT_TYPE` and `HTTP_CONTENT_LENGTH`. ([#2007](https://github.com/rack/rack/pull/2007), [@byroot](https://github.com/byroot)) +- Extend `Rack::MethodOverride` to handle `QueryParser::ParamsTooDeepError` error. ([#2006](https://github.com/rack/rack/pull/2006), [@byroot](https://github.com/byroot)) + +## [3.0.3] - 2022-12-27 + +### Fixed + +- `Rack::URLMap` uses non-deprecated form of `Regexp.new`. ([#1998](https://github.com/rack/rack/pull/1998), [@weizheheng](https://github.com/weizheheng)) + +## [3.0.2] - 2022-12-05 + +### Fixed + +- `Utils.build_nested_query` URL-encodes nested field names including the square brackets. +- Allow `Rack::Response` to pass through streaming bodies. ([#1993](https://github.com/rack/rack/pull/1993), [@ioquatix]) + +## [3.0.1] - 2022-11-18 + +### Fixed + +- `MethodOverride` does not look for an override if a request does not include form/parseable data. +- `Rack::Lint::Wrapper` correctly handles `respond_to?` with `to_ary`, `each`, `call` and `to_path`, forwarding to the body. ([#1981](https://github.com/rack/rack/pull/1981), [@ioquatix]) + +## [3.0.0] - 2022-09-06 + +- No changes + +## [3.0.0.rc1] - 2022-09-04 + +### SPEC Changes + +- Stream argument must implement `<<` https://github.com/rack/rack/pull/1959 +- `close` may be called on `rack.input` https://github.com/rack/rack/pull/1956 +- `rack.response_finished` may be used for executing code after the response has been finished https://github.com/rack/rack/pull/1952 + +## [3.0.0.beta1] - 2022-08-08 + +### Security + +- Do not use semicolon as GET parameter separator. ([#1733](https://github.com/rack/rack/pull/1733), [@jeremyevans]) + +### SPEC Changes + +- Response array must now be non-frozen. +- Response `status` must now be an integer greater than or equal to 100. +- Response `headers` must now be an unfrozen hash. +- Response header keys can no longer include uppercase characters. +- Response header values can be an `Array` to handle multiple values (and no longer supports `\n` encoded headers). +- Response body can now respond to `#call` (streaming body) instead of `#each` (enumerable body), for the equivalent of response hijacking in previous versions. +- Middleware must no longer call `#each` on the body, but they can call `#to_ary` on the body if it responds to `#to_ary`. +- `rack.input` is no longer required to be rewindable. +- `rack.multithread`/`rack.multiprocess`/`rack.run_once`/`rack.version` are no longer required environment keys. +- `SERVER_PROTOCOL` is now a required environment key, matching the HTTP protocol used in the request. +- `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. +- `rack.hijack_io` has been removed completely. +- `rack.response_finished` is an optional environment key which contains an array of callable objects that must accept `#call(env, status, headers, error)` and are invoked after the response is finished (either successfully or unsuccessfully). +- It is okay to call `#close` on `rack.input` to indicate that you no longer need or care about the input. +- The stream argument supplied to the streaming body and hijack must support `#<<` for writing output. + +### Removed + +- Remove `rack.multithread`/`rack.multiprocess`/`rack.run_once`. These variables generally come too late to be useful. ([#1720](https://github.com/rack/rack/pull/1720), [@ioquatix], [@jeremyevans])) +- Remove deprecated Rack::Request::SCHEME_WHITELIST. ([@jeremyevans]) +- Remove internal cookie deletion using pattern matching, there are very few practical cases where it would be useful and browsers handle it correctly without us doing anything special. ([#1844](https://github.com/rack/rack/pull/1844), [@ioquatix]) +- Remove `rack.version` as it comes too late to be useful. ([#1938](https://github.com/rack/rack/pull/1938), [@ioquatix]) +- Extract `rackup` command, `Rack::Server`, `Rack::Handler`, `Rack::Lobster` and related code into a separate gem. ([#1937](https://github.com/rack/rack/pull/1937), [@ioquatix]) + +### Added + +- `Rack::Headers` added to support lower-case header keys. ([@jeremyevans]) +- `Rack::Utils#set_cookie_header` now supports `escape_key: false` to avoid key escaping. ([@jeremyevans]) +- `Rack::RewindableInput` supports size. ([@ahorek](https://github.com/ahorek)) +- `Rack::RewindableInput::Middleware` added for making `rack.input` rewindable. ([@jeremyevans]) +- The RFC 7239 Forwarded header is now supported and considered by default when looking for information on forwarding, falling back to the X-Forwarded-* headers. `Rack::Request.forwarded_priority` accessor has been added for configuring the priority of which header to check. ([#1423](https://github.com/rack/rack/issues/1423), [@jeremyevans]) +- Allow response headers to contain array of values. ([#1598](https://github.com/rack/rack/issues/1598), [@ioquatix]) +- Support callable body for explicit streaming support and clarify streaming response body behaviour. ([#1745](https://github.com/rack/rack/pull/1745), [@ioquatix], [#1748](https://github.com/rack/rack/pull/1748), [@wjordan]) +- Allow `Rack::Builder#run` to take a block instead of an argument. ([#1942](https://github.com/rack/rack/pull/1942), [@ioquatix]) +- Add `rack.response_finished` to `Rack::Lint`. ([#1802](https://github.com/rack/rack/pull/1802), [@BlakeWilliams], [#1952](https://github.com/rack/rack/pull/1952), [@ioquatix]) +- The stream argument must implement `#<<`. ([#1959](https://github.com/rack/rack/pull/1959), [@ioquatix]) + +### Changed + +- BREAKING CHANGE: Require `status` to be an Integer. ([#1662](https://github.com/rack/rack/pull/1662), [@olleolleolle](https://github.com/olleolleolle)) +- BREAKING CHANGE: Query parsing now treats parameters without `=` as having the empty string value instead of nil value, to conform to the URL spec. ([#1696](https://github.com/rack/rack/issues/1696), [@jeremyevans]) +- Relax validations around `Rack::Request#host` and `Rack::Request#hostname`. ([#1606](https://github.com/rack/rack/issues/1606), [@pvande](https://github.com/pvande)) +- Removed antiquated handlers: FCGI, LSWS, SCGI, Thin. ([#1658](https://github.com/rack/rack/pull/1658), [@ioquatix]) +- Removed options from `Rack::Builder.parse_file` and `Rack::Builder.load_file`. ([#1663](https://github.com/rack/rack/pull/1663), [@ioquatix]) +- `Rack::HTTP_VERSION` has been removed and the `HTTP_VERSION` env setting is no longer set in the CGI and Webrick handlers. ([#970](https://github.com/rack/rack/issues/970), [@jeremyevans]) +- `Rack::Request#[]` and `#[]=` now warn even in non-verbose mode. ([#1277](https://github.com/rack/rack/issues/1277), [@jeremyevans]) +- Decrease default allowed parameter recursion level from 100 to 32. ([#1640](https://github.com/rack/rack/issues/1640), [@jeremyevans]) +- Attempting to parse a multipart response with an empty body now raises Rack::Multipart::EmptyContentError. ([#1603](https://github.com/rack/rack/issues/1603), [@jeremyevans]) +- `Rack::Utils.secure_compare` uses OpenSSL's faster implementation if available. ([#1711](https://github.com/rack/rack/pull/1711), [@bdewater](https://github.com/bdewater)) +- `Rack::Request#POST` now caches an empty hash if input content type is not parseable. ([#749](https://github.com/rack/rack/pull/749), [@jeremyevans]) +- BREAKING CHANGE: Updated `trusted_proxy?` to match full 127.0.0.0/8 network. ([#1781](https://github.com/rack/rack/pull/1781), [@snbloch](https://github.com/snbloch)) +- Explicitly deprecate `Rack::File` which was an alias for `Rack::Files`. ([#1811](https://github.com/rack/rack/pull/1720), [@ioquatix]). +- Moved `Rack::Session` into [separate gem](https://github.com/rack/rack-session). ([#1805](https://github.com/rack/rack/pull/1805), [@ioquatix]) +- `rackup -D` option to daemonizes no longer changes the working directory to the root. ([#1813](https://github.com/rack/rack/pull/1813), [@jeremyevans]) +- The `x-forwarded-proto` header is now considered before the `x-forwarded-scheme` header for determining the forwarded protocol. `Rack::Request.x_forwarded_proto_priority` accessor has been added for configuring the priority of which header to check. ([#1809](https://github.com/rack/rack/issues/1809), [@jeremyevans]) +- `Rack::Request.forwarded_authority` (and methods that call it, such as `host`) now returns the last authority in the forwarded header, instead of the first, as earlier forwarded authorities can be forged by clients. This restores the Rack 2.1 behavior. ([#1829](https://github.com/rack/rack/issues/1809), [@jeremyevans]) +- Use lower case cookie attributes when creating cookies, and fold cookie attributes to lower case when reading cookies (specifically impacting `secure` and `httponly` attributes). ([#1849](https://github.com/rack/rack/pull/1849), [@ioquatix]) +- The response array must now be mutable (non-frozen) so middleware can modify it without allocating a new Array,therefore reducing object allocations. ([#1887](https://github.com/rack/rack/pull/1887), [#1927](https://github.com/rack/rack/pull/1927), [@amatsuda], [@ioquatix]) +- `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. `rack.hijack_io` is no longer required/specified. ([#1939](https://github.com/rack/rack/pull/1939), [@ioquatix]) +- Allow calling close on `rack.input`. ([#1956](https://github.com/rack/rack/pull/1956), [@ioquatix]) + +### Fixed + +- Make Rack::MockResponse handle non-hash headers. ([#1629](https://github.com/rack/rack/issues/1629), [@jeremyevans]) +- TempfileReaper now deletes temp files if application raises an exception. ([#1679](https://github.com/rack/rack/issues/1679), [@jeremyevans]) +- Handle cookies with values that end in '=' ([#1645](https://github.com/rack/rack/pull/1645), [@lukaso](https://github.com/lukaso)) +- Make `Rack::NullLogger` respond to `#fatal!` [@jeremyevans]) +- Fix multipart filename generation for filenames that contain spaces. Encode spaces as "%20" instead of "+" which will be decoded properly by the multipart parser. ([#1736](https://github.com/rack/rack/pull/1645), [@muirdm](https://github.com/muirdm)) +- `Rack::Request#scheme` returns `ws` or `wss` when one of the `X-Forwarded-Scheme` / `X-Forwarded-Proto` headers is set to `ws` or `wss`, respectively. ([#1730](https://github.com/rack/rack/issues/1730), [@erwanst](https://github.com/erwanst)) + +## [2.2.4] - 2022-06-30 + +- Better support for lower case headers in `Rack::ETag` middleware. ([#1919](https://github.com/rack/rack/pull/1919), [@ioquatix](https://github.com/ioquatix)) +- Use custom exception on params too deep error. ([#1838](https://github.com/rack/rack/pull/1838), [@simi](https://github.com/simi)) + +## [2.2.3.1] - 2022-05-27 + +### Security + +- [CVE-2022-30123] Fix shell escaping issue in Common Logger +- [CVE-2022-30122] Restrict parsing of broken MIME attachments + +## [2.2.3] - 2020-06-15 + +### Security + +- [[CVE-2020-8184](https://nvd.nist.gov/vuln/detail/CVE-2020-8184)] Do not allow percent-encoded cookie name to override existing cookie names. BREAKING CHANGE: Accessing cookie names that require URL encoding with decoded name no longer works. ([@fletchto99](https://github.com/fletchto99)) + +## [2.2.2] - 2020-02-11 + +### Fixed + +- Fix incorrect `Rack::Request#host` value. ([#1591](https://github.com/rack/rack/pull/1591), [@ioquatix]) +- Revert `Rack::Handler::Thin` implementation. ([#1583](https://github.com/rack/rack/pull/1583), [@jeremyevans]) +- Double assignment is still needed to prevent an "unused variable" warning. ([#1589](https://github.com/rack/rack/pull/1589), [@kamipo](https://github.com/kamipo)) +- Fix to handle same_site option for session pool. ([#1587](https://github.com/rack/rack/pull/1587), [@kamipo](https://github.com/kamipo)) + +## [2.2.1] - 2020-02-09 + +### Fixed + +- Rework `Rack::Request#ip` to handle empty `forwarded_for`. ([#1577](https://github.com/rack/rack/pull/1577), [@ioquatix]) + +## [2.2.0] - 2020-02-08 + +### SPEC Changes + +- `rack.session` request environment entry must respond to `to_hash` and return unfrozen Hash. ([@jeremyevans]) +- Request environment cannot be frozen. ([@jeremyevans]) +- CGI values in the request environment with non-ASCII characters must use ASCII-8BIT encoding. ([@jeremyevans]) +- Improve SPEC/lint relating to SERVER_NAME, SERVER_PORT and HTTP_HOST. ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix]) + +### Added + +- `rackup` supports multiple `-r` options and will require all arguments. ([@jeremyevans]) +- `Server` supports an array of paths to require for the `:require` option. ([@khotta](https://github.com/khotta)) +- `Files` supports multipart range requests. ([@fatkodima](https://github.com/fatkodima)) +- `Multipart::UploadedFile` supports an IO-like object instead of using the filesystem, using `:filename` and `:io` options. ([@jeremyevans]) +- `Multipart::UploadedFile` supports keyword arguments `:path`, `:content_type`, and `:binary` in addition to positional arguments. ([@jeremyevans]) +- `Static` supports a `:cascade` option for calling the app if there is no matching file. ([@jeremyevans]) +- `Session::Abstract::SessionHash#dig`. ([@jeremyevans]) +- `Response.[]` and `MockResponse.[]` for creating instances using status, headers, and body. ([@ioquatix]) +- Convenient cache and content type methods for `Rack::Response`. ([#1555](https://github.com/rack/rack/pull/1555), [@ioquatix]) + +### Changed + +- `Request#params` no longer rescues EOFError. ([@jeremyevans]) +- `Directory` uses a streaming approach, significantly improving time to first byte for large directories. ([@jeremyevans]) +- `Directory` no longer includes a Parent directory link in the root directory index. ([@jeremyevans]) +- `QueryParser#parse_nested_query` uses original backtrace when reraising exception with new class. ([@jeremyevans]) +- `ConditionalGet` follows RFC 7232 precedence if both If-None-Match and If-Modified-Since headers are provided. ([@jeremyevans]) +- `.ru` files supports the `frozen-string-literal` magic comment. ([@eregon](https://github.com/eregon)) +- Rely on autoload to load constants instead of requiring internal files, make sure to require 'rack' and not just 'rack/...'. ([@jeremyevans]) +- BREAKING CHANGE: `Etag` will continue sending ETag even if the response should not be cached. Streaming no longer works without a workaround, see [#1619](https://github.com/rack/rack/issues/1619#issuecomment-848460528). ([@henm](https://github.com/henm)) +- `Request#host_with_port` no longer includes a colon for a missing or empty port. ([@AlexWayfer](https://github.com/AlexWayfer)) +- All handlers uses keywords arguments instead of an options hash argument. ([@ioquatix]) +- `Files` handling of range requests no longer return a body that supports `to_path`, to ensure range requests are handled correctly. ([@jeremyevans]) +- `Multipart::Generator` only includes `Content-Length` for files with paths, and `Content-Disposition` `filename` if the `UploadedFile` instance has one. ([@jeremyevans]) +- `Request#ssl?` is true for the `wss` scheme (secure websockets). ([@jeremyevans]) +- `Rack::HeaderHash` is memoized by default. ([#1549](https://github.com/rack/rack/pull/1549), [@ioquatix]) +- `Rack::Directory` allow directory traversal inside root directory. ([#1417](https://github.com/rack/rack/pull/1417), [@ThomasSevestre](https://github.com/ThomasSevestre)) +- Sort encodings by server preference. ([#1184](https://github.com/rack/rack/pull/1184), [@ioquatix], [@wjordan](https://github.com/wjordan)) +- Rework host/hostname/authority implementation in `Rack::Request`. `#host` and `#host_with_port` have been changed to correctly return IPv6 addresses formatted with square brackets, as defined by [RFC3986](https://tools.ietf.org/html/rfc3986#section-3.2.2). ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix]) +- `Rack::Builder` parsing options on first `#\` line is deprecated. ([#1574](https://github.com/rack/rack/pull/1574), [@ioquatix]) + +### Removed + +- `Directory#path` as it was not used and always returned nil. ([@jeremyevans]) +- `BodyProxy#each` as it was only needed to work around a bug in Ruby <1.9.3. ([@jeremyevans]) +- `URLMap::INFINITY` and `URLMap::NEGATIVE_INFINITY`, in favor of `Float::INFINITY`. ([@ch1c0t](https://github.com/ch1c0t)) +- Deprecation of `Rack::File`. It will be deprecated again in rack 2.2 or 3.0. ([@rafaelfranca](https://github.com/rafaelfranca)) +- Support for Ruby 2.2 as it is well past EOL. ([@ioquatix]) +- Remove `Rack::Files#response_body` as the implementation was broken. ([#1153](https://github.com/rack/rack/pull/1153), [@ioquatix]) +- Remove `SERVER_ADDR` which was never part of the original SPEC. ([#1573](https://github.com/rack/rack/pull/1573), [@ioquatix]) + +### Fixed + +- `Directory` correctly handles root paths containing glob metacharacters. ([@jeremyevans]) +- `Cascade` uses a new response object for each call if initialized with no apps. ([@jeremyevans]) +- `BodyProxy` correctly delegates keyword arguments to the body object on Ruby 2.7+. ([@jeremyevans]) +- `BodyProxy#method` correctly handles methods delegated to the body object. ([@jeremyevans]) +- `Request#host` and `Request#host_with_port` handle IPv6 addresses correctly. ([@AlexWayfer](https://github.com/AlexWayfer)) +- `Lint` checks when response hijacking that `rack.hijack` is called with a valid object. ([@jeremyevans]) +- `Response#write` correctly updates `Content-Length` if initialized with a body. ([@jeremyevans]) +- `CommonLogger` includes `SCRIPT_NAME` when logging. ([@Erol](https://github.com/Erol)) +- `Utils.parse_nested_query` correctly handles empty queries, using an empty instance of the params class instead of a hash. ([@jeremyevans]) +- `Directory` correctly escapes paths in links. ([@yous](https://github.com/yous)) +- `Request#delete_cookie` and related `Utils` methods handle `:domain` and `:path` options in same call. ([@jeremyevans]) +- `Request#delete_cookie` and related `Utils` methods do an exact match on `:domain` and `:path` options. ([@jeremyevans]) +- `Static` no longer adds headers when a gzipped file request has a 304 response. ([@chooh](https://github.com/chooh)) +- `ContentLength` sets `Content-Length` response header even for bodies not responding to `to_ary`. ([@jeremyevans]) +- Thin handler supports options passed directly to `Thin::Controllers::Controller`. ([@jeremyevans]) +- WEBrick handler no longer ignores `:BindAddress` option. ([@jeremyevans]) +- `ShowExceptions` handles invalid POST data. ([@jeremyevans]) +- Basic authentication requires a password, even if the password is empty. ([@jeremyevans]) +- `Lint` checks response is array with 3 elements, per SPEC. ([@jeremyevans]) +- Support for using `:SSLEnable` option when using WEBrick handler. (Gregor Melhorn) +- Close response body after buffering it when buffering. ([@ioquatix]) +- Only accept `;` as delimiter when parsing cookies. ([@mrageh](https://github.com/mrageh)) +- `Utils::HeaderHash#clear` clears the name mapping as well. ([@raxoft](https://github.com/raxoft)) +- Support for passing `nil` `Rack::Files.new`, which notably fixes Rails' current `ActiveStorage::FileServer` implementation. ([@ioquatix]) + +### Documentation + +- CHANGELOG updates. ([@aupajo](https://github.com/aupajo)) +- Added [CONTRIBUTING](CONTRIBUTING.md). ([@dblock](https://github.com/dblock)) + +## [2.0.9] - 2020-02-08 + +- Handle case where session id key is requested but missing ([@jeremyevans]) +- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans]) +- Add support for `SameSite=None` cookie value. ([@hennikul](https://github.com/hennikul)) + +## [2.1.2] - 2020-01-27 + +- Fix multipart parser for some files to prevent denial of service ([@aiomaster](https://github.com/aiomaster)) +- Fix `Rack::Builder#use` with keyword arguments ([@kamipo](https://github.com/kamipo)) +- Skip deflating in Rack::Deflater if Content-Length is 0 ([@jeremyevans]) +- Remove `SessionHash#transform_keys`, no longer needed ([@pavel](https://github.com/pavel)) +- Add to_hash to wrap Hash and Session classes ([@oleh-demyanyuk](https://github.com/oleh-demyanyuk)) +- Handle case where session id key is requested but missing ([@jeremyevans]) + +## [2.1.1] - 2020-01-12 + +- Remove `Rack::Chunked` from `Rack::Server` default middleware. ([#1475](https://github.com/rack/rack/pull/1475), [@ioquatix]) +- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans]) + +## [2.1.0] - 2020-01-10 + +### Added + +- Add support for `SameSite=None` cookie value. ([@hennikul](https://github.com/hennikul)) +- Add trailer headers. ([@eileencodes](https://github.com/eileencodes)) +- Add MIME Types for video streaming. ([@styd](https://github.com/styd)) +- Add MIME Type for WASM. ([@buildrtech](https://github.com/buildrtech)) +- Add `Early Hints(103)` to status codes. ([@egtra](https://github.com/egtra)) +- Add `Too Early(425)` to status codes. ([@y-yagi]((https://github.com/y-yagi))) +- Add `Bandwidth Limit Exceeded(509)` to status codes. ([@CJKinni](https://github.com/CJKinni)) +- Add method for custom `ip_filter`. ([@svcastaneda](https://github.com/svcastaneda)) +- Add boot-time profiling capabilities to `rackup`. ([@tenderlove](https://github.com/tenderlove)) +- Add multi mapping support for `X-Accel-Mappings` header. ([@yoshuki](https://github.com/yoshuki)) +- Add `sync: false` option to `Rack::Deflater`. (Eric Wong) +- Add `Builder#freeze_app` to freeze application and all middleware instances. ([@jeremyevans]) +- Add API to extract cookies from `Rack::MockResponse`. ([@petercline](https://github.com/petercline)) + +### Changed + +- Don't propagate nil values from middleware. ([@ioquatix]) +- Lazily initialize the response body and only buffer it if required. ([@ioquatix]) +- Fix deflater zlib buffer errors on empty body part. ([@felixbuenemann](https://github.com/felixbuenemann)) +- Set `X-Accel-Redirect` to percent-encoded path. ([@diskkid](https://github.com/diskkid)) +- Remove unnecessary buffer growing when parsing multipart. ([@tainoe](https://github.com/tainoe)) +- Expand the root path in `Rack::Static` upon initialization. ([@rosenfeld](https://github.com/rosenfeld)) +- Make `ShowExceptions` work with binary data. ([@axyjo](https://github.com/axyjo)) +- Use buffer string when parsing multipart requests. ([@janko-m](https://github.com/janko-m)) +- Support optional UTF-8 Byte Order Mark (BOM) in config.ru. ([@mikegee](https://github.com/mikegee)) +- Handle `X-Forwarded-For` with optional port. ([@dpritchett](https://github.com/dpritchett)) +- Use `Time#httpdate` format for Expires, as proposed by RFC 7231. ([@nanaya](https://github.com/nanaya)) +- Make `Utils.status_code` raise an error when the status symbol is invalid instead of `500`. ([@adambutler](https://github.com/adambutler)) +- Rename `Request::SCHEME_WHITELIST` to `Request::ALLOWED_SCHEMES`. +- Make `Multipart::Parser.get_filename` accept files with `+` in their name. ([@lucaskanashiro](https://github.com/lucaskanashiro)) +- Add Falcon to the default handler fallbacks. ([@ioquatix]) +- Update codebase to avoid string mutations in preparation for `frozen_string_literals`. ([@pat](https://github.com/pat)) +- Change `MockRequest#env_for` to rely on the input optionally responding to `#size` instead of `#length`. ([@janko](https://github.com/janko)) +- Rename `Rack::File` -> `Rack::Files` and add deprecation notice. ([@postmodern](https://github.com/postmodern)) +- Prefer Base64 “strict encoding” for Base64 cookies. ([@ioquatix]) + +### Removed + +- BREAKING CHANGE: Remove `to_ary` from Response ([@tenderlove](https://github.com/tenderlove)) +- Deprecate `Rack::Session::Memcache` in favor of `Rack::Session::Dalli` from dalli gem ([@fatkodima](https://github.com/fatkodima)) + +### Fixed + +- Eliminate warnings for Ruby 2.7. ([@osamtimizer](https://github.com/osamtimizer])) + +### Documentation + +- Update broken example in `Session::Abstract::ID` documentation. ([tonytonyjan](https://github.com/tonytonyjan)) +- Add Padrino to the list of frameworks implementing Rack. ([@wikimatze](https://github.com/wikimatze)) +- Remove Mongrel from the suggested server options in the help output. ([@tricknotes](https://github.com/tricknotes)) +- Replace `HISTORY.md` and `NEWS.md` with `CHANGELOG.md`. ([@twitnithegirl](https://github.com/twitnithegirl)) +- CHANGELOG updates. ([@drenmi](https://github.com/Drenmi), [@p8](https://github.com/p8)) + +## [2.0.8] - 2019-12-08 + +### Security + +- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) + +## [1.6.12] - 2019-12-08 + +### Security + +- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) + +## [2.0.7] - 2019-04-02 + +### Fixed + +- Remove calls to `#eof?` on Rack input in `Multipart::Parser`, as this breaks the specification. ([@matthewd](https://github.com/matthewd)) +- Preserve forwarded IP addresses for trusted proxy chains. ([@SamSaffron](https://github.com/SamSaffron)) + +## [2.0.6] - 2018-11-05 + +### Fixed + +- [[CVE-2018-16470](https://nvd.nist.gov/vuln/detail/CVE-2018-16470)] Reduce buffer size of `Multipart::Parser` to avoid pathological parsing. ([@tenderlove](https://github.com/tenderlove)) +- Fix a call to a non-existing method `#accepts_html` in the `ShowExceptions` middleware. ([@tomelm](https://github.com/tomelm)) +- [[CVE-2018-16471](https://nvd.nist.gov/vuln/detail/CVE-2018-16471)] Whitelist HTTP and HTTPS schemes in `Request#scheme` to prevent a possible XSS attack. ([@PatrickTulskie](https://github.com/PatrickTulskie)) + +## [2.0.5] - 2018-04-23 + +### Fixed + +- Record errors originating from invalid UTF8 in `MethodOverride` middleware instead of breaking. ([@mclark](https://github.com/mclark)) + +## [2.0.4] - 2018-01-31 + +### Changed + +- Ensure the `Lock` middleware passes the original `env` object. ([@lugray](https://github.com/lugray)) +- Improve performance of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) +- Increase buffer size in `Multipart::Parser` for better performance. ([@jkowens](https://github.com/jkowens)) +- Reduce memory usage of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) +- Replace ConcurrentRuby dependency with native `Queue`. ([@devmchakan](https://github.com/devmchakan)) + +### Fixed + +- Require the correct digest algorithm in the `ETag` middleware. ([@matthewd](https://github.com/matthewd)) + +### Documentation + +- Update homepage links to use SSL. ([@hugoabonizio](https://github.com/hugoabonizio)) + +## [2.0.3] - 2017-05-15 + +### Changed + +- Ensure `env` values are ASCII 8-bit encoded. ([@eileencodes](https://github.com/eileencodes)) + +### Fixed + +- Prevent exceptions when a class with mixins inherits from `Session::Abstract::ID`. ([@jnraine](https://github.com/jnraine)) + +## [2.0.2] - 2017-05-08 + +### Added + +- Allow `Session::Abstract::SessionHash#fetch` to accept a block with a default value. ([@yannvanhalewyn](https://github.com/yannvanhalewyn)) +- Add `Builder#freeze_app` to freeze application and all middleware. ([@jeremyevans]) + +### Changed + +- Freeze default session options to avoid accidental mutation. ([@kirs](https://github.com/kirs)) +- Detect partial hijack without hash headers. ([@devmchakan](https://github.com/devmchakan)) +- Update tests to use MiniTest 6 matchers. ([@tonytonyjan](https://github.com/tonytonyjan)) +- Allow 205 Reset Content responses to set a Content-Length, as RFC 7231 proposes setting this to 0. ([@devmchakan](https://github.com/devmchakan)) + +### Fixed + +- Handle `NULL` bytes in multipart filenames. ([@casperisfine](https://github.com/casperisfine)) +- Remove warnings due to miscapitalized global. ([@ioquatix]) +- Prevent exceptions caused by a race condition on multi-threaded servers. ([@sophiedeziel](https://github.com/sophiedeziel)) +- Add RDoc as an explicit dependency for `doc` group. ([@tonytonyjan](https://github.com/tonytonyjan)) +- Record errors originating from `Multipart::Parser` in the `MethodOverride` middleware instead of letting them bubble up. ([@carlzulauf](https://github.com/carlzulauf)) +- Remove remaining use of removed `Utils#bytesize` method from the `File` middleware. ([@brauliomartinezlm](https://github.com/brauliomartinezlm)) + +### Removed + +- Remove `deflate` encoding support to reduce caching overhead. ([@devmchakan](https://github.com/devmchakan)) + +### Documentation + +- Update broken example in `Deflater` documentation. ([@mwpastore](https://github.com/mwpastore)) + +## [2.0.1] - 2016-06-30 + +### Changed + +- Remove JSON as an explicit dependency. ([@mperham](https://github.com/mperham)) + + +# History/News Archive +Items below this line are from the previously maintained HISTORY.md and NEWS.md files. + +## [2.0.0.rc1] 2016-05-06 +- Rack::Session::Abstract::ID is deprecated. Please change to use Rack::Session::Abstract::Persisted + +## [2.0.0.alpha] 2015-12-04 +- First-party "SameSite" cookies. Browsers omit SameSite cookies from third-party requests, closing the door on many CSRF attacks. +- Pass `same_site: true` (or `:strict`) to enable: response.set_cookie 'foo', value: 'bar', same_site: true or `same_site: :lax` to use Lax enforcement: response.set_cookie 'foo', value: 'bar', same_site: :lax +- Based on version 7 of the Same-site Cookies internet draft: + https://tools.ietf.org/html/draft-west-first-party-cookies-07 +- Thanks to Ben Toews (@mastahyeti) and Bob Long (@bobjflong) for updating to drafts 5 and 7. +- Add `Rack::Events` middleware for adding event based middleware: middleware that does not care about the response body, but only cares about doing work at particular points in the request / response lifecycle. +- Add `Rack::Request#authority` to calculate the authority under which the response is being made (this will be handy for h2 pushes). +- Add `Rack::Response::Helpers#cache_control` and `cache_control=`. Use this for setting cache control headers on your response objects. +- Add `Rack::Response::Helpers#etag` and `etag=`. Use this for setting etag values on the response. +- Introduce `Rack::Response::Helpers#add_header` to add a value to a multi-valued response header. Implemented in terms of other `Response#*_header` methods, so it's available to any response-like class that includes the `Helpers` module. +- Add `Rack::Request#add_header` to match. +- `Rack::Session::Abstract::ID` IS DEPRECATED. Please switch to `Rack::Session::Abstract::Persisted`. `Rack::Session::Abstract::Persisted` uses a request object rather than the `env` hash. +- Pull `ENV` access inside the request object in to a module. This will help with legacy Request objects that are ENV based but don't want to inherit from Rack::Request +- Move most methods on the `Rack::Request` to a module `Rack::Request::Helpers` and use public API to get values from the request object. This enables users to mix `Rack::Request::Helpers` in to their own objects so they can implement `(get|set|fetch|each)_header` as they see fit (for example a proxy object). +- Files and directories with + in the name are served correctly. Rather than unescaping paths like a form, we unescape with a URI parser using `Rack::Utils.unescape_path`. Fixes #265 +- Tempfiles are automatically closed in the case that there were too + many posted. +- Added methods for manipulating response headers that don't assume + they're stored as a Hash. Response-like classes may include the + Rack::Response::Helpers module if they define these methods: + - Rack::Response#has_header? + - Rack::Response#get_header + - Rack::Response#set_header + - Rack::Response#delete_header +- Introduce Util.get_byte_ranges that will parse the value of the HTTP_RANGE string passed to it without depending on the `env` hash. `byte_ranges` is deprecated in favor of this method. +- Change Session internals to use Request objects for looking up session information. This allows us to only allocate one request object when dealing with session objects (rather than doing it every time we need to manipulate cookies, etc). +- Add `Rack::Request#initialize_copy` so that the env is duped when the request gets duped. +- Added methods for manipulating request specific data. This includes + data set as CGI parameters, and just any arbitrary data the user wants + to associate with a particular request. New methods: + - Rack::Request#has_header? + - Rack::Request#get_header + - Rack::Request#fetch_header + - Rack::Request#each_header + - Rack::Request#set_header + - Rack::Request#delete_header +- lib/rack/utils.rb: add a method for constructing "delete" cookie + headers. This allows us to construct cookie headers without depending + on the side effects of mutating a hash. +- Prevent extremely deep parameters from being parsed. CVE-2015-3225 + +## [1.6.1] 2015-05-06 + - Fix CVE-2014-9490, denial of service attack in OkJson + - Use a monotonic time for Rack::Runtime, if available + - RACK_MULTIPART_LIMIT changed to RACK_MULTIPART_PART_LIMIT (RACK_MULTIPART_LIMIT is deprecated and will be removed in 1.7.0) + +## [1.5.3] 2015-05-06 + - Fix CVE-2014-9490, denial of service attack in OkJson + - Backport bug fixes to 1.5 series + +## [1.6.0] 2014-01-18 + - Response#unauthorized? helper + - Deflater now accepts an options hash to control compression on a per-request level + - Builder#warmup method for app preloading + - Request#accept_language method to extract HTTP_ACCEPT_LANGUAGE + - Add quiet mode of rack server, rackup --quiet + - Update HTTP Status Codes to RFC 7231 + - Less strict header name validation according to RFC 2616 + - SPEC updated to specify headers conform to RFC7230 specification + - Etag correctly marks etags as weak + - Request#port supports multiple x-http-forwarded-proto values + - Utils#multipart_part_limit configures the maximum number of parts a request can contain + - Default host to localhost when in development mode + - Various bugfixes and performance improvements + +## [1.5.2] 2013-02-07 + - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie + - Fix CVE-2013-0262, symlink path traversal in Rack::File + - Add various methods to Session for enhanced Rails compatibility + - Request#trusted_proxy? now only matches whole strings + - Add JSON cookie coder, to be default in Rack 1.6+ due to security concerns + - URLMap host matching in environments that don't set the Host header fixed + - Fix a race condition that could result in overwritten pidfiles + - Various documentation additions + +## [1.4.5] 2013-02-07 + - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie + - Fix CVE-2013-0262, symlink path traversal in Rack::File + +## [1.1.6, 1.2.8, 1.3.10] 2013-02-07 + - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie + +## [1.5.1] 2013-01-28 + - Rack::Lint check_hijack now conforms to other parts of SPEC + - Added hash-like methods to Abstract::ID::SessionHash for compatibility + - Various documentation corrections + +## [1.5.0] 2013-01-21 + - Introduced hijack SPEC, for before-response and after-response hijacking + - SessionHash is no longer a Hash subclass + - Rack::File cache_control parameter is removed, in place of headers options + - Rack::Auth::AbstractRequest#scheme now yields strings, not symbols + - Rack::Utils cookie functions now format expires in RFC 2822 format + - Rack::File now has a default mime type + - rackup -b 'run Rack::Files.new(".")', option provides command line configs + - Rack::Deflater will no longer double encode bodies + - Rack::Mime#match? provides convenience for Accept header matching + - Rack::Utils#q_values provides splitting for Accept headers + - Rack::Utils#best_q_match provides a helper for Accept headers + - Rack::Handler.pick provides convenience for finding available servers + - Puma added to the list of default servers (preferred over Webrick) + - Various middleware now correctly close body when replacing it + - Rack::Request#params is no longer persistent with only GET params + - Rack::Request#update_param and #delete_param provide persistent operations + - Rack::Request#trusted_proxy? now returns true for local unix sockets + - Rack::Response no longer forces Content-Types + - Rack::Sendfile provides local mapping configuration options + - Rack::Utils#rfc2109 provides old netscape style time output + - Updated HTTP status codes + - Ruby 1.8.6 likely no longer passes tests, and is no longer fully supported + +## [1.4.4, 1.3.9, 1.2.7, 1.1.5] 2013-01-13 + - [SEC] Rack::Auth::AbstractRequest no longer symbolizes arbitrary strings + - Fixed erroneous test case in the 1.3.x series + +## [1.4.3] 2013-01-07 + - Security: Prevent unbounded reads in large multipart boundaries + +## [1.3.8] 2013-01-07 + - Security: Prevent unbounded reads in large multipart boundaries + +## [1.4.2] 2013-01-06 + - Add warnings when users do not provide a session secret + - Fix parsing performance for unquoted filenames + - Updated URI backports + - Fix URI backport version matching, and silence constant warnings + - Correct parameter parsing with empty values + - Correct rackup '-I' flag, to allow multiple uses + - Correct rackup pidfile handling + - Report rackup line numbers correctly + - Fix request loops caused by non-stale nonces with time limits + - Fix reloader on Windows + - Prevent infinite recursions from Response#to_ary + - Various middleware better conforms to the body close specification + - Updated language for the body close specification + - Additional notes regarding ECMA escape compatibility issues + - Fix the parsing of multiple ranges in range headers + - Prevent errors from empty parameter keys + - Added PATCH verb to Rack::Request + - Various documentation updates + - Fix session merge semantics (fixes rack-test) + - Rack::Static :index can now handle multiple directories + - All tests now utilize Rack::Lint (special thanks to Lars Gierth) + - Rack::File cache_control parameter is now deprecated, and removed by 1.5 + - Correct Rack::Directory script name escaping + - Rack::Static supports header rules for sophisticated configurations + - Multipart parsing now works without a Content-Length header + - New logos courtesy of Zachary Scott! + - Rack::BodyProxy now explicitly defines #each, useful for C extensions + - Cookies that are not URI escaped no longer cause exceptions + +## [1.3.7] 2013-01-06 + - Add warnings when users do not provide a session secret + - Fix parsing performance for unquoted filenames + - Updated URI backports + - Fix URI backport version matching, and silence constant warnings + - Correct parameter parsing with empty values + - Correct rackup '-I' flag, to allow multiple uses + - Correct rackup pidfile handling + - Report rackup line numbers correctly + - Fix request loops caused by non-stale nonces with time limits + - Fix reloader on Windows + - Prevent infinite recursions from Response#to_ary + - Various middleware better conforms to the body close specification + - Updated language for the body close specification + - Additional notes regarding ECMA escape compatibility issues + - Fix the parsing of multiple ranges in range headers + +## [1.2.6] 2013-01-06 + - Add warnings when users do not provide a session secret + - Fix parsing performance for unquoted filenames + +## [1.1.4] 2013-01-06 + - Add warnings when users do not provide a session secret + +## [1.4.1] 2012-01-22 + - Alter the keyspace limit calculations to reduce issues with nested params + - Add a workaround for multipart parsing where files contain unescaped "%" + - Added Rack::Response::Helpers#method_not_allowed? (code 405) + - Rack::File now returns 404 for illegal directory traversals + - Rack::File now returns 405 for illegal methods (non HEAD/GET) + - Rack::Cascade now catches 405 by default, as well as 404 + - Cookies missing '--' no longer cause an exception to be raised + - Various style changes and documentation spelling errors + - Rack::BodyProxy always ensures to execute its block + - Additional test coverage around cookies and secrets + - Rack::Session::Cookie can now be supplied either secret or old_secret + - Tests are no longer dependent on set order + - Rack::Static no longer defaults to serving index files + - Rack.release was fixed + +## [1.4.0] 2011-12-28 + - Ruby 1.8.6 support has officially been dropped. Not all tests pass. + - Raise sane error messages for broken config.ru + - Allow combining run and map in a config.ru + - Rack::ContentType will not set Content-Type for responses without a body + - Status code 205 does not send a response body + - Rack::Response::Helpers will not rely on instance variables + - Rack::Utils.build_query no longer outputs '=' for nil query values + - Various mime types added + - Rack::MockRequest now supports HEAD + - Rack::Directory now supports files that contain RFC3986 reserved chars + - Rack::File now only supports GET and HEAD requests + - Rack::Server#start now passes the block to Rack::Handler::#run + - Rack::Static now supports an index option + - Added the Teapot status code + - rackup now defaults to Thin instead of Mongrel (if installed) + - Support added for HTTP_X_FORWARDED_SCHEME + - Numerous bug fixes, including many fixes for new and alternate rubies + +## [1.1.3] 2011-12-28 + - Security fix. http://www.ocert.org/advisories/ocert-2011-003.html + Further information here: http://jruby.org/2011/12/27/jruby-1-6-5-1 + +## [1.3.5] 2011-10-17 + - Fix annoying warnings caused by the backport in 1.3.4 + +## [1.3.4] 2011-10-01 + - Backport security fix from 1.9.3, also fixes some roundtrip issues in URI + - Small documentation update + - Fix an issue where BodyProxy could cause an infinite recursion + - Add some supporting files for travis-ci + +## [1.2.4] 2011-09-16 + - Fix a bug with MRI regex engine to prevent XSS by malformed unicode + +## [1.3.3] 2011-09-16 + - Fix bug with broken query parameters in Rack::ShowExceptions + - Rack::Request#cookies no longer swallows exceptions on broken input + - Prevents XSS attacks enabled by bug in Ruby 1.8's regexp engine + - Rack::ConditionalGet handles broken If-Modified-Since helpers + +## [1.3.2] 2011-07-16 + - Fix for Rails and rack-test, Rack::Utils#escape calls to_s + +## [1.3.1] 2011-07-13 + - Fix 1.9.1 support + - Fix JRuby support + - Properly handle $KCODE in Rack::Utils.escape + - Make method_missing/respond_to behavior consistent for Rack::Lock, + Rack::Auth::Digest::Request and Rack::Multipart::UploadedFile + - Reenable passing rack.session to session middleware + - Rack::CommonLogger handles streaming responses correctly + - Rack::MockResponse calls close on the body object + - Fix a DOS vector from MRI stdlib backport + +## [1.2.3] 2011-05-22 + - Pulled in relevant bug fixes from 1.3 + - Fixed 1.8.6 support + +## [1.3.0] 2011-05-22 + - Various performance optimizations + - Various multipart fixes + - Various multipart refactors + - Infinite loop fix for multipart + - Test coverage for Rack::Server returns + - Allow files with '..', but not path components that are '..' + - rackup accepts handler-specific options on the command line + - Request#params no longer merges POST into GET (but returns the same) + - Use URI.encode_www_form_component instead. Use core methods for escaping. + - Allow multi-line comments in the config file + - Bug L#94 reported by Nikolai Lugovoi, query parameter unescaping. + - Rack::Response now deletes Content-Length when appropriate + - Rack::Deflater now supports streaming + - Improved Rack::Handler loading and searching + - Support for the PATCH verb + - env['rack.session.options'] now contains session options + - Cookies respect renew + - Session middleware uses SecureRandom.hex + +## [1.2.2, 1.1.2] 2011-03-13 + - Security fix in Rack::Auth::Digest::MD5: when authenticator + returned nil, permission was granted on empty password. + +## [1.2.1] 2010-06-15 + - Make CGI handler rewindable + - Rename spec/ to test/ to not conflict with SPEC on lesser + operating systems + +## [1.2.0] 2010-06-13 + - Removed Camping adapter: Camping 2.0 supports Rack as-is + - Removed parsing of quoted values + - Add Request.trace? and Request.options? + - Add mime-type for .webm and .htc + - Fix HTTP_X_FORWARDED_FOR + - Various multipart fixes + - Switch test suite to bacon + +## [1.1.0] 2010-01-03 + - Moved Auth::OpenID to rack-contrib. + - SPEC change that relaxes Lint slightly to allow subclasses of the + required types + - SPEC change to document rack.input binary mode in greater detail + - SPEC define optional rack.logger specification + - File servers support X-Cascade header + - Imported Config middleware + - Imported ETag middleware + - Imported Runtime middleware + - Imported Sendfile middleware + - New Logger and NullLogger middlewares + - Added mime type for .ogv and .manifest. + - Don't squeeze PATH_INFO slashes + - Use Content-Type to determine POST params parsing + - Update Rack::Utils::HTTP_STATUS_CODES hash + - Add status code lookup utility + - Response should call #to_i on the status + - Add Request#user_agent + - Request#host knows about forwarded host + - Return an empty string for Request#host if HTTP_HOST and + SERVER_NAME are both missing + - Allow MockRequest to accept hash params + - Optimizations to HeaderHash + - Refactored rackup into Rack::Server + - Added Utils.build_nested_query to complement Utils.parse_nested_query + - Added Utils::Multipart.build_multipart to complement + Utils::Multipart.parse_multipart + - Extracted set and delete cookie helpers into Utils so they can be + used outside Response + - Extract parse_query and parse_multipart in Request so subclasses + can change their behavior + - Enforce binary encoding in RewindableInput + - Set correct external_encoding for handlers that don't use RewindableInput + +## [1.0.1] 2009-10-18 + - Bump remainder of rack.versions. + - Support the pure Ruby FCGI implementation. + - Fix for form names containing "=": split first then unescape components + - Fixes the handling of the filename parameter with semicolons in names. + - Add anchor to nested params parsing regexp to prevent stack overflows + - Use more compatible gzip write api instead of "<<". + - Make sure that Reloader doesn't break when executed via ruby -e + - Make sure WEBrick respects the :Host option + - Many Ruby 1.9 fixes. + +## [1.0.0] 2009-04-25 + - SPEC change: Rack::VERSION has been pushed to [1,0]. + - SPEC change: header values must be Strings now, split on "\n". + - SPEC change: Content-Length can be missing, in this case chunked transfer + encoding is used. + - SPEC change: rack.input must be rewindable and support reading into + a buffer, wrap with Rack::RewindableInput if it isn't. + - SPEC change: rack.session is now specified. + - SPEC change: Bodies can now additionally respond to #to_path with + a filename to be served. + - NOTE: String bodies break in 1.9, use an Array consisting of a + single String instead. + - New middleware Rack::Lock. + - New middleware Rack::ContentType. + - Rack::Reloader has been rewritten. + - Major update to Rack::Auth::OpenID. + - Support for nested parameter parsing in Rack::Response. + - Support for redirects in Rack::Response. + - HttpOnly cookie support in Rack::Response. + - The Rakefile has been rewritten. + - Many bugfixes and small improvements. + +## [0.9.1] 2009-01-09 + - Fix directory traversal exploits in Rack::File and Rack::Directory. + +## [0.9] 2009-01-06 + - Rack is now managed by the Rack Core Team. + - Rack::Lint is stricter and follows the HTTP RFCs more closely. + - Added ConditionalGet middleware. + - Added ContentLength middleware. + - Added Deflater middleware. + - Added Head middleware. + - Added MethodOverride middleware. + - Rack::Mime now provides popular MIME-types and their extension. + - Mongrel Header now streams. + - Added Thin handler. + - Official support for swiftiplied Mongrel. + - Secure cookies. + - Made HeaderHash case-preserving. + - Many bugfixes and small improvements. + +## [0.4] 2008-08-21 + - New middleware, Rack::Deflater, by Christoffer Sawicki. + - OpenID authentication now needs ruby-openid 2. + - New Memcache sessions, by blink. + - Explicit EventedMongrel handler, by Joshua Peek + - Rack::Reloader is not loaded in rackup development mode. + - rackup can daemonize with -D. + - Many bugfixes, especially for pool sessions, URLMap, thread safety + and tempfile handling. + - Improved tests. + - Rack moved to Git. + +## [0.3] 2008-02-26 + - LiteSpeed handler, by Adrian Madrid. + - SCGI handler, by Jeremy Evans. + - Pool sessions, by blink. + - OpenID authentication, by blink. + - :Port and :File options for opening FastCGI sockets, by blink. + - Last-Modified HTTP header for Rack::File, by blink. + - Rack::Builder#use now accepts blocks, by Corey Jewett. + (See example/protectedlobster.ru) + - HTTP status 201 can contain a Content-Type and a body now. + - Many bugfixes, especially related to Cookie handling. + +## [0.2] 2007-05-16 + - HTTP Basic authentication. + - Cookie Sessions. + - Static file handler. + - Improved Rack::Request. + - Improved Rack::Response. + - Added Rack::ShowStatus, for better default error messages. + - Bug fixes in the Camping adapter. + - Removed Rails adapter, was too alpha. + +## [0.1] 2007-03-03 + +[@ioquatix]: https://github.com/ioquatix "Samuel Williams" +[@jeremyevans]: https://github.com/jeremyevans "Jeremy Evans" +[@amatsuda]: https://github.com/amatsuda "Akira Matsuda" +[@wjordan]: https://github.com/wjordan "Will Jordan" +[@BlakeWilliams]: https://github.com/BlakeWilliams "Blake Williams" +[@davidstosik]: https://github.com/davidstosik "David Stosik" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CONTRIBUTING.md b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CONTRIBUTING.md new file mode 100644 index 0000000..a95263d --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CONTRIBUTING.md @@ -0,0 +1,144 @@ +# Contributing to Rack + +Rack is work of [hundreds of +contributors](https://github.com/rack/rack/graphs/contributors). You're +encouraged to submit [pull requests](https://github.com/rack/rack/pulls) and +[propose features and discuss issues](https://github.com/rack/rack/issues). + +## Backports + +Only security patches are ideal for backporting to non-main release versions. If +you're not sure if your bug fix is backportable, you should open a discussion to +discuss it first. + +The [Security Policy] documents which release versions will receive security +backports. + +## Fork the Project + +Fork the [project on GitHub](https://github.com/rack/rack) and check out your +copy. + +``` +git clone https://github.com/(your-github-username)/rack.git +cd rack +git remote add upstream https://github.com/rack/rack.git +``` + +## Create a Topic Branch + +Make sure your fork is up-to-date and create a topic branch for your feature or +bug fix. + +``` +git checkout main +git pull upstream main +git checkout -b my-feature-branch +``` + +## Running All Tests + +Install all dependencies. + +``` +bundle install +``` + +Run all tests. + +``` +rake test +``` + +## Write Tests + +Try to write a test that reproduces the problem you're trying to fix or +describes a feature that you want to build. + +We definitely appreciate pull requests that highlight or reproduce a problem, +even without a fix. + +## Write Code + +Implement your feature or bug fix. + +Make sure that all tests pass: + +``` +bundle exec rake test +``` + +## Write Documentation + +Document any external behavior in the [README](README.md). + +## Update Changelog + +Add a line to [CHANGELOG](CHANGELOG.md). + +## Commit Changes + +Make sure git knows your name and email address: + +``` +git config --global user.name "Your Name" +git config --global user.email "contributor@example.com" +``` + +Writing good commit logs is important. A commit log should describe what changed +and why. + +``` +git add ... +git commit +``` + +## Push + +``` +git push origin my-feature-branch +``` + +## Make a Pull Request + +Go to your fork of rack on GitHub and select your feature branch. Click the +'Pull Request' button and fill out the form. Pull requests are usually +reviewed within a few days. + +## Rebase + +If you've been working on a change for a while, rebase with upstream/main. + +``` +git fetch upstream +git rebase upstream/main +git push origin my-feature-branch -f +``` + +## Make Required Changes + +Amend your previous commit and force push the changes. + +``` +git commit --amend +git push origin my-feature-branch -f +``` + +## Check on Your Pull Request + +Go back to your pull request after a few minutes and see whether it passed +tests with GitHub Actions. Everything should look green, otherwise fix issues and +amend your commit as described above. + +## Be Patient + +It's likely that your change will not be merged and that the nitpicky +maintainers will ask you to do more, or fix seemingly benign problems. Hang in +there! + +## Thank You + +Please do know that we really appreciate and value your time and work. We love +you, really. + +[Security Policy]: SECURITY.md diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/MIT-LICENSE b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/MIT-LICENSE new file mode 100644 index 0000000..fb33b7f --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/MIT-LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (C) 2007-2021 Leah Neukirchen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/README.md b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/README.md new file mode 100644 index 0000000..3a197b1 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/README.md @@ -0,0 +1,328 @@ +# ![Rack](contrib/logo.webp) + +Rack provides a minimal, modular, and adaptable interface for developing web +applications in Ruby. By wrapping HTTP requests and responses in the simplest +way possible, it unifies and distills the bridge between web servers, web +frameworks, and web application into a single method call. + +The exact details of this are described in the [Rack Specification], which all +Rack applications should conform to. + +## Version support + +| Version | Support | +|----------|------------------------------------| +| 3.0.x | Bug fixes and security patches. | +| 2.2.x | Security patches only. | +| <= 2.1.x | End of support. | + +Please see the [Security Policy] for more information. + +## Rack 3.0 + +This is the latest version of Rack. It contains API improvements but also some +breaking changes. Please check the [Upgrade Guide](UPGRADE-GUIDE.md) for more +details about migrating servers, middlewares and applications designed for Rack 2 +to Rack 3. For detailed information on specific changes, check the [Change Log](CHANGELOG.md). + +## Rack 2.2 + +This version of Rack is receiving security patches only, and effort should be +made to move to Rack 3. + +Starting in Ruby 3.4 the `base64` dependency will no longer be a default gem, +and may cause a warning or error about `base64` being missing. To correct this, +add `base64` as a dependency to your project. + +## Installation + +Add the rack gem to your application bundle, or follow the instructions provided +by a [supported web framework](#supported-web-frameworks): + +```bash +# Install it generally: +$ gem install rack + +# or, add it to your current application gemfile: +$ bundle add rack +``` + +If you need features from `Rack::Session` or `bin/rackup` please add those gems separately. + +```bash +$ gem install rack-session rackup +``` + +## Usage + +Create a file called `config.ru` with the following contents: + +```ruby +run do |env| + [200, {}, ["Hello World"]] +end +``` + +Run this using the rackup gem or another [supported web +server](#supported-web-servers). + +```bash +$ gem install rackup +$ rackup +$ curl http://localhost:9292 +Hello World +``` + +## Supported web servers + +Rack is supported by a wide range of servers, including: + +* [Agoo](https://github.com/ohler55/agoo) +* [Falcon](https://github.com/socketry/falcon) +* [Iodine](https://github.com/boazsegev/iodine) +* [NGINX Unit](https://unit.nginx.org/) +* [Phusion Passenger](https://www.phusionpassenger.com/) (which is mod_rack for + Apache and for nginx) +* [Puma](https://puma.io/) +* [Thin](https://github.com/macournoyer/thin) +* [Unicorn](https://yhbt.net/unicorn/) +* [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) +* [Lamby](https://lamby.custominktech.com) (for AWS Lambda) + +You will need to consult the server documentation to find out what features and +limitations they may have. In general, any valid Rack app will run the same on +all these servers, without changing anything. + +### Rackup + +Rack provides a separate gem, [rackup](https://github.com/rack/rackup) which is +a generic interface for running a Rack application on supported servers, which +include `WEBRick`, `Puma`, `Falcon` and others. + +## Supported web frameworks + +These frameworks and many others support the [Rack Specification]: + +* [Camping](https://github.com/camping/camping) +* [Hanami](https://hanamirb.org/) +* [Ramaze](https://github.com/ramaze/ramaze) +* [Padrino](https://padrinorb.com/) +* [Roda](https://github.com/jeremyevans/roda) +* [Ruby on Rails](https://rubyonrails.org/) +* [Rum](https://github.com/leahneukirchen/rum) +* [Sinatra](https://sinatrarb.com/) +* [Utopia](https://github.com/socketry/utopia) +* [WABuR](https://github.com/ohler55/wabur) + +## Available middleware shipped with Rack + +Between the server and the framework, Rack can be customized to your +applications needs using middleware. Rack itself ships with the following +middleware: + +* `Rack::CommonLogger` for creating Apache-style logfiles. +* `Rack::ConditionalGet` for returning [Not + Modified](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) + responses when the response has not changed. +* `Rack::Config` for modifying the environment before processing the request. +* `Rack::ContentLength` for setting a `content-length` header based on body + size. +* `Rack::ContentType` for setting a default `content-type` header for responses. +* `Rack::Deflater` for compressing responses with gzip. +* `Rack::ETag` for setting `etag` header on bodies that can be buffered. +* `Rack::Events` for providing easy hooks when a request is received and when + the response is sent. +* `Rack::Files` for serving static files. +* `Rack::Head` for returning an empty body for HEAD requests. +* `Rack::Lint` for checking conformance to the [Rack Specification]. +* `Rack::Lock` for serializing requests using a mutex. +* `Rack::Logger` for setting a logger to handle logging errors. +* `Rack::MethodOverride` for modifying the request method based on a submitted + parameter. +* `Rack::Recursive` for including data from other paths in the application, and + for performing internal redirects. +* `Rack::Reloader` for reloading files if they have been modified. +* `Rack::Runtime` for including a response header with the time taken to process + the request. +* `Rack::Sendfile` for working with web servers that can use optimized file + serving for file system paths. +* `Rack::ShowException` for catching unhandled exceptions and presenting them in + a nice and helpful way with clickable backtrace. +* `Rack::ShowStatus` for using nice error pages for empty client error + responses. +* `Rack::Static` for more configurable serving of static files. +* `Rack::TempfileReaper` for removing temporary files creating during a request. + +All these components use the same interface, which is described in detail in the +[Rack Specification]. These optional components can be used in any way you wish. + +### Convenience interfaces + +If you want to develop outside of existing frameworks, implement your own ones, +or develop middleware, Rack provides many helpers to create Rack applications +quickly and without doing the same web stuff all over: + +* `Rack::Request` which also provides query string parsing and multipart + handling. +* `Rack::Response` for convenient generation of HTTP replies and cookie + handling. +* `Rack::MockRequest` and `Rack::MockResponse` for efficient and quick testing + of Rack application without real HTTP round-trips. +* `Rack::Cascade` for trying additional Rack applications if an application + returns a not found or method not supported response. +* `Rack::Directory` for serving files under a given directory, with directory + indexes. +* `Rack::MediaType` for parsing content-type headers. +* `Rack::Mime` for determining content-type based on file extension. +* `Rack::RewindableInput` for making any IO object rewindable, using a temporary + file buffer. +* `Rack::URLMap` to route to multiple applications inside the same process. + +## Configuration + +Rack exposes several configuration parameters to control various features of the +implementation. + +### `param_depth_limit` + +```ruby +Rack::Utils.param_depth_limit = 32 # default +``` + +The maximum amount of nesting allowed in parameters. For example, if set to 3, +this query string would be allowed: + +``` +?a[b][c]=d +``` + +but this query string would not be allowed: + +``` +?a[b][c][d]=e +``` + +Limiting the depth prevents a possible stack overflow when parsing parameters. + +### `multipart_file_limit` + +```ruby +Rack::Utils.multipart_file_limit = 128 # default +``` + +The maximum number of parts with a filename a request can contain. Accepting +too many parts can lead to the server running out of file handles. + +The default is 128, which means that a single request can't upload more than 128 +files at once. Set to 0 for no limit. + +Can also be set via the `RACK_MULTIPART_FILE_LIMIT` environment variable. + +(This is also aliased as `multipart_part_limit` and `RACK_MULTIPART_PART_LIMIT` for compatibility) + + +### `multipart_total_part_limit` + +The maximum total number of parts a request can contain of any type, including +both file and non-file form fields. + +The default is 4096, which means that a single request can't contain more than +4096 parts. + +Set to 0 for no limit. + +Can also be set via the `RACK_MULTIPART_TOTAL_PART_LIMIT` environment variable. + + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for specific details about how to make a +contribution to Rack. + +Please post bugs, suggestions and patches to [GitHub +Issues](https://github.com/rack/rack/issues). + +Please check our [Security Policy](https://github.com/rack/rack/security/policy) +for responsible disclosure and security bug reporting process. Due to wide usage +of the library, it is strongly preferred that we manage timing in order to +provide viable patches at the time of disclosure. Your assistance in this matter +is greatly appreciated. + +## See Also + +### `rack-contrib` + +The plethora of useful middleware created the need for a project that collects +fresh Rack middleware. `rack-contrib` includes a variety of add-on components +for Rack and it is easy to contribute new modules. + +* https://github.com/rack/rack-contrib + +### `rack-session` + +Provides convenient session management for Rack. + +* https://github.com/rack/rack-session + +## Thanks + +The Rack Core Team, consisting of + +* Aaron Patterson [tenderlove](https://github.com/tenderlove) +* Samuel Williams [ioquatix](https://github.com/ioquatix) +* Jeremy Evans [jeremyevans](https://github.com/jeremyevans) +* Eileen Uchitelle [eileencodes](https://github.com/eileencodes) +* Matthew Draper [matthewd](https://github.com/matthewd) +* Rafael França [rafaelfranca](https://github.com/rafaelfranca) + +and the Rack Alumni + +* Ryan Tomayko [rtomayko](https://github.com/rtomayko) +* Scytrin dai Kinthra [scytrin](https://github.com/scytrin) +* Leah Neukirchen [leahneukirchen](https://github.com/leahneukirchen) +* James Tucker [raggi](https://github.com/raggi) +* Josh Peek [josh](https://github.com/josh) +* José Valim [josevalim](https://github.com/josevalim) +* Michael Fellinger [manveru](https://github.com/manveru) +* Santiago Pastorino [spastorino](https://github.com/spastorino) +* Konstantin Haase [rkh](https://github.com/rkh) + +would like to thank: + +* Adrian Madrid, for the LiteSpeed handler. +* Christoffer Sawicki, for the first Rails adapter and `Rack::Deflater`. +* Tim Fletcher, for the HTTP authentication code. +* Luc Heinrich for the Cookie sessions, the static file handler and bugfixes. +* Armin Ronacher, for the logo and racktools. +* Alex Beregszaszi, Alexander Kahn, Anil Wadghule, Aredridel, Ben Alpert, Dan + Kubb, Daniel Roethlisberger, Matt Todd, Tom Robinson, Phil Hagelberg, S. Brent + Faulkner, Bosko Milekic, Daniel Rodríguez Troitiño, Genki Takiuchi, Geoffrey + Grosenbach, Julien Sanchez, Kamal Fariz Mahyuddin, Masayoshi Takahashi, + Patrick Aljordm, Mig, Kazuhiro Nishiyama, Jon Bardin, Konstantin Haase, Larry + Siden, Matias Korhonen, Sam Ruby, Simon Chiang, Tim Connor, Timur Batyrshin, + and Zach Brock for bug fixing and other improvements. +* Eric Wong, Hongli Lai, Jeremy Kemper for their continuous support and API + improvements. +* Yehuda Katz and Carl Lerche for refactoring rackup. +* Brian Candler, for `Rack::ContentType`. +* Graham Batty, for improved handler loading. +* Stephen Bannasch, for bug reports and documentation. +* Gary Wright, for proposing a better `Rack::Response` interface. +* Jonathan Buch, for improvements regarding `Rack::Response`. +* Armin Röhrl, for tracking down bugs in the Cookie generator. +* Alexander Kellett for testing the Gem and reviewing the announcement. +* Marcus Rückert, for help with configuring and debugging lighttpd. +* The WSGI team for the well-done and documented work they've done and Rack + builds up on. +* All bug reporters and patch contributors not mentioned above. + +## License + +Rack is released under the [MIT License](MIT-LICENSE). + +[Rack Specification]: SPEC.rdoc +[Security Policy]: SECURITY.md diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/SPEC.rdoc b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/SPEC.rdoc new file mode 100644 index 0000000..ed5d982 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/SPEC.rdoc @@ -0,0 +1,365 @@ +This specification aims to formalize the Rack protocol. You +can (and should) use Rack::Lint to enforce it. + +When you develop middleware, be sure to add a Lint before and +after to catch all mistakes. + += Rack applications + +A Rack application is a Ruby object (not a class) that +responds to +call+. +It takes exactly one argument, the *environment* +and returns a non-frozen Array of exactly three values: +The *status*, +the *headers*, +and the *body*. + +== The Environment + +The environment must be an unfrozen instance of Hash that includes +CGI-like headers. The Rack application is free to modify the +environment. + +The environment is required to include these variables +(adopted from {PEP 333}[https://peps.python.org/pep-0333/]), except when they'd be empty, but see +below. +REQUEST_METHOD:: The HTTP request method, such as + "GET" or "POST". This cannot ever + be an empty string, and so is + always required. +SCRIPT_NAME:: The initial portion of the request + URL's "path" that corresponds to the + application object, so that the + application knows its virtual + "location". This may be an empty + string, if the application corresponds + to the "root" of the server. +PATH_INFO:: The remainder of the request URL's + "path", designating the virtual + "location" of the request's target + within the application. This may be an + empty string, if the request URL targets + the application root and does not have a + trailing slash. This value may be + percent-encoded when originating from + a URL. +QUERY_STRING:: The portion of the request URL that + follows the ?, if any. May be + empty, but is always required! +SERVER_NAME:: When combined with SCRIPT_NAME and + PATH_INFO, these variables can be + used to complete the URL. Note, however, + that HTTP_HOST, if present, + should be used in preference to + SERVER_NAME for reconstructing + the request URL. + SERVER_NAME can never be an empty + string, and so is always required. +SERVER_PORT:: An optional +Integer+ which is the port the + server is running on. Should be specified if + the server is running on a non-standard port. +SERVER_PROTOCOL:: A string representing the HTTP version used + for the request. +HTTP_ Variables:: Variables corresponding to the + client-supplied HTTP request + headers (i.e., variables whose + names begin with HTTP_). The + presence or absence of these + variables should correspond with + the presence or absence of the + appropriate HTTP header in the + request. See + {RFC3875 section 4.1.18}[https://tools.ietf.org/html/rfc3875#section-4.1.18] + for specific behavior. +In addition to this, the Rack environment must include these +Rack-specific variables: +rack.url_scheme:: +http+ or +https+, depending on the + request URL. +rack.input:: See below, the input stream. +rack.errors:: See below, the error stream. +rack.hijack?:: See below, if present and true, indicates + that the server supports partial hijacking. +rack.hijack:: See below, if present, an object responding + to +call+ that is used to perform a full + hijack. +rack.protocol:: An optional +Array+ of +String+, containing + the protocols advertised by the client in + the +upgrade+ header (HTTP/1) or the + +:protocol+ pseudo-header (HTTP/2). +Additional environment specifications have approved to +standardized middleware APIs. None of these are required to +be implemented by the server. +rack.session:: A hash-like interface for storing + request session data. + The store must implement: + store(key, value) (aliased as []=); + fetch(key, default = nil) (aliased as []); + delete(key); + clear; + to_hash (returning unfrozen Hash instance); +rack.logger:: A common object interface for logging messages. + The object must implement: + info(message, &block) + debug(message, &block) + warn(message, &block) + error(message, &block) + fatal(message, &block) +rack.multipart.buffer_size:: An Integer hint to the multipart parser as to what chunk size to use for reads and writes. +rack.multipart.tempfile_factory:: An object responding to #call with two arguments, the filename and content_type given for the multipart form field, and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate the tempfile for each multipart form file upload field, rather than the default class of Tempfile. +The server or the application can store their own data in the +environment, too. The keys must contain at least one dot, +and should be prefixed uniquely. The prefix rack. +is reserved for use with the Rack core distribution and other +accepted specifications and must not be used otherwise. + +The SERVER_PORT must be an Integer if set. +The SERVER_NAME must be a valid authority as defined by RFC7540. +The HTTP_HOST must be a valid authority as defined by RFC7540. +The SERVER_PROTOCOL must match the regexp HTTP/\d(\.\d)?. +The environment must not contain the keys +HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH +(use the versions without HTTP_). +The CGI keys (named without a period) must have String values. +If the string values for CGI keys contain non-ASCII characters, +they should use ASCII-8BIT encoding. +There are the following restrictions: +* rack.url_scheme must either be +http+ or +https+. +* There may be a valid input stream in rack.input. +* There must be a valid error stream in rack.errors. +* There may be a valid hijack callback in rack.hijack +* There may be a valid early hints callback in rack.early_hints +* The REQUEST_METHOD must be a valid token. +* The SCRIPT_NAME, if non-empty, must start with / +* The PATH_INFO, if provided, must be a valid request target or an empty string. + * Only OPTIONS requests may have PATH_INFO set to * (asterisk-form). + * Only CONNECT requests may have PATH_INFO set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. + * CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form). + * Otherwise, PATH_INFO must start with a / and must not include a fragment part starting with '#' (origin-form). +* The CONTENT_LENGTH, if given, must consist of digits only. +* One of SCRIPT_NAME or PATH_INFO must be + set. PATH_INFO should be / if + SCRIPT_NAME is empty. + SCRIPT_NAME never should be /, but instead be empty. +rack.response_finished:: An array of callables run by the server after the response has been +processed. This would typically be invoked after sending the response to the client, but it could also be +invoked if an error occurs while generating the response or sending the response; in that case, the error +argument will be a subclass of +Exception+. +The callables are invoked with +env, status, headers, error+ arguments and should not raise any +exceptions. They should be invoked in reverse order of registration. + +=== The Input Stream + +The input stream is an IO-like object which contains the raw HTTP +POST data. +When applicable, its external encoding must be "ASCII-8BIT" and it +must be opened in binary mode. +The input stream must respond to +gets+, +each+, and +read+. +* +gets+ must be called without arguments and return a string, + or +nil+ on EOF. +* +read+ behaves like IO#read. + Its signature is read([length, [buffer]]). + + If given, +length+ must be a non-negative Integer (>= 0) or +nil+, + and +buffer+ must be a String and may not be nil. + + If +length+ is given and not nil, then this method reads at most + +length+ bytes from the input stream. + + If +length+ is not given or nil, then this method reads + all data until EOF. + + When EOF is reached, this method returns nil if +length+ is given + and not nil, or "" if +length+ is not given or is nil. + + If +buffer+ is given, then the read data will be placed + into +buffer+ instead of a newly created String object. +* +each+ must be called without arguments and only yield Strings. +* +close+ can be called on the input stream to indicate that + any remaining input is not needed. + +=== The Error Stream + +The error stream must respond to +puts+, +write+ and +flush+. +* +puts+ must be called with a single argument that responds to +to_s+. +* +write+ must be called with a single argument that is a String. +* +flush+ must be called without arguments and must be called + in order to make the error appear for sure. +* +close+ must never be called on the error stream. + +=== Hijacking + +The hijacking interfaces provides a means for an application to take +control of the HTTP connection. There are two distinct hijack +interfaces: full hijacking where the application takes over the raw +connection, and partial hijacking where the application takes over +just the response body stream. In both cases, the application is +responsible for closing the hijacked stream. + +Full hijacking only works with HTTP/1. Partial hijacking is functionally +equivalent to streaming bodies, and is still optionally supported for +backwards compatibility with older Rack versions. + +==== Full Hijack + +Full hijack is used to completely take over an HTTP/1 connection. It +occurs before any headers are written and causes the request to +ignores any response generated by the application. + +It is intended to be used when applications need access to raw HTTP/1 +connection. + +If +rack.hijack+ is present in +env+, it must respond to +call+ +and return an +IO+ instance which can be used to read and write +to the underlying connection using HTTP/1 semantics and +formatting. + +==== Partial Hijack + +Partial hijack is used for bi-directional streaming of the request and +response body. It occurs after the status and headers are written by +the server and causes the server to ignore the Body of the response. + +It is intended to be used when applications need bi-directional +streaming. + +If +rack.hijack?+ is present in +env+ and truthy, +an application may set the special response header +rack.hijack+ +to an object that responds to +call+, +accepting a +stream+ argument. + +After the response status and headers have been sent, this hijack +callback will be invoked with a +stream+ argument which follows the +same interface as outlined in "Streaming Body". Servers must +ignore the +body+ part of the response tuple when the ++rack.hijack+ response header is present. Using an empty +Array+ +instance is recommended. + +The special response header +rack.hijack+ must only be set +if the request +env+ has a truthy +rack.hijack?+. + +=== Early Hints + +The application or any middleware may call the rack.early_hints +with an object which would be valid as the headers of a Rack response. + +If rack.early_hints is present, it must respond to #call. +If rack.early_hints is called, it must be called with +valid Rack response headers. + +== The Response + +=== The Status + +This is an HTTP status. It must be an Integer greater than or equal to +100. + +=== The Headers + +The headers must be a unfrozen Hash. +The header keys must be Strings. +Special headers starting "rack." are for communicating with the +server, and must not be sent back to the client. +The header must not contain a +Status+ key. +Header keys must conform to RFC7230 token specification, i.e. cannot +contain non-printable ASCII, DQUOTE or "(),/:;<=>?@[\]{}". +Header keys must not contain uppercase ASCII characters (A-Z). +Header values must be either a String instance, +or an Array of String instances, +such that each String instance must not contain characters below 037. + +==== The +content-type+ Header + +There must not be a content-type header key when the +Status+ is 1xx, +204, or 304. + +==== The +content-length+ Header + +There must not be a content-length header key when the ++Status+ is 1xx, 204, or 304. + +==== The +rack.protocol+ Header + +If the +rack.protocol+ header is present, it must be a +String+, and +must be one of the values from the +rack.protocol+ array from the +environment. + +Setting this value informs the server that it should perform a +connection upgrade. In HTTP/1, this is done using the +upgrade+ +header. In HTTP/2, this is done by accepting the request. + +=== The Body + +The Body is typically an +Array+ of +String+ instances, an enumerable +that yields +String+ instances, a +Proc+ instance, or a File-like +object. + +The Body must respond to +each+ or +call+. It may optionally respond +to +to_path+ or +to_ary+. A Body that responds to +each+ is considered +to be an Enumerable Body. A Body that responds to +call+ is considered +to be a Streaming Body. + +A Body that responds to both +each+ and +call+ must be treated as an +Enumerable Body, not a Streaming Body. If it responds to +each+, you +must call +each+ and not +call+. If the Body doesn't respond to ++each+, then you can assume it responds to +call+. + +The Body must either be consumed or returned. The Body is consumed by +optionally calling either +each+ or +call+. +Then, if the Body responds to +close+, it must be called to release +any resources associated with the generation of the body. +In other words, +close+ must always be called at least once; typically +after the web server has sent the response to the client, but also in +cases where the Rack application makes internal/virtual requests and +discards the response. + + +After calling +close+, the Body is considered closed and should not +be consumed again. +If the original Body is replaced by a new Body, the new Body must +also consume the original Body by calling +close+ if possible. + +If the Body responds to +to_path+, it must return a +String+ +path for the local file system whose contents are identical +to that produced by calling +each+; this may be used by the +server as an alternative, possibly more efficient way to +transport the response. The +to_path+ method does not consume +the body. + +==== Enumerable Body + +The Enumerable Body must respond to +each+. +It must only be called once. +It must not be called after being closed, +and must only yield String values. + +Middleware must not call +each+ directly on the Body. +Instead, middleware can return a new Body that calls +each+ on the +original Body, yielding at least once per iteration. + +If the Body responds to +to_ary+, it must return an +Array+ whose +contents are identical to that produced by calling +each+. +Middleware may call +to_ary+ directly on the Body and return a new +Body in its place. In other words, middleware can only process the +Body directly if it responds to +to_ary+. If the Body responds to both ++to_ary+ and +close+, its implementation of +to_ary+ must call ++close+. + +==== Streaming Body + +The Streaming Body must respond to +call+. +It must only be called once. +It must not be called after being closed. +It takes a +stream+ argument. + +The +stream+ argument must implement: +read, write, <<, flush, close, close_read, close_write, closed? + +The semantics of these IO methods must be a best effort match to +those of a normal Ruby IO or Socket object, using standard arguments +and raising standard exceptions. Servers are encouraged to simply +pass on real IO objects, although it is recognized that this approach +is not directly compatible with HTTP/2. + +== Thanks +Some parts of this specification are adopted from {PEP 333 – Python Web Server Gateway Interface v1.0}[https://peps.python.org/pep-0333/] +I'd like to thank everyone involved in that effort. diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack.rb new file mode 100644 index 0000000..6021248 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack.rb @@ -0,0 +1,66 @@ +# socket-patch: patched rack-3.1.8 (spike marker) +# frozen_string_literal: true + +# Copyright (C) 2007-2019 Leah Neukirchen +# +# Rack is freely distributable under the terms of an MIT-style license. +# See MIT-LICENSE or https://opensource.org/licenses/MIT. + +# The Rack main module, serving as a namespace for all core Rack +# modules and classes. +# +# All modules meant for use in your application are autoloaded here, +# so it should be enough just to require 'rack' in your code. + +require_relative 'rack/version' +require_relative 'rack/constants' + +module Rack + autoload :BadRequest, "rack/bad_request" + autoload :BodyProxy, "rack/body_proxy" + autoload :Builder, "rack/builder" + autoload :Cascade, "rack/cascade" + autoload :CommonLogger, "rack/common_logger" + autoload :ConditionalGet, "rack/conditional_get" + autoload :Config, "rack/config" + autoload :ContentLength, "rack/content_length" + autoload :ContentType, "rack/content_type" + autoload :Deflater, "rack/deflater" + autoload :Directory, "rack/directory" + autoload :ETag, "rack/etag" + autoload :Events, "rack/events" + autoload :Files, "rack/files" + autoload :ForwardRequest, "rack/recursive" + autoload :Head, "rack/head" + autoload :Headers, "rack/headers" + autoload :Lint, "rack/lint" + autoload :Lock, "rack/lock" + autoload :Logger, "rack/logger" + autoload :MediaType, "rack/media_type" + autoload :MethodOverride, "rack/method_override" + autoload :Mime, "rack/mime" + autoload :MockRequest, "rack/mock_request" + autoload :MockResponse, "rack/mock_response" + autoload :Multipart, "rack/multipart" + autoload :NullLogger, "rack/null_logger" + autoload :QueryParser, "rack/query_parser" + autoload :Recursive, "rack/recursive" + autoload :Reloader, "rack/reloader" + autoload :Request, "rack/request" + autoload :Response, "rack/response" + autoload :RewindableInput, "rack/rewindable_input" + autoload :Runtime, "rack/runtime" + autoload :Sendfile, "rack/sendfile" + autoload :ShowExceptions, "rack/show_exceptions" + autoload :ShowStatus, "rack/show_status" + autoload :Static, "rack/static" + autoload :TempfileReaper, "rack/tempfile_reaper" + autoload :URLMap, "rack/urlmap" + autoload :Utils, "rack/utils" + + module Auth + autoload :Basic, "rack/auth/basic" + autoload :AbstractHandler, "rack/auth/abstract/handler" + autoload :AbstractRequest, "rack/auth/abstract/request" + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb new file mode 100644 index 0000000..4731ee8 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative '../../constants' + +module Rack + module Auth + # Rack::Auth::AbstractHandler implements common authentication functionality. + # + # +realm+ should be set for all handlers. + + class AbstractHandler + + attr_accessor :realm + + def initialize(app, realm = nil, &authenticator) + @app, @realm, @authenticator = app, realm, authenticator + end + + + private + + def unauthorized(www_authenticate = challenge) + return [ 401, + { CONTENT_TYPE => 'text/plain', + CONTENT_LENGTH => '0', + 'www-authenticate' => www_authenticate.to_s }, + [] + ] + end + + def bad_request + return [ 400, + { CONTENT_TYPE => 'text/plain', + CONTENT_LENGTH => '0' }, + [] + ] + end + + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb new file mode 100644 index 0000000..f872331 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require_relative '../../request' + +module Rack + module Auth + class AbstractRequest + + def initialize(env) + @env = env + end + + def request + @request ||= Request.new(@env) + end + + def provided? + !authorization_key.nil? && valid? + end + + def valid? + !@env[authorization_key].nil? + end + + def parts + @parts ||= @env[authorization_key].split(' ', 2) + end + + def scheme + @scheme ||= parts.first&.downcase + end + + def params + @params ||= parts.last + end + + + private + + AUTHORIZATION_KEYS = ['HTTP_AUTHORIZATION', 'X-HTTP_AUTHORIZATION', 'X_HTTP_AUTHORIZATION'] + + def authorization_key + @authorization_key ||= AUTHORIZATION_KEYS.detect { |key| @env.has_key?(key) } + end + + end + + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/basic.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/basic.rb new file mode 100644 index 0000000..67ffc49 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/basic.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_relative 'abstract/handler' +require_relative 'abstract/request' + +module Rack + module Auth + # Rack::Auth::Basic implements HTTP Basic Authentication, as per RFC 2617. + # + # Initialize with the Rack application that you want protecting, + # and a block that checks if a username and password pair are valid. + + class Basic < AbstractHandler + + def call(env) + auth = Basic::Request.new(env) + + return unauthorized unless auth.provided? + + return bad_request unless auth.basic? + + if valid?(auth) + env['REMOTE_USER'] = auth.username + + return @app.call(env) + end + + unauthorized + end + + + private + + def challenge + 'Basic realm="%s"' % realm + end + + def valid?(auth) + @authenticator.call(*auth.credentials) + end + + class Request < Auth::AbstractRequest + def basic? + "basic" == scheme && credentials.length == 2 + end + + def credentials + @credentials ||= params.unpack1('m').split(':', 2) + end + + def username + credentials.first + end + end + + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/bad_request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/bad_request.rb new file mode 100644 index 0000000..8eaa94e --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/bad_request.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Rack + # Represents a 400 Bad Request error when input data fails to meet the + # requirements. + module BadRequest + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/body_proxy.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/body_proxy.rb new file mode 100644 index 0000000..7291579 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/body_proxy.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Rack + # Proxy for response bodies allowing calling a block when + # the response body is closed (after the response has been fully + # sent to the client). + class BodyProxy + # Set the response body to wrap, and the block to call when the + # response has been fully sent. + def initialize(body, &block) + @body = body + @block = block + @closed = false + end + + # Return whether the wrapped body responds to the method. + def respond_to_missing?(method_name, include_all = false) + case method_name + when :to_str + false + else + super or @body.respond_to?(method_name, include_all) + end + end + + # If not already closed, close the wrapped body and + # then call the block the proxy was initialized with. + def close + return if @closed + @closed = true + begin + @body.close if @body.respond_to?(:close) + ensure + @block.call + end + end + + # Whether the proxy is closed. The proxy starts as not closed, + # and becomes closed on the first call to close. + def closed? + @closed + end + + # Delegate missing methods to the wrapped body. + def method_missing(method_name, *args, &block) + case method_name + when :to_str + super + when :to_ary + begin + @body.__send__(method_name, *args, &block) + ensure + close + end + else + @body.__send__(method_name, *args, &block) + end + end + # :nocov: + ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) + # :nocov: + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/builder.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/builder.rb new file mode 100644 index 0000000..9faeffb --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/builder.rb @@ -0,0 +1,290 @@ +# frozen_string_literal: true + +require_relative 'urlmap' + +module Rack; end +Rack::BUILDER_TOPLEVEL_BINDING = ->(builder){builder.instance_eval{binding}} + +module Rack + # Rack::Builder provides a domain-specific language (DSL) to construct Rack + # applications. It is primarily used to parse +config.ru+ files which + # instantiate several middleware and a final application which are hosted + # by a Rack-compatible web server. + # + # Example: + # + # app = Rack::Builder.new do + # use Rack::CommonLogger + # map "/ok" do + # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } + # end + # end + # + # run app + # + # Or + # + # app = Rack::Builder.app do + # use Rack::CommonLogger + # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } + # end + # + # run app + # + # +use+ adds middleware to the stack, +run+ dispatches to an application. + # You can use +map+ to construct a Rack::URLMap in a convenient way. + class Builder + + # https://stackoverflow.com/questions/2223882/whats-the-difference-between-utf-8-and-utf-8-without-bom + UTF_8_BOM = '\xef\xbb\xbf' + + # Parse the given config file to get a Rack application. + # + # If the config file ends in +.ru+, it is treated as a + # rackup file and the contents will be treated as if + # specified inside a Rack::Builder block. + # + # If the config file does not end in +.ru+, it is + # required and Rack will use the basename of the file + # to guess which constant will be the Rack application to run. + # + # Examples: + # + # Rack::Builder.parse_file('config.ru') + # # Rack application built using Rack::Builder.new + # + # Rack::Builder.parse_file('app.rb') + # # requires app.rb, which can be anywhere in Ruby's + # # load path. After requiring, assumes App constant + # # is a Rack application + # + # Rack::Builder.parse_file('./my_app.rb') + # # requires ./my_app.rb, which should be in the + # # process's current directory. After requiring, + # # assumes MyApp constant is a Rack application + def self.parse_file(path, **options) + if path.end_with?('.ru') + return self.load_file(path, **options) + else + require path + return Object.const_get(::File.basename(path, '.rb').split('_').map(&:capitalize).join('')) + end + end + + # Load the given file as a rackup file, treating the + # contents as if specified inside a Rack::Builder block. + # + # Ignores content in the file after +__END__+, so that + # use of +__END__+ will not result in a syntax error. + # + # Example config.ru file: + # + # $ cat config.ru + # + # use Rack::ContentLength + # require './app.rb' + # run App + def self.load_file(path, **options) + config = ::File.read(path) + config.slice!(/\A#{UTF_8_BOM}/) if config.encoding == Encoding::UTF_8 + + if config[/^#\\(.*)/] + fail "Parsing options from the first comment line is no longer supported: #{path}" + end + + config.sub!(/^__END__\n.*\Z/m, '') + + return new_from_string(config, path, **options) + end + + # Evaluate the given +builder_script+ string in the context of + # a Rack::Builder block, returning a Rack application. + def self.new_from_string(builder_script, path = "(rackup)", **options) + builder = self.new(**options) + + # We want to build a variant of TOPLEVEL_BINDING with self as a Rack::Builder instance. + # We cannot use instance_eval(String) as that would resolve constants differently. + binding = BUILDER_TOPLEVEL_BINDING.call(builder) + eval(builder_script, binding, path) + + return builder.to_app + end + + # Initialize a new Rack::Builder instance. +default_app+ specifies the + # default application if +run+ is not called later. If a block + # is given, it is evaluated in the context of the instance. + def initialize(default_app = nil, **options, &block) + @use = [] + @map = nil + @run = default_app + @warmup = nil + @freeze_app = false + @options = options + + instance_eval(&block) if block_given? + end + + # Any options provided to the Rack::Builder instance at initialization. + # These options can be server-specific. Some general options are: + # + # * +:isolation+: One of +process+, +thread+ or +fiber+. The execution + # isolation model to use. + attr :options + + # Create a new Rack::Builder instance and return the Rack application + # generated from it. + def self.app(default_app = nil, &block) + self.new(default_app, &block).to_app + end + + # Specifies middleware to use in a stack. + # + # class Middleware + # def initialize(app) + # @app = app + # end + # + # def call(env) + # env["rack.some_header"] = "setting an example" + # @app.call(env) + # end + # end + # + # use Middleware + # run lambda { |env| [200, { "content-type" => "text/plain" }, ["OK"]] } + # + # All requests through to this application will first be processed by the middleware class. + # The +call+ method in this example sets an additional environment key which then can be + # referenced in the application if required. + def use(middleware, *args, &block) + if @map + mapping, @map = @map, nil + @use << proc { |app| generate_map(app, mapping) } + end + @use << proc { |app| middleware.new(app, *args, &block) } + end + # :nocov: + ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true) + # :nocov: + + # Takes a block or argument that is an object that responds to #call and + # returns a Rack response. + # + # You can use a block: + # + # run do |env| + # [200, { "content-type" => "text/plain" }, ["Hello World!"]] + # end + # + # You can also provide a lambda: + # + # run lambda { |env| [200, { "content-type" => "text/plain" }, ["OK"]] } + # + # You can also provide a class instance: + # + # class Heartbeat + # def call(env) + # [200, { "content-type" => "text/plain" }, ["OK"]] + # end + # end + # + # run Heartbeat.new + # + def run(app = nil, &block) + raise ArgumentError, "Both app and block given!" if app && block_given? + + @run = app || block + end + + # Takes a lambda or block that is used to warm-up the application. This block is called + # before the Rack application is returned by to_app. + # + # warmup do |app| + # client = Rack::MockRequest.new(app) + # client.get('/') + # end + # + # use SomeMiddleware + # run MyApp + def warmup(prc = nil, &block) + @warmup = prc || block + end + + # Creates a route within the application. Routes under the mapped path will be sent to + # the Rack application specified by run inside the block. Other requests will be sent to the + # default application specified by run outside the block. + # + # class App + # def call(env) + # [200, {'content-type' => 'text/plain'}, ["Hello World"]] + # end + # end + # + # class Heartbeat + # def call(env) + # [200, { "content-type" => "text/plain" }, ["OK"]] + # end + # end + # + # app = Rack::Builder.app do + # map '/heartbeat' do + # run Heartbeat.new + # end + # run App.new + # end + # + # run app + # + # The +use+ method can also be used inside the block to specify middleware to run under a specific path: + # + # app = Rack::Builder.app do + # map '/heartbeat' do + # use Middleware + # run Heartbeat.new + # end + # run App.new + # end + # + # This example includes a piece of middleware which will run before +/heartbeat+ requests hit +Heartbeat+. + # + # Note that providing a +path+ of +/+ will ignore any default application given in a +run+ statement + # outside the block. + def map(path, &block) + @map ||= {} + @map[path] = block + end + + # Freeze the app (set using run) and all middleware instances when building the application + # in to_app. + def freeze_app + @freeze_app = true + end + + # Return the Rack application generated by this instance. + def to_app + app = @map ? generate_map(@run, @map) : @run + fail "missing run or map statement" unless app + app.freeze if @freeze_app + app = @use.reverse.inject(app) { |a, e| e[a].tap { |x| x.freeze if @freeze_app } } + @warmup.call(app) if @warmup + app + end + + # Call the Rack application generated by this builder instance. Note that + # this rebuilds the Rack application and runs the warmup code (if any) + # every time it is called, so it should not be used if performance is important. + def call(env) + to_app.call(env) + end + + private + + # Generate a URLMap instance by generating new Rack applications for each + # map block in this instance. + def generate_map(default_app, mapping) + mapped = default_app ? { '/' => default_app } : {} + mapping.each { |r, b| mapped[r] = self.class.new(default_app, &b).to_app } + URLMap.new(mapped) + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/cascade.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/cascade.rb new file mode 100644 index 0000000..9c952fd --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/cascade.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative 'constants' + +module Rack + # Rack::Cascade tries a request on several apps, and returns the + # first response that is not 404 or 405 (or in a list of configured + # status codes). If all applications tried return one of the configured + # status codes, return the last response. + + class Cascade + # An array of applications to try in order. + attr_reader :apps + + # Set the apps to send requests to, and what statuses result in + # cascading. Arguments: + # + # apps: An enumerable of rack applications. + # cascade_for: The statuses to use cascading for. If a response is received + # from an app, the next app is tried. + def initialize(apps, cascade_for = [404, 405]) + @apps = [] + apps.each { |app| add app } + + @cascade_for = {} + [*cascade_for].each { |status| @cascade_for[status] = true } + end + + # Call each app in order. If the responses uses a status that requires + # cascading, try the next app. If all responses require cascading, + # return the response from the last app. + def call(env) + return [404, { CONTENT_TYPE => "text/plain" }, []] if @apps.empty? + result = nil + last_body = nil + + @apps.each do |app| + # The SPEC says that the body must be closed after it has been iterated + # by the server, or if it is replaced by a middleware action. Cascade + # replaces the body each time a cascade happens. It is assumed that nil + # does not respond to close, otherwise the previous application body + # will be closed. The final application body will not be closed, as it + # will be passed to the server as a result. + last_body.close if last_body.respond_to? :close + + result = app.call(env) + return result unless @cascade_for.include?(result[0].to_i) + last_body = result[2] + end + + result + end + + # Append an app to the list of apps to cascade. This app will + # be tried last. + def add(app) + @apps << app + end + + # Whether the given app is one of the apps to cascade to. + def include?(app) + @apps.include?(app) + end + + alias_method :<<, :add + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/common_logger.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/common_logger.rb new file mode 100644 index 0000000..2feb067 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/common_logger.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' +require_relative 'body_proxy' +require_relative 'request' + +module Rack + # Rack::CommonLogger forwards every request to the given +app+, and + # logs a line in the + # {Apache common log format}[http://httpd.apache.org/docs/1.3/logs.html#common] + # to the configured logger. + class CommonLogger + # Common Log Format: http://httpd.apache.org/docs/1.3/logs.html#common + # + # lilith.local - - [07/Aug/2006 23:58:02 -0400] "GET / HTTP/1.1" 500 - + # + # %{%s - %s [%s] "%s %s%s %s" %d %s\n} % + # + # The actual format is slightly different than the above due to the + # separation of SCRIPT_NAME and PATH_INFO, and because the elapsed + # time in seconds is included at the end. + FORMAT = %{%s - %s [%s] "%s %s%s%s %s" %d %s %0.4f\n} + + # +logger+ can be any object that supports the +write+ or +<<+ methods, + # which includes the standard library Logger. These methods are called + # with a single string argument, the log message. + # If +logger+ is nil, CommonLogger will fall back env['rack.errors']. + def initialize(app, logger = nil) + @app = app + @logger = logger + end + + # Log all requests in common_log format after a response has been + # returned. Note that if the app raises an exception, the request + # will not be logged, so if exception handling middleware are used, + # they should be loaded after this middleware. Additionally, because + # the logging happens after the request body has been fully sent, any + # exceptions raised during the sending of the response body will + # cause the request not to be logged. + def call(env) + began_at = Utils.clock_time + status, headers, body = response = @app.call(env) + + response[2] = BodyProxy.new(body) { log(env, status, headers, began_at) } + response + end + + private + + # Log the request to the configured logger. + def log(env, status, response_headers, began_at) + request = Rack::Request.new(env) + length = extract_content_length(response_headers) + + msg = sprintf(FORMAT, + request.ip || "-", + request.get_header("REMOTE_USER") || "-", + Time.now.strftime("%d/%b/%Y:%H:%M:%S %z"), + request.request_method, + request.script_name, + request.path_info, + request.query_string.empty? ? "" : "?#{request.query_string}", + request.get_header(SERVER_PROTOCOL), + status.to_s[0..3], + length, + Utils.clock_time - began_at) + + msg.gsub!(/[^[:print:]\n]/) { |c| sprintf("\\x%x", c.ord) } + + logger = @logger || request.get_header(RACK_ERRORS) + # Standard library logger doesn't support write but it supports << which actually + # calls to write on the log device without formatting + if logger.respond_to?(:write) + logger.write(msg) + else + logger << msg + end + end + + # Attempt to determine the content length for the response to + # include it in the logged data. + def extract_content_length(headers) + value = headers[CONTENT_LENGTH] + !value || value.to_s == '0' ? '-' : value + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/conditional_get.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/conditional_get.rb new file mode 100644 index 0000000..c3b334a --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/conditional_get.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' +require_relative 'body_proxy' + +module Rack + + # Middleware that enables conditional GET using if-none-match and + # if-modified-since. The application should set either or both of the + # last-modified or etag response headers according to RFC 2616. When + # either of the conditions is met, the response body is set to be zero + # length and the response status is set to 304 Not Modified. + # + # Applications that defer response body generation until the body's each + # message is received will avoid response body generation completely when + # a conditional GET matches. + # + # Adapted from Michael Klishin's Merb implementation: + # https://github.com/wycats/merb/blob/master/merb-core/lib/merb-core/rack/middleware/conditional_get.rb + class ConditionalGet + def initialize(app) + @app = app + end + + # Return empty 304 response if the response has not been + # modified since the last request. + def call(env) + case env[REQUEST_METHOD] + when "GET", "HEAD" + status, headers, body = response = @app.call(env) + + if status == 200 && fresh?(env, headers) + response[0] = 304 + headers.delete(CONTENT_TYPE) + headers.delete(CONTENT_LENGTH) + response[2] = Rack::BodyProxy.new([]) do + body.close if body.respond_to?(:close) + end + end + response + else + @app.call(env) + end + end + + private + + # Return whether the response has not been modified since the + # last request. + def fresh?(env, headers) + # if-none-match has priority over if-modified-since per RFC 7232 + if none_match = env['HTTP_IF_NONE_MATCH'] + etag_matches?(none_match, headers) + elsif (modified_since = env['HTTP_IF_MODIFIED_SINCE']) && (modified_since = to_rfc2822(modified_since)) + modified_since?(modified_since, headers) + end + end + + # Whether the etag response header matches the if-none-match request header. + # If so, the request has not been modified. + def etag_matches?(none_match, headers) + headers[ETAG] == none_match + end + + # Whether the last-modified response header matches the if-modified-since + # request header. If so, the request has not been modified. + def modified_since?(modified_since, headers) + last_modified = to_rfc2822(headers['last-modified']) and + modified_since >= last_modified + end + + # Return a Time object for the given string (which should be in RFC2822 + # format), or nil if the string cannot be parsed. + def to_rfc2822(since) + # shortest possible valid date is the obsolete: 1 Nov 97 09:55 A + # anything shorter is invalid, this avoids exceptions for common cases + # most common being the empty string + if since && since.length >= 16 + # NOTE: there is no trivial way to write this in a non exception way + # _rfc2822 returns a hash but is not that usable + Time.rfc2822(since) rescue nil + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/config.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/config.rb new file mode 100644 index 0000000..41f6f7d --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/config.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Rack + # Rack::Config modifies the environment using the block given during + # initialization. + # + # Example: + # use Rack::Config do |env| + # env['my-key'] = 'some-value' + # end + class Config + def initialize(app, &block) + @app = app + @block = block + end + + def call(env) + @block.call(env) + @app.call(env) + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/constants.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/constants.rb new file mode 100644 index 0000000..e9b6e10 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/constants.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Rack + # Request env keys + HTTP_HOST = 'HTTP_HOST' + HTTP_PORT = 'HTTP_PORT' + HTTPS = 'HTTPS' + PATH_INFO = 'PATH_INFO' + REQUEST_METHOD = 'REQUEST_METHOD' + REQUEST_PATH = 'REQUEST_PATH' + SCRIPT_NAME = 'SCRIPT_NAME' + QUERY_STRING = 'QUERY_STRING' + SERVER_PROTOCOL = 'SERVER_PROTOCOL' + SERVER_NAME = 'SERVER_NAME' + SERVER_PORT = 'SERVER_PORT' + HTTP_COOKIE = 'HTTP_COOKIE' + + # Response Header Keys + CACHE_CONTROL = 'cache-control' + CONTENT_LENGTH = 'content-length' + CONTENT_TYPE = 'content-type' + ETAG = 'etag' + EXPIRES = 'expires' + SET_COOKIE = 'set-cookie' + TRANSFER_ENCODING = 'transfer-encoding' + + # HTTP method verbs + GET = 'GET' + POST = 'POST' + PUT = 'PUT' + PATCH = 'PATCH' + DELETE = 'DELETE' + HEAD = 'HEAD' + OPTIONS = 'OPTIONS' + CONNECT = 'CONNECT' + LINK = 'LINK' + UNLINK = 'UNLINK' + TRACE = 'TRACE' + + # Rack environment variables + RACK_VERSION = 'rack.version' + RACK_TEMPFILES = 'rack.tempfiles' + RACK_EARLY_HINTS = 'rack.early_hints' + RACK_ERRORS = 'rack.errors' + RACK_LOGGER = 'rack.logger' + RACK_INPUT = 'rack.input' + RACK_SESSION = 'rack.session' + RACK_SESSION_OPTIONS = 'rack.session.options' + RACK_SHOWSTATUS_DETAIL = 'rack.showstatus.detail' + RACK_URL_SCHEME = 'rack.url_scheme' + RACK_HIJACK = 'rack.hijack' + RACK_IS_HIJACK = 'rack.hijack?' + RACK_RECURSIVE_INCLUDE = 'rack.recursive.include' + RACK_MULTIPART_BUFFER_SIZE = 'rack.multipart.buffer_size' + RACK_MULTIPART_TEMPFILE_FACTORY = 'rack.multipart.tempfile_factory' + RACK_RESPONSE_FINISHED = 'rack.response_finished' + RACK_REQUEST_FORM_INPUT = 'rack.request.form_input' + RACK_REQUEST_FORM_HASH = 'rack.request.form_hash' + RACK_REQUEST_FORM_PAIRS = 'rack.request.form_pairs' + RACK_REQUEST_FORM_VARS = 'rack.request.form_vars' + RACK_REQUEST_FORM_ERROR = 'rack.request.form_error' + RACK_REQUEST_COOKIE_HASH = 'rack.request.cookie_hash' + RACK_REQUEST_COOKIE_STRING = 'rack.request.cookie_string' + RACK_REQUEST_QUERY_HASH = 'rack.request.query_hash' + RACK_REQUEST_QUERY_STRING = 'rack.request.query_string' + RACK_METHODOVERRIDE_ORIGINAL_METHOD = 'rack.methodoverride.original_method' +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_length.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_length.rb new file mode 100644 index 0000000..cbac93a --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_length.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' + +module Rack + + # Sets the content-length header on responses that do not specify + # a content-length or transfer-encoding header. Note that this + # does not fix responses that have an invalid content-length + # header specified. + class ContentLength + include Rack::Utils + + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = response = @app.call(env) + + if !STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) && + !headers[CONTENT_LENGTH] && + !headers[TRANSFER_ENCODING] && + body.respond_to?(:to_ary) + + response[2] = body = body.to_ary + headers[CONTENT_LENGTH] = body.sum(&:bytesize).to_s + end + + response + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_type.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_type.rb new file mode 100644 index 0000000..19f0782 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_type.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' + +module Rack + + # Sets the content-type header on responses which don't have one. + # + # Builder Usage: + # use Rack::ContentType, "text/plain" + # + # When no content type argument is provided, "text/html" is the + # default. + class ContentType + include Rack::Utils + + def initialize(app, content_type = "text/html") + @app = app + @content_type = content_type + end + + def call(env) + status, headers, _ = response = @app.call(env) + + unless STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) + headers[CONTENT_TYPE] ||= @content_type + end + + response + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/deflater.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/deflater.rb new file mode 100644 index 0000000..cc01c32 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/deflater.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require "zlib" +require "time" # for Time.httpdate + +require_relative 'constants' +require_relative 'utils' +require_relative 'request' +require_relative 'body_proxy' + +module Rack + # This middleware enables content encoding of http responses, + # usually for purposes of compression. + # + # Currently supported encodings: + # + # * gzip + # * identity (no transformation) + # + # This middleware automatically detects when encoding is supported + # and allowed. For example no encoding is made when a cache + # directive of 'no-transform' is present, when the response status + # code is one that doesn't allow an entity body, or when the body + # is empty. + # + # Note that despite the name, Deflater does not support the +deflate+ + # encoding. + class Deflater + # Creates Rack::Deflater middleware. Options: + # + # :if :: a lambda enabling / disabling deflation based on returned boolean value + # (e.g use Rack::Deflater, :if => lambda { |*, body| sum=0; body.each { |i| sum += i.length }; sum > 512 }). + # However, be aware that calling `body.each` inside the block will break cases where `body.each` is not idempotent, + # such as when it is an +IO+ instance. + # :include :: a list of content types that should be compressed. By default, all content types are compressed. + # :sync :: determines if the stream is going to be flushed after every chunk. Flushing after every chunk reduces + # latency for time-sensitive streaming applications, but hurts compression and throughput. + # Defaults to +true+. + def initialize(app, options = {}) + @app = app + @condition = options[:if] + @compressible_types = options[:include] + @sync = options.fetch(:sync, true) + end + + def call(env) + status, headers, body = response = @app.call(env) + + unless should_deflate?(env, status, headers, body) + return response + end + + request = Request.new(env) + + encoding = Utils.select_best_encoding(%w(gzip identity), + request.accept_encoding) + + # Set the Vary HTTP header. + vary = headers["vary"].to_s.split(",").map(&:strip) + unless vary.include?("*") || vary.any?{|v| v.downcase == 'accept-encoding'} + headers["vary"] = vary.push("Accept-Encoding").join(",") + end + + case encoding + when "gzip" + headers['content-encoding'] = "gzip" + headers.delete(CONTENT_LENGTH) + mtime = headers["last-modified"] + mtime = Time.httpdate(mtime).to_i if mtime + response[2] = GzipStream.new(body, mtime, @sync) + response + when "identity" + response + else # when nil + # Only possible encoding values here are 'gzip', 'identity', and nil + message = "An acceptable encoding for the requested resource #{request.fullpath} could not be found." + bp = Rack::BodyProxy.new([message]) { body.close if body.respond_to?(:close) } + [406, { CONTENT_TYPE => "text/plain", CONTENT_LENGTH => message.length.to_s }, bp] + end + end + + # Body class used for gzip encoded responses. + class GzipStream + + BUFFER_LENGTH = 128 * 1_024 + + # Initialize the gzip stream. Arguments: + # body :: Response body to compress with gzip + # mtime :: The modification time of the body, used to set the + # modification time in the gzip header. + # sync :: Whether to flush each gzip chunk as soon as it is ready. + def initialize(body, mtime, sync) + @body = body + @mtime = mtime + @sync = sync + end + + # Yield gzip compressed strings to the given block. + def each(&block) + @writer = block + gzip = ::Zlib::GzipWriter.new(self) + gzip.mtime = @mtime if @mtime + # @body.each is equivalent to @body.gets (slow) + if @body.is_a? ::File # XXX: Should probably be ::IO + while part = @body.read(BUFFER_LENGTH) + gzip.write(part) + gzip.flush if @sync + end + else + @body.each { |part| + # Skip empty strings, as they would result in no output, + # and flushing empty parts would raise Zlib::BufError. + next if part.empty? + gzip.write(part) + gzip.flush if @sync + } + end + ensure + gzip.finish + end + + # Call the block passed to #each with the gzipped data. + def write(data) + @writer.call(data) + end + + # Close the original body if possible. + def close + @body.close if @body.respond_to?(:close) + end + end + + private + + # Whether the body should be compressed. + def should_deflate?(env, status, headers, body) + # Skip compressing empty entity body responses and responses with + # no-transform set. + if Utils::STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) || + /\bno-transform\b/.match?(headers[CACHE_CONTROL].to_s) || + headers['content-encoding']&.!~(/\bidentity\b/) + return false + end + + # Skip if @compressible_types are given and does not include request's content type + return false if @compressible_types && !(headers.has_key?(CONTENT_TYPE) && @compressible_types.include?(headers[CONTENT_TYPE][/[^;]*/])) + + # Skip if @condition lambda is given and evaluates to false + return false if @condition && !@condition.call(env, status, headers, body) + + # No point in compressing empty body, also handles usage with + # Rack::Sendfile. + return false if headers[CONTENT_LENGTH] == '0' + + true + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/directory.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/directory.rb new file mode 100644 index 0000000..089623f --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/directory.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'time' + +require_relative 'constants' +require_relative 'utils' +require_relative 'head' +require_relative 'mime' +require_relative 'files' + +module Rack + # Rack::Directory serves entries below the +root+ given, according to the + # path info of the Rack request. If a directory is found, the file's contents + # will be presented in an html based index. If a file is found, the env will + # be passed to the specified +app+. + # + # If +app+ is not specified, a Rack::Files of the same +root+ will be used. + + class Directory + DIR_FILE = "%s%s%s%s\n" + DIR_PAGE_HEADER = <<-PAGE + + %s + + + +

%s

+
+ + + + + + + + PAGE + DIR_PAGE_FOOTER = <<-PAGE +
NameSizeTypeLast Modified
+
+ + PAGE + + # Body class for directory entries, showing an index page with links + # to each file. + class DirectoryBody < Struct.new(:root, :path, :files) + # Yield strings for each part of the directory entry + def each + show_path = Utils.escape_html(path.sub(/^#{root}/, '')) + yield(DIR_PAGE_HEADER % [ show_path, show_path ]) + + unless path.chomp('/') == root + yield(DIR_FILE % DIR_FILE_escape(files.call('..'))) + end + + Dir.foreach(path) do |basename| + next if basename.start_with?('.') + next unless f = files.call(basename) + yield(DIR_FILE % DIR_FILE_escape(f)) + end + + yield(DIR_PAGE_FOOTER) + end + + private + + # Escape each element in the array of html strings. + def DIR_FILE_escape(htmls) + htmls.map { |e| Utils.escape_html(e) } + end + end + + # The root of the directory hierarchy. Only requests for files and + # directories inside of the root directory are supported. + attr_reader :root + + # Set the root directory and application for serving files. + def initialize(root, app = nil) + @root = ::File.expand_path(root) + @app = app || Files.new(@root) + @head = Head.new(method(:get)) + end + + def call(env) + # strip body if this is a HEAD call + @head.call env + end + + # Internals of request handling. Similar to call but does + # not remove body for HEAD requests. + def get(env) + script_name = env[SCRIPT_NAME] + path_info = Utils.unescape_path(env[PATH_INFO]) + + if client_error_response = check_bad_request(path_info) || check_forbidden(path_info) + client_error_response + else + path = ::File.join(@root, path_info) + list_path(env, path, path_info, script_name) + end + end + + # Rack response to use for requests with invalid paths, or nil if path is valid. + def check_bad_request(path_info) + return if Utils.valid_path?(path_info) + + body = "Bad Request\n" + [400, { CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.bytesize.to_s, + "x-cascade" => "pass" }, [body]] + end + + # Rack response to use for requests with paths outside the root, or nil if path is inside the root. + def check_forbidden(path_info) + return unless path_info.include? ".." + return if ::File.expand_path(::File.join(@root, path_info)).start_with?(@root) + + body = "Forbidden\n" + [403, { CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.bytesize.to_s, + "x-cascade" => "pass" }, [body]] + end + + # Rack response to use for directories under the root. + def list_directory(path_info, path, script_name) + url_head = (script_name.split('/') + path_info.split('/')).map do |part| + Utils.escape_path part + end + + # Globbing not safe as path could contain glob metacharacters + body = DirectoryBody.new(@root, path, ->(basename) do + stat = stat(::File.join(path, basename)) + next unless stat + + url = ::File.join(*url_head + [Utils.escape_path(basename)]) + mtime = stat.mtime.httpdate + if stat.directory? + type = 'directory' + size = '-' + url << '/' + if basename == '..' + basename = 'Parent Directory' + else + basename << '/' + end + else + type = Mime.mime_type(::File.extname(basename)) + size = filesize_format(stat.size) + end + + [ url, basename, size, type, mtime ] + end) + + [ 200, { CONTENT_TYPE => 'text/html; charset=utf-8' }, body ] + end + + # File::Stat for the given path, but return nil for missing/bad entries. + def stat(path) + ::File.stat(path) + rescue Errno::ENOENT, Errno::ELOOP + return nil + end + + # Rack response to use for files and directories under the root. + # Unreadable and non-file, non-directory entries will get a 404 response. + def list_path(env, path, path_info, script_name) + if (stat = stat(path)) && stat.readable? + return @app.call(env) if stat.file? + return list_directory(path_info, path, script_name) if stat.directory? + end + + entity_not_found(path_info) + end + + # Rack response to use for unreadable and non-file, non-directory entries. + def entity_not_found(path_info) + body = "Entity not found: #{path_info}\n" + [404, { CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.bytesize.to_s, + "x-cascade" => "pass" }, [body]] + end + + # Stolen from Ramaze + FILESIZE_FORMAT = [ + ['%.1fT', 1 << 40], + ['%.1fG', 1 << 30], + ['%.1fM', 1 << 20], + ['%.1fK', 1 << 10], + ] + + # Provide human readable file sizes + def filesize_format(int) + FILESIZE_FORMAT.each do |format, size| + return format % (int.to_f / size) if int >= size + end + + "#{int}B" + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/etag.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/etag.rb new file mode 100644 index 0000000..fa78b47 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/etag.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'digest/sha2' + +require_relative 'constants' +require_relative 'utils' + +module Rack + # Automatically sets the etag header on all String bodies. + # + # The etag header is skipped if etag or last-modified headers are sent or if + # a sendfile body (body.responds_to :to_path) is given (since such cases + # should be handled by apache/nginx). + # + # On initialization, you can pass two parameters: a cache-control directive + # used when etag is absent and a directive when it is present. The first + # defaults to nil, while the second defaults to "max-age=0, private, must-revalidate" + class ETag + ETAG_STRING = Rack::ETAG + DEFAULT_CACHE_CONTROL = "max-age=0, private, must-revalidate" + + def initialize(app, no_cache_control = nil, cache_control = DEFAULT_CACHE_CONTROL) + @app = app + @cache_control = cache_control + @no_cache_control = no_cache_control + end + + def call(env) + status, headers, body = response = @app.call(env) + + if etag_status?(status) && body.respond_to?(:to_ary) && !skip_caching?(headers) + body = body.to_ary + digest = digest_body(body) + headers[ETAG_STRING] = %(W/"#{digest}") if digest + end + + unless headers[CACHE_CONTROL] + if digest + headers[CACHE_CONTROL] = @cache_control if @cache_control + else + headers[CACHE_CONTROL] = @no_cache_control if @no_cache_control + end + end + + response + end + + private + + def etag_status?(status) + status == 200 || status == 201 + end + + def skip_caching?(headers) + headers.key?(ETAG_STRING) || headers.key?('last-modified') + end + + def digest_body(body) + digest = nil + + body.each do |part| + (digest ||= Digest::SHA256.new) << part unless part.empty? + end + + digest && digest.hexdigest.byteslice(0,32) + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/events.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/events.rb new file mode 100644 index 0000000..c7bb201 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/events.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require_relative 'body_proxy' +require_relative 'request' +require_relative 'response' + +module Rack + ### This middleware provides hooks to certain places in the request / + # response lifecycle. This is so that middleware that don't need to filter + # the response data can safely leave it alone and not have to send messages + # down the traditional "rack stack". + # + # The events are: + # + # * on_start(request, response) + # + # This event is sent at the start of the request, before the next + # middleware in the chain is called. This method is called with a request + # object, and a response object. Right now, the response object is always + # nil, but in the future it may actually be a real response object. + # + # * on_commit(request, response) + # + # The response has been committed. The application has returned, but the + # response has not been sent to the webserver yet. This method is always + # called with a request object and the response object. The response + # object is constructed from the rack triple that the application returned. + # Changes may still be made to the response object at this point. + # + # * on_send(request, response) + # + # The webserver has started iterating over the response body and presumably + # has started sending data over the wire. This method is always called with + # a request object and the response object. The response object is + # constructed from the rack triple that the application returned. Changes + # SHOULD NOT be made to the response object as the webserver has already + # started sending data. Any mutations will likely result in an exception. + # + # * on_finish(request, response) + # + # The webserver has closed the response, and all data has been written to + # the response socket. The request and response object should both be + # read-only at this point. The body MAY NOT be available on the response + # object as it may have been flushed to the socket. + # + # * on_error(request, response, error) + # + # An exception has occurred in the application or an `on_commit` event. + # This method will get the request, the response (if available) and the + # exception that was raised. + # + # ## Order + # + # `on_start` is called on the handlers in the order that they were passed to + # the constructor. `on_commit`, on_send`, `on_finish`, and `on_error` are + # called in the reverse order. `on_finish` handlers are called inside an + # `ensure` block, so they are guaranteed to be called even if something + # raises an exception. If something raises an exception in a `on_finish` + # method, then nothing is guaranteed. + + class Events + module Abstract + def on_start(req, res) + end + + def on_commit(req, res) + end + + def on_send(req, res) + end + + def on_finish(req, res) + end + + def on_error(req, res, e) + end + end + + class EventedBodyProxy < Rack::BodyProxy # :nodoc: + attr_reader :request, :response + + def initialize(body, request, response, handlers, &block) + super(body, &block) + @request = request + @response = response + @handlers = handlers + end + + def each + @handlers.reverse_each { |handler| handler.on_send request, response } + super + end + end + + class BufferedResponse < Rack::Response::Raw # :nodoc: + attr_reader :body + + def initialize(status, headers, body) + super(status, headers) + @body = body + end + + def to_a; [status, headers, body]; end + end + + def initialize(app, handlers) + @app = app + @handlers = handlers + end + + def call(env) + request = make_request env + on_start request, nil + + begin + status, headers, body = @app.call request.env + response = make_response status, headers, body + on_commit request, response + rescue StandardError => e + on_error request, response, e + on_finish request, response + raise + end + + body = EventedBodyProxy.new(body, request, response, @handlers) do + on_finish request, response + end + [response.status, response.headers, body] + end + + private + + def on_error(request, response, e) + @handlers.reverse_each { |handler| handler.on_error request, response, e } + end + + def on_commit(request, response) + @handlers.reverse_each { |handler| handler.on_commit request, response } + end + + def on_start(request, response) + @handlers.each { |handler| handler.on_start request, nil } + end + + def on_finish(request, response) + @handlers.reverse_each { |handler| handler.on_finish request, response } + end + + def make_request(env) + Rack::Request.new env + end + + def make_response(status, headers, body) + BufferedResponse.new status, headers, body + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/files.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/files.rb new file mode 100644 index 0000000..5b8353f --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/files.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require 'time' + +require_relative 'constants' +require_relative 'head' +require_relative 'utils' +require_relative 'request' +require_relative 'mime' + +module Rack + # Rack::Files serves files below the +root+ directory given, according to the + # path info of the Rack request. + # e.g. when Rack::Files.new("/etc") is used, you can access 'passwd' file + # as http://localhost:9292/passwd + # + # Handlers can detect if bodies are a Rack::Files, and use mechanisms + # like sendfile on the +path+. + + class Files + ALLOWED_VERBS = %w[GET HEAD OPTIONS] + ALLOW_HEADER = ALLOWED_VERBS.join(', ') + MULTIPART_BOUNDARY = 'AaB03x' + + attr_reader :root + + def initialize(root, headers = {}, default_mime = 'text/plain') + @root = (::File.expand_path(root) if root) + @headers = headers + @default_mime = default_mime + @head = Rack::Head.new(lambda { |env| get env }) + end + + def call(env) + # HEAD requests drop the response body, including 4xx error messages. + @head.call env + end + + def get(env) + request = Rack::Request.new env + unless ALLOWED_VERBS.include? request.request_method + return fail(405, "Method Not Allowed", { 'allow' => ALLOW_HEADER }) + end + + path_info = Utils.unescape_path request.path_info + return fail(400, "Bad Request") unless Utils.valid_path?(path_info) + + clean_path_info = Utils.clean_path_info(path_info) + path = ::File.join(@root, clean_path_info) + + available = begin + ::File.file?(path) && ::File.readable?(path) + rescue SystemCallError + # Not sure in what conditions this exception can occur, but this + # is a safe way to handle such an error. + # :nocov: + false + # :nocov: + end + + if available + serving(request, path) + else + fail(404, "File not found: #{path_info}") + end + end + + def serving(request, path) + if request.options? + return [200, { 'allow' => ALLOW_HEADER, CONTENT_LENGTH => '0' }, []] + end + last_modified = ::File.mtime(path).httpdate + return [304, {}, []] if request.get_header('HTTP_IF_MODIFIED_SINCE') == last_modified + + headers = { "last-modified" => last_modified } + mime_type = mime_type path, @default_mime + headers[CONTENT_TYPE] = mime_type if mime_type + + # Set custom headers + headers.merge!(@headers) if @headers + + status = 200 + size = filesize path + + ranges = Rack::Utils.get_byte_ranges(request.get_header('HTTP_RANGE'), size) + if ranges.nil? + # No ranges: + ranges = [0..size - 1] + elsif ranges.empty? + # Unsatisfiable. Return error, and file size: + response = fail(416, "Byte range unsatisfiable") + response[1]["content-range"] = "bytes */#{size}" + return response + else + # Partial content + partial_content = true + + if ranges.size == 1 + range = ranges[0] + headers["content-range"] = "bytes #{range.begin}-#{range.end}/#{size}" + else + headers[CONTENT_TYPE] = "multipart/byteranges; boundary=#{MULTIPART_BOUNDARY}" + end + + status = 206 + body = BaseIterator.new(path, ranges, mime_type: mime_type, size: size) + size = body.bytesize + end + + headers[CONTENT_LENGTH] = size.to_s + + if request.head? + body = [] + elsif !partial_content + body = Iterator.new(path, ranges, mime_type: mime_type, size: size) + end + + [status, headers, body] + end + + class BaseIterator + attr_reader :path, :ranges, :options + + def initialize(path, ranges, options) + @path = path + @ranges = ranges + @options = options + end + + def each + ::File.open(path, "rb") do |file| + ranges.each do |range| + yield multipart_heading(range) if multipart? + + each_range_part(file, range) do |part| + yield part + end + end + + yield "\r\n--#{MULTIPART_BOUNDARY}--\r\n" if multipart? + end + end + + def bytesize + size = ranges.inject(0) do |sum, range| + sum += multipart_heading(range).bytesize if multipart? + sum += range.size + end + size += "\r\n--#{MULTIPART_BOUNDARY}--\r\n".bytesize if multipart? + size + end + + def close; end + + private + + def multipart? + ranges.size > 1 + end + + def multipart_heading(range) +<<-EOF +\r +--#{MULTIPART_BOUNDARY}\r +content-type: #{options[:mime_type]}\r +content-range: bytes #{range.begin}-#{range.end}/#{options[:size]}\r +\r +EOF + end + + def each_range_part(file, range) + file.seek(range.begin) + remaining_len = range.end - range.begin + 1 + while remaining_len > 0 + part = file.read([8192, remaining_len].min) + break unless part + remaining_len -= part.length + + yield part + end + end + end + + class Iterator < BaseIterator + alias :to_path :path + end + + private + + def fail(status, body, headers = {}) + body += "\n" + + [ + status, + { + CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.size.to_s, + "x-cascade" => "pass" + }.merge!(headers), + [body] + ] + end + + # The MIME type for the contents of the file located at @path + def mime_type(path, default_mime) + Mime.mime_type(::File.extname(path), default_mime) + end + + def filesize(path) + # We check via File::size? whether this file provides size info + # via stat (e.g. /proc files often don't), otherwise we have to + # figure it out by reading the whole file into memory. + ::File.size?(path) || ::File.read(path).bytesize + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/head.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/head.rb new file mode 100644 index 0000000..c1c430f --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/head.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'body_proxy' + +module Rack + # Rack::Head returns an empty body for all HEAD requests. It leaves + # all other requests unchanged. + class Head + def initialize(app) + @app = app + end + + def call(env) + _, _, body = response = @app.call(env) + + if env[REQUEST_METHOD] == HEAD + response[2] = Rack::BodyProxy.new([]) do + body.close if body.respond_to? :close + end + end + + response + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/headers.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/headers.rb new file mode 100644 index 0000000..cedf3a8 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/headers.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +module Rack + # Rack::Headers is a Hash subclass that downcases all keys. It's designed + # to be used by rack applications that don't implement the Rack 3 SPEC + # (by using non-lowercase response header keys), automatically handling + # the downcasing of keys. + class Headers < Hash + KNOWN_HEADERS = {} + %w( + Accept-CH + Accept-Patch + Accept-Ranges + Access-Control-Allow-Credentials + Access-Control-Allow-Headers + Access-Control-Allow-Methods + Access-Control-Allow-Origin + Access-Control-Expose-Headers + Access-Control-Max-Age + Age + Allow + Alt-Svc + Cache-Control + Connection + Content-Disposition + Content-Encoding + Content-Language + Content-Length + Content-Location + Content-MD5 + Content-Range + Content-Security-Policy + Content-Security-Policy-Report-Only + Content-Type + Date + Delta-Base + ETag + Expect-CT + Expires + Feature-Policy + IM + Last-Modified + Link + Location + NEL + P3P + Permissions-Policy + Pragma + Preference-Applied + Proxy-Authenticate + Public-Key-Pins + Referrer-Policy + Refresh + Report-To + Retry-After + Server + Set-Cookie + Status + Strict-Transport-Security + Timing-Allow-Origin + Tk + Trailer + Transfer-Encoding + Upgrade + Vary + Via + WWW-Authenticate + Warning + X-Cascade + X-Content-Duration + X-Content-Security-Policy + X-Content-Type-Options + X-Correlation-ID + X-Correlation-Id + X-Download-Options + X-Frame-Options + X-Permitted-Cross-Domain-Policies + X-Powered-By + X-Redirect-By + X-Request-ID + X-Request-Id + X-Runtime + X-UA-Compatible + X-WebKit-CS + X-XSS-Protection + ).each do |str| + downcased = str.downcase.freeze + KNOWN_HEADERS[str] = KNOWN_HEADERS[downcased] = downcased + end + + def self.[](*items) + if items.length % 2 != 0 + if items.length == 1 && items.first.is_a?(Hash) + new.merge!(items.first) + else + raise ArgumentError, "odd number of arguments for Rack::Headers" + end + else + hash = new + loop do + break if items.length == 0 + key = items.shift + value = items.shift + hash[key] = value + end + hash + end + end + + def [](key) + super(downcase_key(key)) + end + + def []=(key, value) + super(KNOWN_HEADERS[key] || key.downcase.freeze, value) + end + alias store []= + + def assoc(key) + super(downcase_key(key)) + end + + def compare_by_identity + raise TypeError, "Rack::Headers cannot compare by identity, use regular Hash" + end + + def delete(key) + super(downcase_key(key)) + end + + def dig(key, *a) + super(downcase_key(key), *a) + end + + def fetch(key, *default, &block) + key = downcase_key(key) + super + end + + def fetch_values(*a) + super(*a.map!{|key| downcase_key(key)}) + end + + def has_key?(key) + super(downcase_key(key)) + end + alias include? has_key? + alias key? has_key? + alias member? has_key? + + def invert + hash = self.class.new + each{|key, value| hash[value] = key} + hash + end + + def merge(hash, &block) + dup.merge!(hash, &block) + end + + def reject(&block) + hash = dup + hash.reject!(&block) + hash + end + + def replace(hash) + clear + update(hash) + end + + def select(&block) + hash = dup + hash.select!(&block) + hash + end + + def to_proc + lambda{|x| self[x]} + end + + def transform_values(&block) + dup.transform_values!(&block) + end + + def update(hash, &block) + hash.each do |key, value| + self[key] = if block_given? && include?(key) + block.call(key, self[key], value) + else + value + end + end + self + end + alias merge! update + + def values_at(*keys) + keys.map{|key| self[key]} + end + + # :nocov: + if RUBY_VERSION >= '2.5' + # :nocov: + def slice(*a) + h = self.class.new + a.each{|k| h[k] = self[k] if has_key?(k)} + h + end + + def transform_keys(&block) + dup.transform_keys!(&block) + end + + def transform_keys! + hash = self.class.new + each do |k, v| + hash[yield k] = v + end + replace(hash) + end + end + + # :nocov: + if RUBY_VERSION >= '3.0' + # :nocov: + def except(*a) + super(*a.map!{|key| downcase_key(key)}) + end + end + + private + + def downcase_key(key) + key.is_a?(String) ? KNOWN_HEADERS[key] || key.downcase : key + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lint.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lint.rb new file mode 100644 index 0000000..4f36c2e --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lint.rb @@ -0,0 +1,991 @@ +# frozen_string_literal: true + +require 'forwardable' +require 'uri' + +require_relative 'constants' +require_relative 'utils' + +module Rack + # Rack::Lint validates your application and the requests and + # responses according to the Rack spec. + + class Lint + REQUEST_PATH_ORIGIN_FORM = /\A\/[^#]*\z/ + REQUEST_PATH_ABSOLUTE_FORM = /\A#{Utils::URI_PARSER.make_regexp}\z/ + REQUEST_PATH_AUTHORITY_FORM = /\A[^\/:]+:\d+\z/ + REQUEST_PATH_ASTERISK_FORM = '*' + + def initialize(app) + @app = app + end + + # :stopdoc: + + class LintError < RuntimeError; end + # AUTHORS: n.b. The trailing whitespace between paragraphs is important and + # should not be removed. The whitespace creates paragraphs in the RDoc + # output. + # + ## This specification aims to formalize the Rack protocol. You + ## can (and should) use Rack::Lint to enforce it. + ## + ## When you develop middleware, be sure to add a Lint before and + ## after to catch all mistakes. + ## + ## = Rack applications + ## + ## A Rack application is a Ruby object (not a class) that + ## responds to +call+. + def call(env = nil) + Wrapper.new(@app, env).response + end + + class Wrapper + def initialize(app, env) + @app = app + @env = env + @response = nil + @head_request = false + + @status = nil + @headers = nil + @body = nil + @invoked = nil + @content_length = nil + @closed = false + @size = 0 + end + + def response + ## It takes exactly one argument, the *environment* + raise LintError, "No env given" unless @env + check_environment(@env) + + ## and returns a non-frozen Array of exactly three values: + @response = @app.call(@env) + raise LintError, "response is not an Array, but #{@response.class}" unless @response.kind_of? Array + raise LintError, "response is frozen" if @response.frozen? + raise LintError, "response array has #{@response.size} elements instead of 3" unless @response.size == 3 + + @status, @headers, @body = @response + ## The *status*, + check_status(@status) + + ## the *headers*, + check_headers(@headers) + + hijack_proc = check_hijack_response(@headers, @env) + if hijack_proc + @headers[RACK_HIJACK] = hijack_proc + end + + ## and the *body*. + check_content_type_header(@status, @headers) + check_content_length_header(@status, @headers) + check_rack_protocol_header(@status, @headers) + @head_request = @env[REQUEST_METHOD] == HEAD + + @lint = (@env['rack.lint'] ||= []) << self + + if (@env['rack.lint.body_iteration'] ||= 0) > 0 + raise LintError, "Middleware must not call #each directly" + end + + return [@status, @headers, self] + end + + ## + ## == The Environment + ## + def check_environment(env) + ## The environment must be an unfrozen instance of Hash that includes + ## CGI-like headers. The Rack application is free to modify the + ## environment. + raise LintError, "env #{env.inspect} is not a Hash, but #{env.class}" unless env.kind_of? Hash + raise LintError, "env should not be frozen, but is" if env.frozen? + + ## + ## The environment is required to include these variables + ## (adopted from {PEP 333}[https://peps.python.org/pep-0333/]), except when they'd be empty, but see + ## below. + + ## REQUEST_METHOD:: The HTTP request method, such as + ## "GET" or "POST". This cannot ever + ## be an empty string, and so is + ## always required. + + ## SCRIPT_NAME:: The initial portion of the request + ## URL's "path" that corresponds to the + ## application object, so that the + ## application knows its virtual + ## "location". This may be an empty + ## string, if the application corresponds + ## to the "root" of the server. + + ## PATH_INFO:: The remainder of the request URL's + ## "path", designating the virtual + ## "location" of the request's target + ## within the application. This may be an + ## empty string, if the request URL targets + ## the application root and does not have a + ## trailing slash. This value may be + ## percent-encoded when originating from + ## a URL. + + ## QUERY_STRING:: The portion of the request URL that + ## follows the ?, if any. May be + ## empty, but is always required! + + ## SERVER_NAME:: When combined with SCRIPT_NAME and + ## PATH_INFO, these variables can be + ## used to complete the URL. Note, however, + ## that HTTP_HOST, if present, + ## should be used in preference to + ## SERVER_NAME for reconstructing + ## the request URL. + ## SERVER_NAME can never be an empty + ## string, and so is always required. + + ## SERVER_PORT:: An optional +Integer+ which is the port the + ## server is running on. Should be specified if + ## the server is running on a non-standard port. + + ## SERVER_PROTOCOL:: A string representing the HTTP version used + ## for the request. + + ## HTTP_ Variables:: Variables corresponding to the + ## client-supplied HTTP request + ## headers (i.e., variables whose + ## names begin with HTTP_). The + ## presence or absence of these + ## variables should correspond with + ## the presence or absence of the + ## appropriate HTTP header in the + ## request. See + ## {RFC3875 section 4.1.18}[https://tools.ietf.org/html/rfc3875#section-4.1.18] + ## for specific behavior. + + ## In addition to this, the Rack environment must include these + ## Rack-specific variables: + + ## rack.url_scheme:: +http+ or +https+, depending on the + ## request URL. + + ## rack.input:: See below, the input stream. + + ## rack.errors:: See below, the error stream. + + ## rack.hijack?:: See below, if present and true, indicates + ## that the server supports partial hijacking. + + ## rack.hijack:: See below, if present, an object responding + ## to +call+ that is used to perform a full + ## hijack. + + ## rack.protocol:: An optional +Array+ of +String+, containing + ## the protocols advertised by the client in + ## the +upgrade+ header (HTTP/1) or the + ## +:protocol+ pseudo-header (HTTP/2). + if protocols = @env['rack.protocol'] + unless protocols.is_a?(Array) && protocols.all?{|protocol| protocol.is_a?(String)} + raise LintError, "rack.protocol must be an Array of Strings" + end + end + + ## Additional environment specifications have approved to + ## standardized middleware APIs. None of these are required to + ## be implemented by the server. + + ## rack.session:: A hash-like interface for storing + ## request session data. + ## The store must implement: + if session = env[RACK_SESSION] + ## store(key, value) (aliased as []=); + unless session.respond_to?(:store) && session.respond_to?(:[]=) + raise LintError, "session #{session.inspect} must respond to store and []=" + end + + ## fetch(key, default = nil) (aliased as []); + unless session.respond_to?(:fetch) && session.respond_to?(:[]) + raise LintError, "session #{session.inspect} must respond to fetch and []" + end + + ## delete(key); + unless session.respond_to?(:delete) + raise LintError, "session #{session.inspect} must respond to delete" + end + + ## clear; + unless session.respond_to?(:clear) + raise LintError, "session #{session.inspect} must respond to clear" + end + + ## to_hash (returning unfrozen Hash instance); + unless session.respond_to?(:to_hash) && session.to_hash.kind_of?(Hash) && !session.to_hash.frozen? + raise LintError, "session #{session.inspect} must respond to to_hash and return unfrozen Hash instance" + end + end + + ## rack.logger:: A common object interface for logging messages. + ## The object must implement: + if logger = env[RACK_LOGGER] + ## info(message, &block) + unless logger.respond_to?(:info) + raise LintError, "logger #{logger.inspect} must respond to info" + end + + ## debug(message, &block) + unless logger.respond_to?(:debug) + raise LintError, "logger #{logger.inspect} must respond to debug" + end + + ## warn(message, &block) + unless logger.respond_to?(:warn) + raise LintError, "logger #{logger.inspect} must respond to warn" + end + + ## error(message, &block) + unless logger.respond_to?(:error) + raise LintError, "logger #{logger.inspect} must respond to error" + end + + ## fatal(message, &block) + unless logger.respond_to?(:fatal) + raise LintError, "logger #{logger.inspect} must respond to fatal" + end + end + + ## rack.multipart.buffer_size:: An Integer hint to the multipart parser as to what chunk size to use for reads and writes. + if bufsize = env[RACK_MULTIPART_BUFFER_SIZE] + unless bufsize.is_a?(Integer) && bufsize > 0 + raise LintError, "rack.multipart.buffer_size must be an Integer > 0 if specified" + end + end + + ## rack.multipart.tempfile_factory:: An object responding to #call with two arguments, the filename and content_type given for the multipart form field, and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate the tempfile for each multipart form file upload field, rather than the default class of Tempfile. + if tempfile_factory = env[RACK_MULTIPART_TEMPFILE_FACTORY] + raise LintError, "rack.multipart.tempfile_factory must respond to #call" unless tempfile_factory.respond_to?(:call) + env[RACK_MULTIPART_TEMPFILE_FACTORY] = lambda do |filename, content_type| + io = tempfile_factory.call(filename, content_type) + raise LintError, "rack.multipart.tempfile_factory return value must respond to #<<" unless io.respond_to?(:<<) + io + end + end + + ## The server or the application can store their own data in the + ## environment, too. The keys must contain at least one dot, + ## and should be prefixed uniquely. The prefix rack. + ## is reserved for use with the Rack core distribution and other + ## accepted specifications and must not be used otherwise. + ## + %w[REQUEST_METHOD SERVER_NAME QUERY_STRING SERVER_PROTOCOL rack.errors].each do |header| + raise LintError, "env missing required key #{header}" unless env.include? header + end + + ## The SERVER_PORT must be an Integer if set. + server_port = env["SERVER_PORT"] + unless server_port.nil? || (Integer(server_port) rescue false) + raise LintError, "env[SERVER_PORT] is not an Integer" + end + + ## The SERVER_NAME must be a valid authority as defined by RFC7540. + unless (URI.parse("http://#{env[SERVER_NAME]}/") rescue false) + raise LintError, "#{env[SERVER_NAME]} must be a valid authority" + end + + ## The HTTP_HOST must be a valid authority as defined by RFC7540. + unless (URI.parse("http://#{env[HTTP_HOST]}/") rescue false) + raise LintError, "#{env[HTTP_HOST]} must be a valid authority" + end + + ## The SERVER_PROTOCOL must match the regexp HTTP/\d(\.\d)?. + server_protocol = env['SERVER_PROTOCOL'] + unless %r{HTTP/\d(\.\d)?}.match?(server_protocol) + raise LintError, "env[SERVER_PROTOCOL] does not match HTTP/\\d(\\.\\d)?" + end + + ## The environment must not contain the keys + ## HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH + ## (use the versions without HTTP_). + %w[HTTP_CONTENT_TYPE HTTP_CONTENT_LENGTH].each { |header| + if env.include? header + raise LintError, "env contains #{header}, must use #{header[5..-1]}" + end + } + + ## The CGI keys (named without a period) must have String values. + ## If the string values for CGI keys contain non-ASCII characters, + ## they should use ASCII-8BIT encoding. + env.each { |key, value| + next if key.include? "." # Skip extensions + unless value.kind_of? String + raise LintError, "env variable #{key} has non-string value #{value.inspect}" + end + next if value.encoding == Encoding::ASCII_8BIT + unless value.b !~ /[\x80-\xff]/n + raise LintError, "env variable #{key} has value containing non-ASCII characters and has non-ASCII-8BIT encoding #{value.inspect} encoding: #{value.encoding}" + end + } + + ## There are the following restrictions: + + ## * rack.url_scheme must either be +http+ or +https+. + unless %w[http https].include?(env[RACK_URL_SCHEME]) + raise LintError, "rack.url_scheme unknown: #{env[RACK_URL_SCHEME].inspect}" + end + + ## * There may be a valid input stream in rack.input. + if rack_input = env[RACK_INPUT] + check_input_stream(rack_input) + @env[RACK_INPUT] = InputWrapper.new(rack_input) + end + + ## * There must be a valid error stream in rack.errors. + rack_errors = env[RACK_ERRORS] + check_error_stream(rack_errors) + @env[RACK_ERRORS] = ErrorWrapper.new(rack_errors) + + ## * There may be a valid hijack callback in rack.hijack + check_hijack env + ## * There may be a valid early hints callback in rack.early_hints + check_early_hints env + + ## * The REQUEST_METHOD must be a valid token. + unless env[REQUEST_METHOD] =~ /\A[0-9A-Za-z!\#$%&'*+.^_`|~-]+\z/ + raise LintError, "REQUEST_METHOD unknown: #{env[REQUEST_METHOD].dump}" + end + + ## * The SCRIPT_NAME, if non-empty, must start with / + if env.include?(SCRIPT_NAME) && env[SCRIPT_NAME] != "" && env[SCRIPT_NAME] !~ /\A\// + raise LintError, "SCRIPT_NAME must start with /" + end + + ## * The PATH_INFO, if provided, must be a valid request target or an empty string. + if env.include?(PATH_INFO) + case env[PATH_INFO] + when REQUEST_PATH_ASTERISK_FORM + ## * Only OPTIONS requests may have PATH_INFO set to * (asterisk-form). + unless env[REQUEST_METHOD] == OPTIONS + raise LintError, "Only OPTIONS requests may have PATH_INFO set to '*' (asterisk-form)" + end + when REQUEST_PATH_AUTHORITY_FORM + ## * Only CONNECT requests may have PATH_INFO set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. + unless env[REQUEST_METHOD] == CONNECT + raise LintError, "Only CONNECT requests may have PATH_INFO set to an authority (authority-form)" + end + when REQUEST_PATH_ABSOLUTE_FORM + ## * CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form). + if env[REQUEST_METHOD] == CONNECT || env[REQUEST_METHOD] == OPTIONS + raise LintError, "CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form)" + end + when REQUEST_PATH_ORIGIN_FORM + ## * Otherwise, PATH_INFO must start with a / and must not include a fragment part starting with '#' (origin-form). + when "" + # Empty string is okay. + else + raise LintError, "PATH_INFO must start with a '/' and must not include a fragment part starting with '#' (origin-form)" + end + end + + ## * The CONTENT_LENGTH, if given, must consist of digits only. + if env.include?("CONTENT_LENGTH") && env["CONTENT_LENGTH"] !~ /\A\d+\z/ + raise LintError, "Invalid CONTENT_LENGTH: #{env["CONTENT_LENGTH"]}" + end + + ## * One of SCRIPT_NAME or PATH_INFO must be + ## set. PATH_INFO should be / if + ## SCRIPT_NAME is empty. + unless env[SCRIPT_NAME] || env[PATH_INFO] + raise LintError, "One of SCRIPT_NAME or PATH_INFO must be set (make PATH_INFO '/' if SCRIPT_NAME is empty)" + end + ## SCRIPT_NAME never should be /, but instead be empty. + unless env[SCRIPT_NAME] != "/" + raise LintError, "SCRIPT_NAME cannot be '/', make it '' and PATH_INFO '/'" + end + + ## rack.response_finished:: An array of callables run by the server after the response has been + ## processed. This would typically be invoked after sending the response to the client, but it could also be + ## invoked if an error occurs while generating the response or sending the response; in that case, the error + ## argument will be a subclass of +Exception+. + ## The callables are invoked with +env, status, headers, error+ arguments and should not raise any + ## exceptions. They should be invoked in reverse order of registration. + if callables = env[RACK_RESPONSE_FINISHED] + raise LintError, "rack.response_finished must be an array of callable objects" unless callables.is_a?(Array) + + callables.each do |callable| + raise LintError, "rack.response_finished values must respond to call(env, status, headers, error)" unless callable.respond_to?(:call) + end + end + end + + ## + ## === The Input Stream + ## + ## The input stream is an IO-like object which contains the raw HTTP + ## POST data. + def check_input_stream(input) + ## When applicable, its external encoding must be "ASCII-8BIT" and it + ## must be opened in binary mode. + if input.respond_to?(:external_encoding) && input.external_encoding != Encoding::ASCII_8BIT + raise LintError, "rack.input #{input} does not have ASCII-8BIT as its external encoding" + end + if input.respond_to?(:binmode?) && !input.binmode? + raise LintError, "rack.input #{input} is not opened in binary mode" + end + + ## The input stream must respond to +gets+, +each+, and +read+. + [:gets, :each, :read].each { |method| + unless input.respond_to? method + raise LintError, "rack.input #{input} does not respond to ##{method}" + end + } + end + + class InputWrapper + def initialize(input) + @input = input + end + + ## * +gets+ must be called without arguments and return a string, + ## or +nil+ on EOF. + def gets(*args) + raise LintError, "rack.input#gets called with arguments" unless args.size == 0 + v = @input.gets + unless v.nil? or v.kind_of? String + raise LintError, "rack.input#gets didn't return a String" + end + v + end + + ## * +read+ behaves like IO#read. + ## Its signature is read([length, [buffer]]). + ## + ## If given, +length+ must be a non-negative Integer (>= 0) or +nil+, + ## and +buffer+ must be a String and may not be nil. + ## + ## If +length+ is given and not nil, then this method reads at most + ## +length+ bytes from the input stream. + ## + ## If +length+ is not given or nil, then this method reads + ## all data until EOF. + ## + ## When EOF is reached, this method returns nil if +length+ is given + ## and not nil, or "" if +length+ is not given or is nil. + ## + ## If +buffer+ is given, then the read data will be placed + ## into +buffer+ instead of a newly created String object. + def read(*args) + unless args.size <= 2 + raise LintError, "rack.input#read called with too many arguments" + end + if args.size >= 1 + unless args.first.kind_of?(Integer) || args.first.nil? + raise LintError, "rack.input#read called with non-integer and non-nil length" + end + unless args.first.nil? || args.first >= 0 + raise LintError, "rack.input#read called with a negative length" + end + end + if args.size >= 2 + unless args[1].kind_of?(String) + raise LintError, "rack.input#read called with non-String buffer" + end + end + + v = @input.read(*args) + + unless v.nil? or v.kind_of? String + raise LintError, "rack.input#read didn't return nil or a String" + end + if args[0].nil? + unless !v.nil? + raise LintError, "rack.input#read(nil) returned nil on EOF" + end + end + + v + end + + ## * +each+ must be called without arguments and only yield Strings. + def each(*args) + raise LintError, "rack.input#each called with arguments" unless args.size == 0 + @input.each { |line| + unless line.kind_of? String + raise LintError, "rack.input#each didn't yield a String" + end + yield line + } + end + + ## * +close+ can be called on the input stream to indicate that + ## any remaining input is not needed. + def close(*args) + @input.close(*args) + end + end + + ## + ## === The Error Stream + ## + def check_error_stream(error) + ## The error stream must respond to +puts+, +write+ and +flush+. + [:puts, :write, :flush].each { |method| + unless error.respond_to? method + raise LintError, "rack.error #{error} does not respond to ##{method}" + end + } + end + + class ErrorWrapper + def initialize(error) + @error = error + end + + ## * +puts+ must be called with a single argument that responds to +to_s+. + def puts(str) + @error.puts str + end + + ## * +write+ must be called with a single argument that is a String. + def write(str) + raise LintError, "rack.errors#write not called with a String" unless str.kind_of? String + @error.write str + end + + ## * +flush+ must be called without arguments and must be called + ## in order to make the error appear for sure. + def flush + @error.flush + end + + ## * +close+ must never be called on the error stream. + def close(*args) + raise LintError, "rack.errors#close must not be called" + end + end + + ## + ## === Hijacking + ## + ## The hijacking interfaces provides a means for an application to take + ## control of the HTTP connection. There are two distinct hijack + ## interfaces: full hijacking where the application takes over the raw + ## connection, and partial hijacking where the application takes over + ## just the response body stream. In both cases, the application is + ## responsible for closing the hijacked stream. + ## + ## Full hijacking only works with HTTP/1. Partial hijacking is functionally + ## equivalent to streaming bodies, and is still optionally supported for + ## backwards compatibility with older Rack versions. + ## + ## ==== Full Hijack + ## + ## Full hijack is used to completely take over an HTTP/1 connection. It + ## occurs before any headers are written and causes the request to + ## ignores any response generated by the application. + ## + ## It is intended to be used when applications need access to raw HTTP/1 + ## connection. + ## + def check_hijack(env) + ## If +rack.hijack+ is present in +env+, it must respond to +call+ + if original_hijack = env[RACK_HIJACK] + raise LintError, "rack.hijack must respond to call" unless original_hijack.respond_to?(:call) + + env[RACK_HIJACK] = proc do + io = original_hijack.call + + ## and return an +IO+ instance which can be used to read and write + ## to the underlying connection using HTTP/1 semantics and + ## formatting. + raise LintError, "rack.hijack must return an IO instance" unless io.is_a?(IO) + + io + end + end + end + + ## + ## ==== Partial Hijack + ## + ## Partial hijack is used for bi-directional streaming of the request and + ## response body. It occurs after the status and headers are written by + ## the server and causes the server to ignore the Body of the response. + ## + ## It is intended to be used when applications need bi-directional + ## streaming. + ## + def check_hijack_response(headers, env) + ## If +rack.hijack?+ is present in +env+ and truthy, + if env[RACK_IS_HIJACK] + ## an application may set the special response header +rack.hijack+ + if original_hijack = headers[RACK_HIJACK] + ## to an object that responds to +call+, + unless original_hijack.respond_to?(:call) + raise LintError, 'rack.hijack header must respond to #call' + end + ## accepting a +stream+ argument. + return proc do |io| + original_hijack.call StreamWrapper.new(io) + end + end + ## + ## After the response status and headers have been sent, this hijack + ## callback will be invoked with a +stream+ argument which follows the + ## same interface as outlined in "Streaming Body". Servers must + ## ignore the +body+ part of the response tuple when the + ## +rack.hijack+ response header is present. Using an empty +Array+ + ## instance is recommended. + else + ## + ## The special response header +rack.hijack+ must only be set + ## if the request +env+ has a truthy +rack.hijack?+. + if headers.key?(RACK_HIJACK) + raise LintError, 'rack.hijack header must not be present if server does not support hijacking' + end + end + + nil + end + + ## + ## === Early Hints + ## + ## The application or any middleware may call the rack.early_hints + ## with an object which would be valid as the headers of a Rack response. + def check_early_hints(env) + if env[RACK_EARLY_HINTS] + ## + ## If rack.early_hints is present, it must respond to #call. + unless env[RACK_EARLY_HINTS].respond_to?(:call) + raise LintError, "rack.early_hints must respond to call" + end + + original_callback = env[RACK_EARLY_HINTS] + env[RACK_EARLY_HINTS] = lambda do |headers| + ## If rack.early_hints is called, it must be called with + ## valid Rack response headers. + check_headers(headers) + original_callback.call(headers) + end + end + end + + ## + ## == The Response + ## + ## === The Status + ## + def check_status(status) + ## This is an HTTP status. It must be an Integer greater than or equal to + ## 100. + unless status.is_a?(Integer) && status >= 100 + raise LintError, "Status must be an Integer >=100" + end + end + + ## + ## === The Headers + ## + def check_headers(headers) + ## The headers must be a unfrozen Hash. + unless headers.kind_of?(Hash) + raise LintError, "headers object should be a hash, but isn't (got #{headers.class} as headers)" + end + + if headers.frozen? + raise LintError, "headers object should not be frozen, but is" + end + + headers.each do |key, value| + ## The header keys must be Strings. + unless key.kind_of? String + raise LintError, "header key must be a string, was #{key.class}" + end + + ## Special headers starting "rack." are for communicating with the + ## server, and must not be sent back to the client. + next if key.start_with?("rack.") + + ## The header must not contain a +Status+ key. + raise LintError, "header must not contain status" if key == "status" + ## Header keys must conform to RFC7230 token specification, i.e. cannot + ## contain non-printable ASCII, DQUOTE or "(),/:;<=>?@[\]{}". + raise LintError, "invalid header name: #{key}" if key =~ /[\(\),\/:;<=>\?@\[\\\]{}[:cntrl:]]/ + ## Header keys must not contain uppercase ASCII characters (A-Z). + raise LintError, "uppercase character in header name: #{key}" if key =~ /[A-Z]/ + + ## Header values must be either a String instance, + if value.kind_of?(String) + check_header_value(key, value) + elsif value.kind_of?(Array) + ## or an Array of String instances, + value.each{|value| check_header_value(key, value)} + else + raise LintError, "a header value must be a String or Array of Strings, but the value of '#{key}' is a #{value.class}" + end + end + end + + def check_header_value(key, value) + ## such that each String instance must not contain characters below 037. + if value =~ /[\000-\037]/ + raise LintError, "invalid header value #{key}: #{value.inspect}" + end + end + + ## + ## ==== The +content-type+ Header + ## + def check_content_type_header(status, headers) + headers.each { |key, value| + ## There must not be a content-type header key when the +Status+ is 1xx, + ## 204, or 304. + if key == "content-type" + if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i + raise LintError, "content-type header found in #{status} response, not allowed" + end + return + end + } + end + + ## + ## ==== The +content-length+ Header + ## + def check_content_length_header(status, headers) + headers.each { |key, value| + if key == 'content-length' + ## There must not be a content-length header key when the + ## +Status+ is 1xx, 204, or 304. + if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i + raise LintError, "content-length header found in #{status} response, not allowed" + end + @content_length = value + end + } + end + + def verify_content_length(size) + if @head_request + unless size == 0 + raise LintError, "Response body was given for HEAD request, but should be empty" + end + elsif @content_length + unless @content_length == size.to_s + raise LintError, "content-length header was #{@content_length}, but should be #{size}" + end + end + end + + ## + ## ==== The +rack.protocol+ Header + ## + def check_rack_protocol_header(status, headers) + ## If the +rack.protocol+ header is present, it must be a +String+, and + ## must be one of the values from the +rack.protocol+ array from the + ## environment. + protocol = headers['rack.protocol'] + + if protocol + request_protocols = @env['rack.protocol'] + + if request_protocols.nil? + raise LintError, "rack.protocol header is #{protocol.inspect}, but rack.protocol was not set in request!" + elsif !request_protocols.include?(protocol) + raise LintError, "rack.protocol header is #{protocol.inspect}, but should be one of #{request_protocols.inspect} from the request!" + end + end + end + ## + ## Setting this value informs the server that it should perform a + ## connection upgrade. In HTTP/1, this is done using the +upgrade+ + ## header. In HTTP/2, this is done by accepting the request. + ## + ## === The Body + ## + ## The Body is typically an +Array+ of +String+ instances, an enumerable + ## that yields +String+ instances, a +Proc+ instance, or a File-like + ## object. + ## + ## The Body must respond to +each+ or +call+. It may optionally respond + ## to +to_path+ or +to_ary+. A Body that responds to +each+ is considered + ## to be an Enumerable Body. A Body that responds to +call+ is considered + ## to be a Streaming Body. + ## + ## A Body that responds to both +each+ and +call+ must be treated as an + ## Enumerable Body, not a Streaming Body. If it responds to +each+, you + ## must call +each+ and not +call+. If the Body doesn't respond to + ## +each+, then you can assume it responds to +call+. + ## + ## The Body must either be consumed or returned. The Body is consumed by + ## optionally calling either +each+ or +call+. + ## Then, if the Body responds to +close+, it must be called to release + ## any resources associated with the generation of the body. + ## In other words, +close+ must always be called at least once; typically + ## after the web server has sent the response to the client, but also in + ## cases where the Rack application makes internal/virtual requests and + ## discards the response. + ## + def close + ## + ## After calling +close+, the Body is considered closed and should not + ## be consumed again. + @closed = true + + ## If the original Body is replaced by a new Body, the new Body must + ## also consume the original Body by calling +close+ if possible. + @body.close if @body.respond_to?(:close) + + index = @lint.index(self) + unless @env['rack.lint'][0..index].all? {|lint| lint.instance_variable_get(:@closed)} + raise LintError, "Body has not been closed" + end + end + + def verify_to_path + ## + ## If the Body responds to +to_path+, it must return a +String+ + ## path for the local file system whose contents are identical + ## to that produced by calling +each+; this may be used by the + ## server as an alternative, possibly more efficient way to + ## transport the response. The +to_path+ method does not consume + ## the body. + if @body.respond_to?(:to_path) + unless ::File.exist? @body.to_path + raise LintError, "The file identified by body.to_path does not exist" + end + end + end + + ## + ## ==== Enumerable Body + ## + def each + ## The Enumerable Body must respond to +each+. + raise LintError, "Enumerable Body must respond to each" unless @body.respond_to?(:each) + + ## It must only be called once. + raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? + + ## It must not be called after being closed, + raise LintError, "Response body is already closed" if @closed + + @invoked = :each + + @body.each do |chunk| + ## and must only yield String values. + unless chunk.kind_of? String + raise LintError, "Body yielded non-string value #{chunk.inspect}" + end + + ## + ## Middleware must not call +each+ directly on the Body. + ## Instead, middleware can return a new Body that calls +each+ on the + ## original Body, yielding at least once per iteration. + if @lint[0] == self + @env['rack.lint.body_iteration'] += 1 + else + if (@env['rack.lint.body_iteration'] -= 1) > 0 + raise LintError, "New body must yield at least once per iteration of old body" + end + end + + @size += chunk.bytesize + yield chunk + end + + verify_content_length(@size) + + verify_to_path + end + + BODY_METHODS = {to_ary: true, each: true, call: true, to_path: true} + + def to_path + @body.to_path + end + + def respond_to?(name, *) + if BODY_METHODS.key?(name) + @body.respond_to?(name) + else + super + end + end + + ## + ## If the Body responds to +to_ary+, it must return an +Array+ whose + ## contents are identical to that produced by calling +each+. + ## Middleware may call +to_ary+ directly on the Body and return a new + ## Body in its place. In other words, middleware can only process the + ## Body directly if it responds to +to_ary+. If the Body responds to both + ## +to_ary+ and +close+, its implementation of +to_ary+ must call + ## +close+. + def to_ary + @body.to_ary.tap do |content| + unless content == @body.enum_for.to_a + raise LintError, "#to_ary not identical to contents produced by calling #each" + end + end + ensure + close + end + + ## + ## ==== Streaming Body + ## + def call(stream) + ## The Streaming Body must respond to +call+. + raise LintError, "Streaming Body must respond to call" unless @body.respond_to?(:call) + + ## It must only be called once. + raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? + + ## It must not be called after being closed. + raise LintError, "Response body is already closed" if @closed + + @invoked = :call + + ## It takes a +stream+ argument. + ## + ## The +stream+ argument must implement: + ## read, write, <<, flush, close, close_read, close_write, closed? + ## + @body.call(StreamWrapper.new(stream)) + end + + class StreamWrapper + extend Forwardable + + ## The semantics of these IO methods must be a best effort match to + ## those of a normal Ruby IO or Socket object, using standard arguments + ## and raising standard exceptions. Servers are encouraged to simply + ## pass on real IO objects, although it is recognized that this approach + ## is not directly compatible with HTTP/2. + REQUIRED_METHODS = [ + :read, :write, :<<, :flush, :close, + :close_read, :close_write, :closed? + ] + + def_delegators :@stream, *REQUIRED_METHODS + + def initialize(stream) + @stream = stream + + REQUIRED_METHODS.each do |method_name| + raise LintError, "Stream must respond to #{method_name}" unless stream.respond_to?(method_name) + end + end + end + + # :startdoc: + end + end +end + +## +## == Thanks +## Some parts of this specification are adopted from {PEP 333 – Python Web Server Gateway Interface v1.0}[https://peps.python.org/pep-0333/] +## I'd like to thank everyone involved in that effort. diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lock.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lock.rb new file mode 100644 index 0000000..342123a --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lock.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative 'body_proxy' + +module Rack + # Rack::Lock locks every request inside a mutex, so that every request + # will effectively be executed synchronously. + class Lock + def initialize(app, mutex = Mutex.new) + @app, @mutex = app, mutex + end + + def call(env) + @mutex.lock + begin + response = @app.call(env) + returned = response << BodyProxy.new(response.pop) { unlock } + ensure + unlock unless returned + end + end + + private + + def unlock + @mutex.unlock + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/logger.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/logger.rb new file mode 100644 index 0000000..081212d --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/logger.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'logger' +require_relative 'constants' + +warn "Rack::Logger is deprecated and will be removed in Rack 3.2.", uplevel: 1 + +module Rack + # Sets up rack.logger to write to rack.errors stream + class Logger + def initialize(app, level = ::Logger::INFO) + @app, @level = app, level + end + + def call(env) + logger = ::Logger.new(env[RACK_ERRORS]) + logger.level = @level + + env[RACK_LOGGER] = logger + @app.call(env) + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/media_type.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/media_type.rb new file mode 100644 index 0000000..7fc1e39 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/media_type.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Rack + # Rack::MediaType parse media type and parameters out of content_type string + + class MediaType + SPLIT_PATTERN = /[;,]/ + + class << self + # The media type (type/subtype) portion of the CONTENT_TYPE header + # without any media type parameters. e.g., when CONTENT_TYPE is + # "text/plain;charset=utf-8", the media-type is "text/plain". + # + # For more information on the use of media types in HTTP, see: + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 + def type(content_type) + return nil unless content_type + if type = content_type.split(SPLIT_PATTERN, 2).first + type.rstrip! + type.downcase! + type + end + end + + # The media type parameters provided in CONTENT_TYPE as a Hash, or + # an empty Hash if no CONTENT_TYPE or media-type parameters were + # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", + # this method responds with the following Hash: + # { 'charset' => 'utf-8' } + def params(content_type) + return {} if content_type.nil? + + content_type.split(SPLIT_PATTERN)[1..-1].each_with_object({}) do |s, hsh| + s.strip! + k, v = s.split('=', 2) + k.downcase! + hsh[k] = strip_doublequotes(v) + end + end + + private + + def strip_doublequotes(str) + (str.start_with?('"') && str.end_with?('"')) ? str[1..-2] : str + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/method_override.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/method_override.rb new file mode 100644 index 0000000..6125b19 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/method_override.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'request' +require_relative 'utils' + +module Rack + class MethodOverride + HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK] + + METHOD_OVERRIDE_PARAM_KEY = "_method" + HTTP_METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE" + ALLOWED_METHODS = %w[POST] + + def initialize(app) + @app = app + end + + def call(env) + if allowed_methods.include?(env[REQUEST_METHOD]) + method = method_override(env) + if HTTP_METHODS.include?(method) + env[RACK_METHODOVERRIDE_ORIGINAL_METHOD] = env[REQUEST_METHOD] + env[REQUEST_METHOD] = method + end + end + + @app.call(env) + end + + def method_override(env) + req = Request.new(env) + method = method_override_param(req) || + env[HTTP_METHOD_OVERRIDE_HEADER] + begin + method.to_s.upcase + rescue ArgumentError + env[RACK_ERRORS].puts "Invalid string for method" + end + end + + private + + def allowed_methods + ALLOWED_METHODS + end + + def method_override_param(req) + req.POST[METHOD_OVERRIDE_PARAM_KEY] if req.form_data? || req.parseable_data? + rescue Utils::InvalidParameterError, Utils::ParameterTypeError, QueryParser::ParamsTooDeepError + req.get_header(RACK_ERRORS).puts "Invalid or incomplete POST params" + rescue EOFError + req.get_header(RACK_ERRORS).puts "Bad request content body" + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mime.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mime.rb new file mode 100644 index 0000000..0272968 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mime.rb @@ -0,0 +1,694 @@ +# frozen_string_literal: true + +module Rack + module Mime + # Returns String with mime type if found, otherwise use +fallback+. + # +ext+ should be filename extension in the '.ext' format that + # File.extname(file) returns. + # +fallback+ may be any object + # + # Also see the documentation for MIME_TYPES + # + # Usage: + # Rack::Mime.mime_type('.foo') + # + # This is a shortcut for: + # Rack::Mime::MIME_TYPES.fetch('.foo', 'application/octet-stream') + + def mime_type(ext, fallback = 'application/octet-stream') + MIME_TYPES.fetch(ext.to_s.downcase, fallback) + end + module_function :mime_type + + # Returns true if the given value is a mime match for the given mime match + # specification, false otherwise. + # + # Rack::Mime.match?('text/html', 'text/*') => true + # Rack::Mime.match?('text/plain', '*') => true + # Rack::Mime.match?('text/html', 'application/json') => false + + def match?(value, matcher) + v1, v2 = value.split('/', 2) + m1, m2 = matcher.split('/', 2) + + (m1 == '*' || v1 == m1) && (m2.nil? || m2 == '*' || m2 == v2) + end + module_function :match? + + # List of most common mime-types, selected various sources + # according to their usefulness in a webserving scope for Ruby + # users. + # + # To amend this list with your local mime.types list you can use: + # + # require 'webrick/httputils' + # list = WEBrick::HTTPUtils.load_mime_types('/etc/mime.types') + # Rack::Mime::MIME_TYPES.merge!(list) + # + # N.B. On Ubuntu the mime.types file does not include the leading period, so + # users may need to modify the data before merging into the hash. + + MIME_TYPES = { + ".123" => "application/vnd.lotus-1-2-3", + ".3dml" => "text/vnd.in3d.3dml", + ".3g2" => "video/3gpp2", + ".3gp" => "video/3gpp", + ".a" => "application/octet-stream", + ".acc" => "application/vnd.americandynamics.acc", + ".ace" => "application/x-ace-compressed", + ".acu" => "application/vnd.acucobol", + ".aep" => "application/vnd.audiograph", + ".afp" => "application/vnd.ibm.modcap", + ".ai" => "application/postscript", + ".aif" => "audio/x-aiff", + ".aiff" => "audio/x-aiff", + ".ami" => "application/vnd.amiga.ami", + ".apng" => "image/apng", + ".appcache" => "text/cache-manifest", + ".apr" => "application/vnd.lotus-approach", + ".asc" => "application/pgp-signature", + ".asf" => "video/x-ms-asf", + ".asm" => "text/x-asm", + ".aso" => "application/vnd.accpac.simply.aso", + ".asx" => "video/x-ms-asf", + ".atc" => "application/vnd.acucorp", + ".atom" => "application/atom+xml", + ".atomcat" => "application/atomcat+xml", + ".atomsvc" => "application/atomsvc+xml", + ".atx" => "application/vnd.antix.game-component", + ".au" => "audio/basic", + ".avi" => "video/x-msvideo", + ".avif" => "image/avif", + ".bat" => "application/x-msdownload", + ".bcpio" => "application/x-bcpio", + ".bdm" => "application/vnd.syncml.dm+wbxml", + ".bh2" => "application/vnd.fujitsu.oasysprs", + ".bin" => "application/octet-stream", + ".bmi" => "application/vnd.bmi", + ".bmp" => "image/bmp", + ".box" => "application/vnd.previewsystems.box", + ".btif" => "image/prs.btif", + ".bz" => "application/x-bzip", + ".bz2" => "application/x-bzip2", + ".c" => "text/x-c", + ".c4g" => "application/vnd.clonk.c4group", + ".cab" => "application/vnd.ms-cab-compressed", + ".cc" => "text/x-c", + ".ccxml" => "application/ccxml+xml", + ".cdbcmsg" => "application/vnd.contact.cmsg", + ".cdkey" => "application/vnd.mediastation.cdkey", + ".cdx" => "chemical/x-cdx", + ".cdxml" => "application/vnd.chemdraw+xml", + ".cdy" => "application/vnd.cinderella", + ".cer" => "application/pkix-cert", + ".cgm" => "image/cgm", + ".chat" => "application/x-chat", + ".chm" => "application/vnd.ms-htmlhelp", + ".chrt" => "application/vnd.kde.kchart", + ".cif" => "chemical/x-cif", + ".cii" => "application/vnd.anser-web-certificate-issue-initiation", + ".cil" => "application/vnd.ms-artgalry", + ".cla" => "application/vnd.claymore", + ".class" => "application/octet-stream", + ".clkk" => "application/vnd.crick.clicker.keyboard", + ".clkp" => "application/vnd.crick.clicker.palette", + ".clkt" => "application/vnd.crick.clicker.template", + ".clkw" => "application/vnd.crick.clicker.wordbank", + ".clkx" => "application/vnd.crick.clicker", + ".clp" => "application/x-msclip", + ".cmc" => "application/vnd.cosmocaller", + ".cmdf" => "chemical/x-cmdf", + ".cml" => "chemical/x-cml", + ".cmp" => "application/vnd.yellowriver-custom-menu", + ".cmx" => "image/x-cmx", + ".com" => "application/x-msdownload", + ".conf" => "text/plain", + ".cpio" => "application/x-cpio", + ".cpp" => "text/x-c", + ".cpt" => "application/mac-compactpro", + ".crd" => "application/x-mscardfile", + ".crl" => "application/pkix-crl", + ".crt" => "application/x-x509-ca-cert", + ".csh" => "application/x-csh", + ".csml" => "chemical/x-csml", + ".csp" => "application/vnd.commonspace", + ".css" => "text/css", + ".csv" => "text/csv", + ".curl" => "application/vnd.curl", + ".cww" => "application/prs.cww", + ".cxx" => "text/x-c", + ".daf" => "application/vnd.mobius.daf", + ".davmount" => "application/davmount+xml", + ".dcr" => "application/x-director", + ".dd2" => "application/vnd.oma.dd2+xml", + ".ddd" => "application/vnd.fujixerox.ddd", + ".deb" => "application/x-debian-package", + ".der" => "application/x-x509-ca-cert", + ".dfac" => "application/vnd.dreamfactory", + ".diff" => "text/x-diff", + ".dis" => "application/vnd.mobius.dis", + ".djv" => "image/vnd.djvu", + ".djvu" => "image/vnd.djvu", + ".dll" => "application/x-msdownload", + ".dmg" => "application/octet-stream", + ".dna" => "application/vnd.dna", + ".doc" => "application/msword", + ".docm" => "application/vnd.ms-word.document.macroEnabled.12", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".dot" => "application/msword", + ".dotm" => "application/vnd.ms-word.template.macroEnabled.12", + ".dotx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + ".dp" => "application/vnd.osgi.dp", + ".dpg" => "application/vnd.dpgraph", + ".dsc" => "text/prs.lines.tag", + ".dtd" => "application/xml-dtd", + ".dts" => "audio/vnd.dts", + ".dtshd" => "audio/vnd.dts.hd", + ".dv" => "video/x-dv", + ".dvi" => "application/x-dvi", + ".dwf" => "model/vnd.dwf", + ".dwg" => "image/vnd.dwg", + ".dxf" => "image/vnd.dxf", + ".dxp" => "application/vnd.spotfire.dxp", + ".ear" => "application/java-archive", + ".ecelp4800" => "audio/vnd.nuera.ecelp4800", + ".ecelp7470" => "audio/vnd.nuera.ecelp7470", + ".ecelp9600" => "audio/vnd.nuera.ecelp9600", + ".ecma" => "application/ecmascript", + ".edm" => "application/vnd.novadigm.edm", + ".edx" => "application/vnd.novadigm.edx", + ".efif" => "application/vnd.picsel", + ".ei6" => "application/vnd.pg.osasli", + ".eml" => "message/rfc822", + ".eol" => "audio/vnd.digital-winds", + ".eot" => "application/vnd.ms-fontobject", + ".eps" => "application/postscript", + ".es3" => "application/vnd.eszigno3+xml", + ".esf" => "application/vnd.epson.esf", + ".etx" => "text/x-setext", + ".exe" => "application/x-msdownload", + ".ext" => "application/vnd.novadigm.ext", + ".ez" => "application/andrew-inset", + ".ez2" => "application/vnd.ezpix-album", + ".ez3" => "application/vnd.ezpix-package", + ".f" => "text/x-fortran", + ".f77" => "text/x-fortran", + ".f90" => "text/x-fortran", + ".fbs" => "image/vnd.fastbidsheet", + ".fdf" => "application/vnd.fdf", + ".fe_launch" => "application/vnd.denovo.fcselayout-link", + ".fg5" => "application/vnd.fujitsu.oasysgp", + ".fli" => "video/x-fli", + ".flif" => "image/flif", + ".flo" => "application/vnd.micrografx.flo", + ".flv" => "video/x-flv", + ".flw" => "application/vnd.kde.kivio", + ".flx" => "text/vnd.fmi.flexstor", + ".fly" => "text/vnd.fly", + ".fm" => "application/vnd.framemaker", + ".fnc" => "application/vnd.frogans.fnc", + ".for" => "text/x-fortran", + ".fpx" => "image/vnd.fpx", + ".fsc" => "application/vnd.fsc.weblaunch", + ".fst" => "image/vnd.fst", + ".ftc" => "application/vnd.fluxtime.clip", + ".fti" => "application/vnd.anser-web-funds-transfer-initiation", + ".fvt" => "video/vnd.fvt", + ".fzs" => "application/vnd.fuzzysheet", + ".g3" => "image/g3fax", + ".gac" => "application/vnd.groove-account", + ".gdl" => "model/vnd.gdl", + ".gem" => "application/octet-stream", + ".gemspec" => "text/x-script.ruby", + ".ghf" => "application/vnd.groove-help", + ".gif" => "image/gif", + ".gim" => "application/vnd.groove-identity-message", + ".gmx" => "application/vnd.gmx", + ".gph" => "application/vnd.flographit", + ".gqf" => "application/vnd.grafeq", + ".gram" => "application/srgs", + ".grv" => "application/vnd.groove-injector", + ".grxml" => "application/srgs+xml", + ".gtar" => "application/x-gtar", + ".gtm" => "application/vnd.groove-tool-message", + ".gtw" => "model/vnd.gtw", + ".gv" => "text/vnd.graphviz", + ".gz" => "application/x-gzip", + ".h" => "text/x-c", + ".h261" => "video/h261", + ".h263" => "video/h263", + ".h264" => "video/h264", + ".hbci" => "application/vnd.hbci", + ".hdf" => "application/x-hdf", + ".heic" => "image/heic", + ".heics" => "image/heic-sequence", + ".heif" => "image/heif", + ".heifs" => "image/heif-sequence", + ".hh" => "text/x-c", + ".hlp" => "application/winhlp", + ".hpgl" => "application/vnd.hp-hpgl", + ".hpid" => "application/vnd.hp-hpid", + ".hps" => "application/vnd.hp-hps", + ".hqx" => "application/mac-binhex40", + ".htc" => "text/x-component", + ".htke" => "application/vnd.kenameaapp", + ".htm" => "text/html", + ".html" => "text/html", + ".hvd" => "application/vnd.yamaha.hv-dic", + ".hvp" => "application/vnd.yamaha.hv-voice", + ".hvs" => "application/vnd.yamaha.hv-script", + ".icc" => "application/vnd.iccprofile", + ".ice" => "x-conference/x-cooltalk", + ".ico" => "image/vnd.microsoft.icon", + ".ics" => "text/calendar", + ".ief" => "image/ief", + ".ifb" => "text/calendar", + ".ifm" => "application/vnd.shana.informed.formdata", + ".igl" => "application/vnd.igloader", + ".igs" => "model/iges", + ".igx" => "application/vnd.micrografx.igx", + ".iif" => "application/vnd.shana.informed.interchange", + ".imp" => "application/vnd.accpac.simply.imp", + ".ims" => "application/vnd.ms-ims", + ".ipk" => "application/vnd.shana.informed.package", + ".irm" => "application/vnd.ibm.rights-management", + ".irp" => "application/vnd.irepository.package+xml", + ".iso" => "application/octet-stream", + ".itp" => "application/vnd.shana.informed.formtemplate", + ".ivp" => "application/vnd.immervision-ivp", + ".ivu" => "application/vnd.immervision-ivu", + ".jad" => "text/vnd.sun.j2me.app-descriptor", + ".jam" => "application/vnd.jam", + ".jar" => "application/java-archive", + ".java" => "text/x-java-source", + ".jisp" => "application/vnd.jisp", + ".jlt" => "application/vnd.hp-jlyt", + ".jnlp" => "application/x-java-jnlp-file", + ".joda" => "application/vnd.joost.joda-archive", + ".jp2" => "image/jp2", + ".jpeg" => "image/jpeg", + ".jpg" => "image/jpeg", + ".jpgv" => "video/jpeg", + ".jpm" => "video/jpm", + ".js" => "text/javascript", + ".json" => "application/json", + ".karbon" => "application/vnd.kde.karbon", + ".kfo" => "application/vnd.kde.kformula", + ".kia" => "application/vnd.kidspiration", + ".kml" => "application/vnd.google-earth.kml+xml", + ".kmz" => "application/vnd.google-earth.kmz", + ".kne" => "application/vnd.kinar", + ".kon" => "application/vnd.kde.kontour", + ".kpr" => "application/vnd.kde.kpresenter", + ".ksp" => "application/vnd.kde.kspread", + ".ktz" => "application/vnd.kahootz", + ".kwd" => "application/vnd.kde.kword", + ".latex" => "application/x-latex", + ".lbd" => "application/vnd.llamagraphics.life-balance.desktop", + ".lbe" => "application/vnd.llamagraphics.life-balance.exchange+xml", + ".les" => "application/vnd.hhe.lesson-player", + ".link66" => "application/vnd.route66.link66+xml", + ".log" => "text/plain", + ".lostxml" => "application/lost+xml", + ".lrm" => "application/vnd.ms-lrm", + ".ltf" => "application/vnd.frogans.ltf", + ".lvp" => "audio/vnd.lucent.voice", + ".lwp" => "application/vnd.lotus-wordpro", + ".m3u" => "audio/x-mpegurl", + ".m3u8" => "application/x-mpegurl", + ".m4a" => "audio/mp4a-latm", + ".m4v" => "video/mp4", + ".ma" => "application/mathematica", + ".mag" => "application/vnd.ecowin.chart", + ".man" => "text/troff", + ".manifest" => "text/cache-manifest", + ".mathml" => "application/mathml+xml", + ".mbk" => "application/vnd.mobius.mbk", + ".mbox" => "application/mbox", + ".mc1" => "application/vnd.medcalcdata", + ".mcd" => "application/vnd.mcd", + ".mdb" => "application/x-msaccess", + ".mdi" => "image/vnd.ms-modi", + ".mdoc" => "text/troff", + ".me" => "text/troff", + ".mfm" => "application/vnd.mfmp", + ".mgz" => "application/vnd.proteus.magazine", + ".mid" => "audio/midi", + ".midi" => "audio/midi", + ".mif" => "application/vnd.mif", + ".mime" => "message/rfc822", + ".mj2" => "video/mj2", + ".mjs" => "text/javascript", + ".mlp" => "application/vnd.dolby.mlp", + ".mmd" => "application/vnd.chipnuts.karaoke-mmd", + ".mmf" => "application/vnd.smaf", + ".mml" => "application/mathml+xml", + ".mmr" => "image/vnd.fujixerox.edmics-mmr", + ".mng" => "video/x-mng", + ".mny" => "application/x-msmoney", + ".mov" => "video/quicktime", + ".movie" => "video/x-sgi-movie", + ".mp3" => "audio/mpeg", + ".mp4" => "video/mp4", + ".mp4a" => "audio/mp4", + ".mp4s" => "application/mp4", + ".mp4v" => "video/mp4", + ".mpc" => "application/vnd.mophun.certificate", + ".mpd" => "application/dash+xml", + ".mpeg" => "video/mpeg", + ".mpg" => "video/mpeg", + ".mpga" => "audio/mpeg", + ".mpkg" => "application/vnd.apple.installer+xml", + ".mpm" => "application/vnd.blueice.multipass", + ".mpn" => "application/vnd.mophun.application", + ".mpp" => "application/vnd.ms-project", + ".mpy" => "application/vnd.ibm.minipay", + ".mqy" => "application/vnd.mobius.mqy", + ".mrc" => "application/marc", + ".ms" => "text/troff", + ".mscml" => "application/mediaservercontrol+xml", + ".mseq" => "application/vnd.mseq", + ".msf" => "application/vnd.epson.msf", + ".msh" => "model/mesh", + ".msi" => "application/x-msdownload", + ".msl" => "application/vnd.mobius.msl", + ".msty" => "application/vnd.muvee.style", + ".mts" => "model/vnd.mts", + ".mus" => "application/vnd.musician", + ".mvb" => "application/x-msmediaview", + ".mwf" => "application/vnd.mfer", + ".mxf" => "application/mxf", + ".mxl" => "application/vnd.recordare.musicxml", + ".mxml" => "application/xv+xml", + ".mxs" => "application/vnd.triscape.mxs", + ".mxu" => "video/vnd.mpegurl", + ".n" => "application/vnd.nokia.n-gage.symbian.install", + ".nc" => "application/x-netcdf", + ".ngdat" => "application/vnd.nokia.n-gage.data", + ".nlu" => "application/vnd.neurolanguage.nlu", + ".nml" => "application/vnd.enliven", + ".nnd" => "application/vnd.noblenet-directory", + ".nns" => "application/vnd.noblenet-sealer", + ".nnw" => "application/vnd.noblenet-web", + ".npx" => "image/vnd.net-fpx", + ".nsf" => "application/vnd.lotus-notes", + ".oa2" => "application/vnd.fujitsu.oasys2", + ".oa3" => "application/vnd.fujitsu.oasys3", + ".oas" => "application/vnd.fujitsu.oasys", + ".obd" => "application/x-msbinder", + ".oda" => "application/oda", + ".odc" => "application/vnd.oasis.opendocument.chart", + ".odf" => "application/vnd.oasis.opendocument.formula", + ".odg" => "application/vnd.oasis.opendocument.graphics", + ".odi" => "application/vnd.oasis.opendocument.image", + ".odp" => "application/vnd.oasis.opendocument.presentation", + ".ods" => "application/vnd.oasis.opendocument.spreadsheet", + ".odt" => "application/vnd.oasis.opendocument.text", + ".oga" => "audio/ogg", + ".ogg" => "application/ogg", + ".ogv" => "video/ogg", + ".ogx" => "application/ogg", + ".org" => "application/vnd.lotus-organizer", + ".otc" => "application/vnd.oasis.opendocument.chart-template", + ".otf" => "font/otf", + ".otg" => "application/vnd.oasis.opendocument.graphics-template", + ".oth" => "application/vnd.oasis.opendocument.text-web", + ".oti" => "application/vnd.oasis.opendocument.image-template", + ".otm" => "application/vnd.oasis.opendocument.text-master", + ".ots" => "application/vnd.oasis.opendocument.spreadsheet-template", + ".ott" => "application/vnd.oasis.opendocument.text-template", + ".oxt" => "application/vnd.openofficeorg.extension", + ".p" => "text/x-pascal", + ".p10" => "application/pkcs10", + ".p12" => "application/x-pkcs12", + ".p7b" => "application/x-pkcs7-certificates", + ".p7m" => "application/pkcs7-mime", + ".p7r" => "application/x-pkcs7-certreqresp", + ".p7s" => "application/pkcs7-signature", + ".pas" => "text/x-pascal", + ".pbd" => "application/vnd.powerbuilder6", + ".pbm" => "image/x-portable-bitmap", + ".pcl" => "application/vnd.hp-pcl", + ".pclxl" => "application/vnd.hp-pclxl", + ".pcx" => "image/x-pcx", + ".pdb" => "chemical/x-pdb", + ".pdf" => "application/pdf", + ".pem" => "application/x-x509-ca-cert", + ".pfr" => "application/font-tdpfr", + ".pgm" => "image/x-portable-graymap", + ".pgn" => "application/x-chess-pgn", + ".pgp" => "application/pgp-encrypted", + ".pic" => "image/x-pict", + ".pict" => "image/pict", + ".pkg" => "application/octet-stream", + ".pki" => "application/pkixcmp", + ".pkipath" => "application/pkix-pkipath", + ".pl" => "text/x-script.perl", + ".plb" => "application/vnd.3gpp.pic-bw-large", + ".plc" => "application/vnd.mobius.plc", + ".plf" => "application/vnd.pocketlearn", + ".pls" => "application/pls+xml", + ".pm" => "text/x-script.perl-module", + ".pml" => "application/vnd.ctc-posml", + ".png" => "image/png", + ".pnm" => "image/x-portable-anymap", + ".pntg" => "image/x-macpaint", + ".portpkg" => "application/vnd.macports.portpkg", + ".pot" => "application/vnd.ms-powerpoint", + ".potm" => "application/vnd.ms-powerpoint.template.macroEnabled.12", + ".potx" => "application/vnd.openxmlformats-officedocument.presentationml.template", + ".ppa" => "application/vnd.ms-powerpoint", + ".ppam" => "application/vnd.ms-powerpoint.addin.macroEnabled.12", + ".ppd" => "application/vnd.cups-ppd", + ".ppm" => "image/x-portable-pixmap", + ".pps" => "application/vnd.ms-powerpoint", + ".ppsm" => "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", + ".ppsx" => "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + ".ppt" => "application/vnd.ms-powerpoint", + ".pptm" => "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".prc" => "application/vnd.palm", + ".pre" => "application/vnd.lotus-freelance", + ".prf" => "application/pics-rules", + ".ps" => "application/postscript", + ".psb" => "application/vnd.3gpp.pic-bw-small", + ".psd" => "image/vnd.adobe.photoshop", + ".ptid" => "application/vnd.pvi.ptid1", + ".pub" => "application/x-mspublisher", + ".pvb" => "application/vnd.3gpp.pic-bw-var", + ".pwn" => "application/vnd.3m.post-it-notes", + ".py" => "text/x-script.python", + ".pya" => "audio/vnd.ms-playready.media.pya", + ".pyv" => "video/vnd.ms-playready.media.pyv", + ".qam" => "application/vnd.epson.quickanime", + ".qbo" => "application/vnd.intu.qbo", + ".qfx" => "application/vnd.intu.qfx", + ".qps" => "application/vnd.publishare-delta-tree", + ".qt" => "video/quicktime", + ".qtif" => "image/x-quicktime", + ".qxd" => "application/vnd.quark.quarkxpress", + ".ra" => "audio/x-pn-realaudio", + ".rake" => "text/x-script.ruby", + ".ram" => "audio/x-pn-realaudio", + ".rar" => "application/x-rar-compressed", + ".ras" => "image/x-cmu-raster", + ".rb" => "text/x-script.ruby", + ".rcprofile" => "application/vnd.ipunplugged.rcprofile", + ".rdf" => "application/rdf+xml", + ".rdz" => "application/vnd.data-vision.rdz", + ".rep" => "application/vnd.businessobjects", + ".rgb" => "image/x-rgb", + ".rif" => "application/reginfo+xml", + ".rl" => "application/resource-lists+xml", + ".rlc" => "image/vnd.fujixerox.edmics-rlc", + ".rld" => "application/resource-lists-diff+xml", + ".rm" => "application/vnd.rn-realmedia", + ".rmp" => "audio/x-pn-realaudio-plugin", + ".rms" => "application/vnd.jcp.javame.midlet-rms", + ".rnc" => "application/relax-ng-compact-syntax", + ".roff" => "text/troff", + ".rpm" => "application/x-redhat-package-manager", + ".rpss" => "application/vnd.nokia.radio-presets", + ".rpst" => "application/vnd.nokia.radio-preset", + ".rq" => "application/sparql-query", + ".rs" => "application/rls-services+xml", + ".rsd" => "application/rsd+xml", + ".rss" => "application/rss+xml", + ".rtf" => "application/rtf", + ".rtx" => "text/richtext", + ".ru" => "text/x-script.ruby", + ".s" => "text/x-asm", + ".saf" => "application/vnd.yamaha.smaf-audio", + ".sbml" => "application/sbml+xml", + ".sc" => "application/vnd.ibm.secure-container", + ".scd" => "application/x-msschedule", + ".scm" => "application/vnd.lotus-screencam", + ".scq" => "application/scvp-cv-request", + ".scs" => "application/scvp-cv-response", + ".sdkm" => "application/vnd.solent.sdkm+xml", + ".sdp" => "application/sdp", + ".see" => "application/vnd.seemail", + ".sema" => "application/vnd.sema", + ".semd" => "application/vnd.semd", + ".semf" => "application/vnd.semf", + ".setpay" => "application/set-payment-initiation", + ".setreg" => "application/set-registration-initiation", + ".sfd" => "application/vnd.hydrostatix.sof-data", + ".sfs" => "application/vnd.spotfire.sfs", + ".sgm" => "text/sgml", + ".sgml" => "text/sgml", + ".sh" => "application/x-sh", + ".shar" => "application/x-shar", + ".shf" => "application/shf+xml", + ".sig" => "application/pgp-signature", + ".sit" => "application/x-stuffit", + ".sitx" => "application/x-stuffitx", + ".skp" => "application/vnd.koan", + ".slt" => "application/vnd.epson.salt", + ".smi" => "application/smil+xml", + ".snd" => "audio/basic", + ".so" => "application/octet-stream", + ".spf" => "application/vnd.yamaha.smaf-phrase", + ".spl" => "application/x-futuresplash", + ".spot" => "text/vnd.in3d.spot", + ".spp" => "application/scvp-vp-response", + ".spq" => "application/scvp-vp-request", + ".src" => "application/x-wais-source", + ".srt" => "text/srt", + ".srx" => "application/sparql-results+xml", + ".sse" => "application/vnd.kodak-descriptor", + ".ssf" => "application/vnd.epson.ssf", + ".ssml" => "application/ssml+xml", + ".stf" => "application/vnd.wt.stf", + ".stk" => "application/hyperstudio", + ".str" => "application/vnd.pg.format", + ".sus" => "application/vnd.sus-calendar", + ".sv4cpio" => "application/x-sv4cpio", + ".sv4crc" => "application/x-sv4crc", + ".svd" => "application/vnd.svd", + ".svg" => "image/svg+xml", + ".svgz" => "image/svg+xml", + ".swf" => "application/x-shockwave-flash", + ".swi" => "application/vnd.arastra.swi", + ".t" => "text/troff", + ".tao" => "application/vnd.tao.intent-module-archive", + ".tar" => "application/x-tar", + ".tbz" => "application/x-bzip-compressed-tar", + ".tcap" => "application/vnd.3gpp2.tcap", + ".tcl" => "application/x-tcl", + ".tex" => "application/x-tex", + ".texi" => "application/x-texinfo", + ".texinfo" => "application/x-texinfo", + ".text" => "text/plain", + ".tif" => "image/tiff", + ".tiff" => "image/tiff", + ".tmo" => "application/vnd.tmobile-livetv", + ".torrent" => "application/x-bittorrent", + ".tpl" => "application/vnd.groove-tool-template", + ".tpt" => "application/vnd.trid.tpt", + ".tr" => "text/troff", + ".tra" => "application/vnd.trueapp", + ".trm" => "application/x-msterminal", + ".ts" => "video/mp2t", + ".tsv" => "text/tab-separated-values", + ".ttf" => "font/ttf", + ".twd" => "application/vnd.simtech-mindmapper", + ".txd" => "application/vnd.genomatix.tuxedo", + ".txf" => "application/vnd.mobius.txf", + ".txt" => "text/plain", + ".ufd" => "application/vnd.ufdl", + ".umj" => "application/vnd.umajin", + ".unityweb" => "application/vnd.unity", + ".uoml" => "application/vnd.uoml+xml", + ".uri" => "text/uri-list", + ".ustar" => "application/x-ustar", + ".utz" => "application/vnd.uiq.theme", + ".uu" => "text/x-uuencode", + ".vcd" => "application/x-cdlink", + ".vcf" => "text/x-vcard", + ".vcg" => "application/vnd.groove-vcard", + ".vcs" => "text/x-vcalendar", + ".vcx" => "application/vnd.vcx", + ".vis" => "application/vnd.visionary", + ".viv" => "video/vnd.vivo", + ".vrml" => "model/vrml", + ".vsd" => "application/vnd.visio", + ".vsf" => "application/vnd.vsf", + ".vtt" => "text/vtt", + ".vtu" => "model/vnd.vtu", + ".vxml" => "application/voicexml+xml", + ".war" => "application/java-archive", + ".wasm" => "application/wasm", + ".wav" => "audio/x-wav", + ".wax" => "audio/x-ms-wax", + ".wbmp" => "image/vnd.wap.wbmp", + ".wbs" => "application/vnd.criticaltools.wbs+xml", + ".wbxml" => "application/vnd.wap.wbxml", + ".webm" => "video/webm", + ".webp" => "image/webp", + ".wm" => "video/x-ms-wm", + ".wma" => "audio/x-ms-wma", + ".wmd" => "application/x-ms-wmd", + ".wmf" => "application/x-msmetafile", + ".wml" => "text/vnd.wap.wml", + ".wmlc" => "application/vnd.wap.wmlc", + ".wmls" => "text/vnd.wap.wmlscript", + ".wmlsc" => "application/vnd.wap.wmlscriptc", + ".wmv" => "video/x-ms-wmv", + ".wmx" => "video/x-ms-wmx", + ".wmz" => "application/x-ms-wmz", + ".woff" => "font/woff", + ".woff2" => "font/woff2", + ".wpd" => "application/vnd.wordperfect", + ".wpl" => "application/vnd.ms-wpl", + ".wps" => "application/vnd.ms-works", + ".wqd" => "application/vnd.wqd", + ".wri" => "application/x-mswrite", + ".wrl" => "model/vrml", + ".wsdl" => "application/wsdl+xml", + ".wspolicy" => "application/wspolicy+xml", + ".wtb" => "application/vnd.webturbo", + ".wvx" => "video/x-ms-wvx", + ".x3d" => "application/vnd.hzn-3d-crossword", + ".xar" => "application/vnd.xara", + ".xbd" => "application/vnd.fujixerox.docuworks.binder", + ".xbm" => "image/x-xbitmap", + ".xdm" => "application/vnd.syncml.dm+xml", + ".xdp" => "application/vnd.adobe.xdp+xml", + ".xdw" => "application/vnd.fujixerox.docuworks", + ".xenc" => "application/xenc+xml", + ".xer" => "application/patch-ops-error+xml", + ".xfdf" => "application/vnd.adobe.xfdf", + ".xfdl" => "application/vnd.xfdl", + ".xhtml" => "application/xhtml+xml", + ".xif" => "image/vnd.xiff", + ".xla" => "application/vnd.ms-excel", + ".xlam" => "application/vnd.ms-excel.addin.macroEnabled.12", + ".xls" => "application/vnd.ms-excel", + ".xlsb" => "application/vnd.ms-excel.sheet.binary.macroEnabled.12", + ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xlsm" => "application/vnd.ms-excel.sheet.macroEnabled.12", + ".xlt" => "application/vnd.ms-excel", + ".xltx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + ".xml" => "application/xml", + ".xo" => "application/vnd.olpc-sugar", + ".xop" => "application/xop+xml", + ".xpm" => "image/x-xpixmap", + ".xpr" => "application/vnd.is-xpr", + ".xps" => "application/vnd.ms-xpsdocument", + ".xpw" => "application/vnd.intercon.formnet", + ".xsl" => "application/xml", + ".xslt" => "application/xslt+xml", + ".xsm" => "application/vnd.syncml+xml", + ".xspf" => "application/xspf+xml", + ".xul" => "application/vnd.mozilla.xul+xml", + ".xwd" => "image/x-xwindowdump", + ".xyz" => "chemical/x-xyz", + ".yaml" => "text/yaml", + ".yml" => "text/yaml", + ".zaz" => "application/vnd.zzazz.deck+xml", + ".zip" => "application/zip", + ".zmm" => "application/vnd.handheld-entertainment+xml", + } + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock.rb new file mode 100644 index 0000000..5e5c457 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative 'mock_request' diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_request.rb new file mode 100644 index 0000000..7c87bea --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_request.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'uri' +require 'stringio' + +require_relative 'constants' +require_relative 'mock_response' + +module Rack + # Rack::MockRequest helps testing your Rack application without + # actually using HTTP. + # + # After performing a request on a URL with get/post/put/patch/delete, it + # returns a MockResponse with useful helper methods for effective + # testing. + # + # You can pass a hash with additional configuration to the + # get/post/put/patch/delete. + # :input:: A String or IO-like to be used as rack.input. + # :fatal:: Raise a FatalWarning if the app writes to rack.errors. + # :lint:: If true, wrap the application in a Rack::Lint. + + class MockRequest + class FatalWarning < RuntimeError + end + + class FatalWarner + def puts(warning) + raise FatalWarning, warning + end + + def write(warning) + raise FatalWarning, warning + end + + def flush + end + + def string + "" + end + end + + def initialize(app) + @app = app + end + + # Make a GET request and return a MockResponse. See #request. + def get(uri, opts = {}) request(GET, uri, opts) end + # Make a POST request and return a MockResponse. See #request. + def post(uri, opts = {}) request(POST, uri, opts) end + # Make a PUT request and return a MockResponse. See #request. + def put(uri, opts = {}) request(PUT, uri, opts) end + # Make a PATCH request and return a MockResponse. See #request. + def patch(uri, opts = {}) request(PATCH, uri, opts) end + # Make a DELETE request and return a MockResponse. See #request. + def delete(uri, opts = {}) request(DELETE, uri, opts) end + # Make a HEAD request and return a MockResponse. See #request. + def head(uri, opts = {}) request(HEAD, uri, opts) end + # Make an OPTIONS request and return a MockResponse. See #request. + def options(uri, opts = {}) request(OPTIONS, uri, opts) end + + # Make a request using the given request method for the given + # uri to the rack application and return a MockResponse. + # Options given are passed to MockRequest.env_for. + def request(method = GET, uri = "", opts = {}) + env = self.class.env_for(uri, opts.merge(method: method)) + + if opts[:lint] + app = Rack::Lint.new(@app) + else + app = @app + end + + errors = env[RACK_ERRORS] + status, headers, body = app.call(env) + MockResponse.new(status, headers, body, errors) + ensure + body.close if body.respond_to?(:close) + end + + # For historical reasons, we're pinning to RFC 2396. + # URI::Parser = URI::RFC2396_Parser + def self.parse_uri_rfc2396(uri) + @parser ||= URI::Parser.new + @parser.parse(uri) + end + + # Return the Rack environment used for a request to +uri+. + # All options that are strings are added to the returned environment. + # Options: + # :fatal :: Whether to raise an exception if request outputs to rack.errors + # :input :: The rack.input to set + # :http_version :: The SERVER_PROTOCOL to set + # :method :: The HTTP request method to use + # :params :: The params to use + # :script_name :: The SCRIPT_NAME to set + def self.env_for(uri = "", opts = {}) + uri = parse_uri_rfc2396(uri) + uri.path = "/#{uri.path}" unless uri.path[0] == ?/ + + env = {} + + env[REQUEST_METHOD] = (opts[:method] ? opts[:method].to_s.upcase : GET).b + env[SERVER_NAME] = (uri.host || "example.org").b + env[SERVER_PORT] = (uri.port ? uri.port.to_s : "80").b + env[SERVER_PROTOCOL] = opts[:http_version] || 'HTTP/1.1' + env[QUERY_STRING] = (uri.query.to_s).b + env[PATH_INFO] = (uri.path).b + env[RACK_URL_SCHEME] = (uri.scheme || "http").b + env[HTTPS] = (env[RACK_URL_SCHEME] == "https" ? "on" : "off").b + + env[SCRIPT_NAME] = opts[:script_name] || "" + + if opts[:fatal] + env[RACK_ERRORS] = FatalWarner.new + else + env[RACK_ERRORS] = StringIO.new + end + + if params = opts[:params] + if env[REQUEST_METHOD] == GET + params = Utils.parse_nested_query(params) if params.is_a?(String) + params.update(Utils.parse_nested_query(env[QUERY_STRING])) + env[QUERY_STRING] = Utils.build_nested_query(params) + elsif !opts.has_key?(:input) + opts["CONTENT_TYPE"] = "application/x-www-form-urlencoded" + if params.is_a?(Hash) + if data = Rack::Multipart.build_multipart(params) + opts[:input] = data + opts["CONTENT_LENGTH"] ||= data.length.to_s + opts["CONTENT_TYPE"] = "multipart/form-data; boundary=#{Rack::Multipart::MULTIPART_BOUNDARY}" + else + opts[:input] = Utils.build_nested_query(params) + end + else + opts[:input] = params + end + end + end + + rack_input = opts[:input] + if String === rack_input + rack_input = StringIO.new(rack_input) + end + + if rack_input + rack_input.set_encoding(Encoding::BINARY) if rack_input.respond_to?(:set_encoding) + env[RACK_INPUT] = rack_input + + env["CONTENT_LENGTH"] ||= env[RACK_INPUT].size.to_s if env[RACK_INPUT].respond_to?(:size) + end + + opts.each { |field, value| + env[field] = value if String === field + } + + env + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_response.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_response.rb new file mode 100644 index 0000000..9af8079 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_response.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'cgi/cookie' +require 'time' + +require_relative 'response' + +module Rack + # Rack::MockResponse provides useful helpers for testing your apps. + # Usually, you don't create the MockResponse on your own, but use + # MockRequest. + + class MockResponse < Rack::Response + class << self + alias [] new + end + + # Headers + attr_reader :original_headers, :cookies + + # Errors + attr_accessor :errors + + def initialize(status, headers, body, errors = nil) + @original_headers = headers + + if errors + @errors = errors.string if errors.respond_to?(:string) + else + @errors = "" + end + + super(body, status, headers) + + @cookies = parse_cookies_from_header + buffered_body! + end + + def =~(other) + body =~ other + end + + def match(other) + body.match other + end + + def body + return @buffered_body if defined?(@buffered_body) + + # FIXME: apparently users of MockResponse expect the return value of + # MockResponse#body to be a string. However, the real response object + # returns the body as a list. + # + # See spec_showstatus.rb: + # + # should "not replace existing messages" do + # ... + # res.body.should == "foo!" + # end + buffer = @buffered_body = String.new + + @body.each do |chunk| + buffer << chunk + end + + return buffer + end + + def empty? + [201, 204, 304].include? status + end + + def cookie(name) + cookies.fetch(name, nil) + end + + private + + def parse_cookies_from_header + cookies = Hash.new + set_cookie_header = headers['set-cookie'] + if set_cookie_header && !set_cookie_header.empty? + Array(set_cookie_header).each do |cookie| + cookie_name, cookie_filling = cookie.split('=', 2) + cookie_attributes = identify_cookie_attributes cookie_filling + parsed_cookie = CGI::Cookie.new( + 'name' => cookie_name.strip, + 'value' => cookie_attributes.fetch('value'), + 'path' => cookie_attributes.fetch('path', nil), + 'domain' => cookie_attributes.fetch('domain', nil), + 'expires' => cookie_attributes.fetch('expires', nil), + 'secure' => cookie_attributes.fetch('secure', false) + ) + cookies.store(cookie_name, parsed_cookie) + end + end + cookies + end + + def identify_cookie_attributes(cookie_filling) + cookie_bits = cookie_filling.split(';') + cookie_attributes = Hash.new + cookie_attributes.store('value', cookie_bits[0].strip) + cookie_bits.drop(1).each do |bit| + if bit.include? '=' + cookie_attribute, attribute_value = bit.split('=', 2) + cookie_attributes.store(cookie_attribute.strip.downcase, attribute_value.strip) + end + if bit.include? 'secure' + cookie_attributes.store('secure', true) + end + end + + if cookie_attributes.key? 'max-age' + cookie_attributes.store('expires', Time.now + cookie_attributes['max-age'].to_i) + elsif cookie_attributes.key? 'expires' + cookie_attributes.store('expires', Time.httpdate(cookie_attributes['expires'])) + end + + cookie_attributes + end + + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart.rb new file mode 100644 index 0000000..4b02fb3 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' + +require_relative 'multipart/parser' +require_relative 'multipart/generator' + +require_relative 'bad_request' + +module Rack + # A multipart form data parser, adapted from IOWA. + # + # Usually, Rack::Request#POST takes care of calling this. + module Multipart + MULTIPART_BOUNDARY = "AaB03x" + + class MissingInputError < StandardError + include BadRequest + end + + # Accumulator for multipart form data, conforming to the QueryParser API. + # In future, the Parser could return the pair list directly, but that would + # change its API. + class ParamList # :nodoc: + def self.make_params + new + end + + def self.normalize_params(params, key, value) + params << [key, value] + end + + def initialize + @pairs = [] + end + + def <<(pair) + @pairs << pair + end + + def to_params_hash + @pairs + end + end + + class << self + def parse_multipart(env, params = Rack::Utils.default_query_parser) + unless io = env[RACK_INPUT] + raise MissingInputError, "Missing input stream!" + end + + if content_length = env['CONTENT_LENGTH'] + content_length = content_length.to_i + end + + content_type = env['CONTENT_TYPE'] + + tempfile = env[RACK_MULTIPART_TEMPFILE_FACTORY] || Parser::TEMPFILE_FACTORY + bufsize = env[RACK_MULTIPART_BUFFER_SIZE] || Parser::BUFSIZE + + info = Parser.parse(io, content_length, content_type, tempfile, bufsize, params) + env[RACK_TEMPFILES] = info.tmp_files + + return info.params + end + + def extract_multipart(request, params = Rack::Utils.default_query_parser) + parse_multipart(request.env) + end + + def build_multipart(params, first = true) + Generator.new(params, first).dump + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/generator.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/generator.rb new file mode 100644 index 0000000..30d7f51 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/generator.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require_relative 'uploaded_file' + +module Rack + module Multipart + class Generator + def initialize(params, first = true) + @params, @first = params, first + + if @first && !@params.is_a?(Hash) + raise ArgumentError, "value must be a Hash" + end + end + + def dump + return nil if @first && !multipart? + return flattened_params unless @first + + flattened_params.map do |name, file| + if file.respond_to?(:original_filename) + if file.path + ::File.open(file.path, 'rb') do |f| + f.set_encoding(Encoding::BINARY) + content_for_tempfile(f, file, name) + end + else + content_for_tempfile(file, file, name) + end + else + content_for_other(file, name) + end + end.join << "--#{MULTIPART_BOUNDARY}--\r" + end + + private + def multipart? + query = lambda { |value| + case value + when Array + value.any?(&query) + when Hash + value.values.any?(&query) + when Rack::Multipart::UploadedFile + true + end + } + + @params.values.any?(&query) + end + + def flattened_params + @flattened_params ||= begin + h = Hash.new + @params.each do |key, value| + k = @first ? key.to_s : "[#{key}]" + + case value + when Array + value.map { |v| + Multipart.build_multipart(v, false).each { |subkey, subvalue| + h["#{k}[]#{subkey}"] = subvalue + } + } + when Hash + Multipart.build_multipart(value, false).each { |subkey, subvalue| + h[k + subkey] = subvalue + } + else + h[k] = value + end + end + h + end + end + + def content_for_tempfile(io, file, name) + length = ::File.stat(file.path).size if file.path + filename = "; filename=\"#{Utils.escape_path(file.original_filename)}\"" +<<-EOF +--#{MULTIPART_BOUNDARY}\r +content-disposition: form-data; name="#{name}"#{filename}\r +content-type: #{file.content_type}\r +#{"content-length: #{length}\r\n" if length}\r +#{io.read}\r +EOF + end + + def content_for_other(file, name) +<<-EOF +--#{MULTIPART_BOUNDARY}\r +content-disposition: form-data; name="#{name}"\r +\r +#{file}\r +EOF + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/parser.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/parser.rb new file mode 100644 index 0000000..3960b37 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/parser.rb @@ -0,0 +1,502 @@ +# frozen_string_literal: true + +require 'strscan' + +require_relative '../utils' +require_relative '../bad_request' + +module Rack + module Multipart + class MultipartPartLimitError < Errno::EMFILE + include BadRequest + end + + class MultipartTotalPartLimitError < StandardError + include BadRequest + end + + # Use specific error class when parsing multipart request + # that ends early. + class EmptyContentError < ::EOFError + include BadRequest + end + + # Base class for multipart exceptions that do not subclass from + # other exception classes for backwards compatibility. + class BoundaryTooLongError < StandardError + include BadRequest + end + + # Prefer to use the BoundaryTooLongError class or Rack::BadRequest. + Error = BoundaryTooLongError + + EOL = "\r\n" + MULTIPART = %r|\Amultipart/.*boundary=\"?([^\";,]+)\"?|ni + MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{EOL}/ni + MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:(.*)(?=#{EOL}(\S|\z))/ni + MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{EOL}]*)/ni + + class Parser + BUFSIZE = 1_048_576 + TEXT_PLAIN = "text/plain" + TEMPFILE_FACTORY = lambda { |filename, content_type| + extension = ::File.extname(filename.gsub("\0", '%00'))[0, 129] + + Tempfile.new(["RackMultipart", extension]) + } + + class BoundedIO # :nodoc: + def initialize(io, content_length) + @io = io + @content_length = content_length + @cursor = 0 + end + + def read(size, outbuf = nil) + return if @cursor >= @content_length + + left = @content_length - @cursor + + str = if left < size + @io.read left, outbuf + else + @io.read size, outbuf + end + + if str + @cursor += str.bytesize + else + # Raise an error for mismatching content-length and actual contents + raise EOFError, "bad content body" + end + + str + end + end + + MultipartInfo = Struct.new :params, :tmp_files + EMPTY = MultipartInfo.new(nil, []) + + def self.parse_boundary(content_type) + return unless content_type + data = content_type.match(MULTIPART) + return unless data + data[1] + end + + def self.parse(io, content_length, content_type, tmpfile, bufsize, qp) + return EMPTY if 0 == content_length + + boundary = parse_boundary content_type + return EMPTY unless boundary + + if boundary.length > 70 + # RFC 1521 Section 7.2.1 imposes a 70 character maximum for the boundary. + # Most clients use no more than 55 characters. + raise BoundaryTooLongError, "multipart boundary size too large (#{boundary.length} characters)" + end + + io = BoundedIO.new(io, content_length) if content_length + + parser = new(boundary, tmpfile, bufsize, qp) + parser.parse(io) + + parser.result + end + + class Collector + class MimePart < Struct.new(:body, :head, :filename, :content_type, :name) + def get_data + data = body + if filename == "" + # filename is blank which means no file has been selected + return + elsif filename + body.rewind if body.respond_to?(:rewind) + + # Take the basename of the upload's original filename. + # This handles the full Windows paths given by Internet Explorer + # (and perhaps other broken user agents) without affecting + # those which give the lone filename. + fn = filename.split(/[\/\\]/).last + + data = { filename: fn, type: content_type, + name: name, tempfile: body, head: head } + end + + yield data + end + end + + class BufferPart < MimePart + def file?; false; end + def close; end + end + + class TempfilePart < MimePart + def file?; true; end + def close; body.close; end + end + + include Enumerable + + def initialize(tempfile) + @tempfile = tempfile + @mime_parts = [] + @open_files = 0 + end + + def each + @mime_parts.each { |part| yield part } + end + + def on_mime_head(mime_index, head, filename, content_type, name) + if filename + body = @tempfile.call(filename, content_type) + body.binmode if body.respond_to?(:binmode) + klass = TempfilePart + @open_files += 1 + else + body = String.new + klass = BufferPart + end + + @mime_parts[mime_index] = klass.new(body, head, filename, content_type, name) + + check_part_limits + end + + def on_mime_body(mime_index, content) + @mime_parts[mime_index].body << content + end + + def on_mime_finish(mime_index) + end + + private + + def check_part_limits + file_limit = Utils.multipart_file_limit + part_limit = Utils.multipart_total_part_limit + + if file_limit && file_limit > 0 + if @open_files >= file_limit + @mime_parts.each(&:close) + raise MultipartPartLimitError, 'Maximum file multiparts in content reached' + end + end + + if part_limit && part_limit > 0 + if @mime_parts.size >= part_limit + @mime_parts.each(&:close) + raise MultipartTotalPartLimitError, 'Maximum total multiparts in content reached' + end + end + end + end + + attr_reader :state + + def initialize(boundary, tempfile, bufsize, query_parser) + @query_parser = query_parser + @params = query_parser.make_params + @bufsize = bufsize + + @state = :FAST_FORWARD + @mime_index = 0 + @collector = Collector.new tempfile + + @sbuf = StringScanner.new("".dup) + @body_regex = /(?:#{EOL}|\A)--#{Regexp.quote(boundary)}(?:#{EOL}|--)/m + @body_regex_at_end = /#{@body_regex}\z/m + @end_boundary_size = boundary.bytesize + 4 # (-- at start, -- at finish) + @rx_max_size = boundary.bytesize + 6 # (\r\n-- at start, either \r\n or -- at finish) + @head_regex = /(.*?#{EOL})#{EOL}/m + end + + def parse(io) + outbuf = String.new + read_data(io, outbuf) + + loop do + status = + case @state + when :FAST_FORWARD + handle_fast_forward + when :CONSUME_TOKEN + handle_consume_token + when :MIME_HEAD + handle_mime_head + when :MIME_BODY + handle_mime_body + else # when :DONE + return + end + + read_data(io, outbuf) if status == :want_read + end + end + + def result + @collector.each do |part| + part.get_data do |data| + tag_multipart_encoding(part.filename, part.content_type, part.name, data) + @query_parser.normalize_params(@params, part.name, data) + end + end + MultipartInfo.new @params.to_params_hash, @collector.find_all(&:file?).map(&:body) + end + + private + + def dequote(str) # From WEBrick::HTTPUtils + ret = (/\A"(.*)"\Z/ =~ str) ? $1 : str.dup + ret.gsub!(/\\(.)/, "\\1") + ret + end + + def read_data(io, outbuf) + content = io.read(@bufsize, outbuf) + handle_empty_content!(content) + @sbuf.concat(content) + end + + # This handles the initial parser state. We read until we find the starting + # boundary, then we can transition to the next state. If we find the ending + # boundary, this is an invalid multipart upload, but keep scanning for opening + # boundary in that case. If no boundary found, we need to keep reading data + # and retry. It's highly unlikely the initial read will not consume the + # boundary. The client would have to deliberately craft a response + # with the opening boundary beyond the buffer size for that to happen. + def handle_fast_forward + while true + case consume_boundary + when :BOUNDARY + # found opening boundary, transition to next state + @state = :MIME_HEAD + return + when :END_BOUNDARY + # invalid multipart upload + if @sbuf.pos == @end_boundary_size && @sbuf.rest == EOL + # stop parsing a buffer if a buffer is only an end boundary. + @state = :DONE + return + end + + # retry for opening boundary + else + # no boundary found, keep reading data + return :want_read + end + end + end + + def handle_consume_token + tok = consume_boundary + # break if we're at the end of a buffer, but not if it is the end of a field + @state = if tok == :END_BOUNDARY || (@sbuf.eos? && tok != :BOUNDARY) + :DONE + else + :MIME_HEAD + end + end + + CONTENT_DISPOSITION_MAX_PARAMS = 16 + CONTENT_DISPOSITION_MAX_BYTES = 1536 + def handle_mime_head + if @sbuf.scan_until(@head_regex) + head = @sbuf[1] + content_type = head[MULTIPART_CONTENT_TYPE, 1] + if (disposition = head[MULTIPART_CONTENT_DISPOSITION, 1]) && + disposition.bytesize <= CONTENT_DISPOSITION_MAX_BYTES + + # ignore actual content-disposition value (should always be form-data) + i = disposition.index(';') + disposition.slice!(0, i+1) + param = nil + num_params = 0 + + # Parse parameter list + while i = disposition.index('=') + # Only parse up to max parameters, to avoid potential denial of service + num_params += 1 + break if num_params > CONTENT_DISPOSITION_MAX_PARAMS + + # Found end of parameter name, ensure forward progress in loop + param = disposition.slice!(0, i+1) + + # Remove ending equals and preceding whitespace from parameter name + param.chomp!('=') + param.lstrip! + + if disposition[0] == '"' + # Parameter value is quoted, parse it, handling backslash escapes + disposition.slice!(0, 1) + value = String.new + + while i = disposition.index(/(["\\])/) + c = $1 + + # Append all content until ending quote or escape + value << disposition.slice!(0, i) + + # Remove either backslash or ending quote, + # ensures forward progress in loop + disposition.slice!(0, 1) + + # stop parsing parameter value if found ending quote + break if c == '"' + + escaped_char = disposition.slice!(0, 1) + if param == 'filename' && escaped_char != '"' + # Possible IE uploaded filename, append both escape backslash and value + value << c << escaped_char + else + # Other only append escaped value + value << escaped_char + end + end + else + if i = disposition.index(';') + # Parameter value unquoted (which may be invalid), value ends at semicolon + value = disposition.slice!(0, i) + else + # If no ending semicolon, assume remainder of line is value and stop + # parsing + disposition.strip! + value = disposition + disposition = '' + end + end + + case param + when 'name' + name = value + when 'filename' + filename = value + when 'filename*' + filename_star = value + # else + # ignore other parameters + end + + # skip trailing semicolon, to proceed to next parameter + if i = disposition.index(';') + disposition.slice!(0, i+1) + end + end + else + name = head[MULTIPART_CONTENT_ID, 1] + end + + if filename_star + encoding, _, filename = filename_star.split("'", 3) + filename = normalize_filename(filename || '') + filename.force_encoding(find_encoding(encoding)) + elsif filename + filename = normalize_filename(filename) + end + + if name.nil? || name.empty? + name = filename || "#{content_type || TEXT_PLAIN}[]".dup + end + + @collector.on_mime_head @mime_index, head, filename, content_type, name + @state = :MIME_BODY + else + :want_read + end + end + + def handle_mime_body + if (body_with_boundary = @sbuf.check_until(@body_regex)) # check but do not advance the pointer yet + body = body_with_boundary.sub(@body_regex_at_end, '') # remove the boundary from the string + @collector.on_mime_body @mime_index, body + @sbuf.pos += body.length + 2 # skip \r\n after the content + @state = :CONSUME_TOKEN + @mime_index += 1 + else + # Save what we have so far + if @rx_max_size < @sbuf.rest_size + delta = @sbuf.rest_size - @rx_max_size + @collector.on_mime_body @mime_index, @sbuf.peek(delta) + @sbuf.pos += delta + @sbuf.string = @sbuf.rest + end + :want_read + end + end + + # Scan until the we find the start or end of the boundary. + # If we find it, return the appropriate symbol for the start or + # end of the boundary. If we don't find the start or end of the + # boundary, clear the buffer and return nil. + def consume_boundary + if read_buffer = @sbuf.scan_until(@body_regex) + read_buffer.end_with?(EOL) ? :BOUNDARY : :END_BOUNDARY + else + @sbuf.terminate + nil + end + end + + def normalize_filename(filename) + if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) } + filename = Utils.unescape_path(filename) + end + + filename.scrub! + + filename.split(/[\/\\]/).last || String.new + end + + CHARSET = "charset" + deprecate_constant :CHARSET + + def tag_multipart_encoding(filename, content_type, name, body) + name = name.to_s + encoding = Encoding::UTF_8 + + name.force_encoding(encoding) + + return if filename + + if content_type + list = content_type.split(';') + type_subtype = list.first + type_subtype.strip! + if TEXT_PLAIN == type_subtype + rest = list.drop 1 + rest.each do |param| + k, v = param.split('=', 2) + k.strip! + v.strip! + v = v[1..-2] if v.start_with?('"') && v.end_with?('"') + if k == "charset" + encoding = find_encoding(v) + end + end + end + end + + name.force_encoding(encoding) + body.force_encoding(encoding) + end + + # Return the related Encoding object. However, because + # enc is submitted by the user, it may be invalid, so + # use a binary encoding in that case. + def find_encoding(enc) + Encoding.find enc + rescue ArgumentError + Encoding::BINARY + end + + def handle_empty_content!(content) + if content.nil? || content.empty? + raise EmptyContentError + end + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb new file mode 100644 index 0000000..2782e44 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'tempfile' +require 'fileutils' + +module Rack + module Multipart + class UploadedFile + + # The filename, *not* including the path, of the "uploaded" file + attr_reader :original_filename + + # The content type of the "uploaded" file + attr_accessor :content_type + + def initialize(filepath = nil, ct = "text/plain", bin = false, + path: filepath, content_type: ct, binary: bin, filename: nil, io: nil) + if io + @tempfile = io + @original_filename = filename + else + raise "#{path} file does not exist" unless ::File.exist?(path) + @original_filename = filename || ::File.basename(path) + @tempfile = Tempfile.new([@original_filename, ::File.extname(path)], encoding: Encoding::BINARY) + @tempfile.binmode if binary + FileUtils.copy_file(path, @tempfile.path) + end + @content_type = content_type + end + + def path + @tempfile.path if @tempfile.respond_to?(:path) + end + alias_method :local_path, :path + + def respond_to?(*args) + super or @tempfile.respond_to?(*args) + end + + def method_missing(method_name, *args, &block) #:nodoc: + @tempfile.__send__(method_name, *args, &block) + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/null_logger.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/null_logger.rb new file mode 100644 index 0000000..52fc125 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/null_logger.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative 'constants' + +module Rack + class NullLogger + def initialize(app) + @app = app + end + + def call(env) + env[RACK_LOGGER] = self + @app.call(env) + end + + def info(progname = nil, &block); end + def debug(progname = nil, &block); end + def warn(progname = nil, &block); end + def error(progname = nil, &block); end + def fatal(progname = nil, &block); end + def unknown(progname = nil, &block); end + def info? ; end + def debug? ; end + def warn? ; end + def error? ; end + def fatal? ; end + def debug! ; end + def error! ; end + def fatal! ; end + def info! ; end + def warn! ; end + def level ; end + def progname ; end + def datetime_format ; end + def formatter ; end + def sev_threshold ; end + def level=(level); end + def progname=(progname); end + def datetime_format=(datetime_format); end + def formatter=(formatter); end + def sev_threshold=(sev_threshold); end + def close ; end + def add(severity, message = nil, progname = nil, &block); end + def log(severity, message = nil, progname = nil, &block); end + def <<(msg); end + def reopen(logdev = nil); end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/query_parser.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/query_parser.rb new file mode 100644 index 0000000..28cbce1 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/query_parser.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require_relative 'bad_request' +require 'uri' + +module Rack + class QueryParser + DEFAULT_SEP = /& */n + COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n } + + # ParameterTypeError is the error that is raised when incoming structural + # parameters (parsed by parse_nested_query) contain conflicting types. + class ParameterTypeError < TypeError + include BadRequest + end + + # InvalidParameterError is the error that is raised when incoming structural + # parameters (parsed by parse_nested_query) contain invalid format or byte + # sequence. + class InvalidParameterError < ArgumentError + include BadRequest + end + + # ParamsTooDeepError is the error that is raised when params are recursively + # nested over the specified limit. + class ParamsTooDeepError < RangeError + include BadRequest + end + + def self.make_default(param_depth_limit) + new Params, param_depth_limit + end + + attr_reader :param_depth_limit + + def initialize(params_class, param_depth_limit) + @params_class = params_class + @param_depth_limit = param_depth_limit + end + + # Stolen from Mongrel, with some small modifications: + # Parses a query string by breaking it up at the '&'. You can also use this + # to parse cookies by changing the characters used in the second parameter + # (which defaults to '&'). + def parse_query(qs, separator = nil, &unescaper) + unescaper ||= method(:unescape) + + params = make_params + + (qs || '').split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p| + next if p.empty? + k, v = p.split('=', 2).map!(&unescaper) + + if cur = params[k] + if cur.class == Array + params[k] << v + else + params[k] = [cur, v] + end + else + params[k] = v + end + end + + return params.to_h + end + + # parse_nested_query expands a query string into structural types. Supported + # types are Arrays, Hashes and basic value types. It is possible to supply + # query strings with parameters of conflicting types, in this case a + # ParameterTypeError is raised. Users are encouraged to return a 400 in this + # case. + def parse_nested_query(qs, separator = nil) + params = make_params + + unless qs.nil? || qs.empty? + (qs || '').split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p| + k, v = p.split('=', 2).map! { |s| unescape(s) } + + _normalize_params(params, k, v, 0) + end + end + + return params.to_h + rescue ArgumentError => e + raise InvalidParameterError, e.message, e.backtrace + end + + # normalize_params recursively expands parameters into structural types. If + # the structural types represented by two different parameter names are in + # conflict, a ParameterTypeError is raised. The depth argument is deprecated + # and should no longer be used, it is kept for backwards compatibility with + # earlier versions of rack. + def normalize_params(params, name, v, _depth=nil) + _normalize_params(params, name, v, 0) + end + + private def _normalize_params(params, name, v, depth) + raise ParamsTooDeepError if depth >= param_depth_limit + + if !name + # nil name, treat same as empty string (required by tests) + k = after = '' + elsif depth == 0 + # Start of parsing, don't treat [] or [ at start of string specially + if start = name.index('[', 1) + # Start of parameter nesting, use part before brackets as key + k = name[0, start] + after = name[start, name.length] + else + # Plain parameter with no nesting + k = name + after = '' + end + elsif name.start_with?('[]') + # Array nesting + k = '[]' + after = name[2, name.length] + elsif name.start_with?('[') && (start = name.index(']', 1)) + # Hash nesting, use the part inside brackets as the key + k = name[1, start-1] + after = name[start+1, name.length] + else + # Probably malformed input, nested but not starting with [ + # treat full name as key for backwards compatibility. + k = name + after = '' + end + + return if k.empty? + + if after == '' + if k == '[]' && depth != 0 + return [v] + else + params[k] = v + end + elsif after == "[" + params[name] = v + elsif after == "[]" + params[k] ||= [] + raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) + params[k] << v + elsif after.start_with?('[]') + # Recognize x[][y] (hash inside array) parameters + unless after[2] == '[' && after.end_with?(']') && (child_key = after[3, after.length-4]) && !child_key.empty? && !child_key.index('[') && !child_key.index(']') + # Handle other nested array parameters + child_key = after[2, after.length] + end + params[k] ||= [] + raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) + if params_hash_type?(params[k].last) && !params_hash_has_key?(params[k].last, child_key) + _normalize_params(params[k].last, child_key, v, depth + 1) + else + params[k] << _normalize_params(make_params, child_key, v, depth + 1) + end + else + params[k] ||= make_params + raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k]) + params[k] = _normalize_params(params[k], after, v, depth + 1) + end + + params + end + + def make_params + @params_class.new + end + + def new_depth_limit(param_depth_limit) + self.class.new @params_class, param_depth_limit + end + + private + + def params_hash_type?(obj) + obj.kind_of?(@params_class) + end + + def params_hash_has_key?(hash, key) + return false if /\[\]/.match?(key) + + key.split(/[\[\]]+/).inject(hash) do |h, part| + next h if part == '' + return false unless params_hash_type?(h) && h.key?(part) + h[part] + end + + true + end + + def unescape(string, encoding = Encoding::UTF_8) + URI.decode_www_form_component(string, encoding) + end + + class Params < Hash + alias_method :to_params_hash, :to_h + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/recursive.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/recursive.rb new file mode 100644 index 0000000..0945d32 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/recursive.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'uri' + +require_relative 'constants' + +module Rack + # Rack::ForwardRequest gets caught by Rack::Recursive and redirects + # the current request to the app at +url+. + # + # raise ForwardRequest.new("/not-found") + # + + class ForwardRequest < Exception + attr_reader :url, :env + + def initialize(url, env = {}) + @url = URI(url) + @env = env + + @env[PATH_INFO] = @url.path + @env[QUERY_STRING] = @url.query if @url.query + @env[HTTP_HOST] = @url.host if @url.host + @env[HTTP_PORT] = @url.port if @url.port + @env[RACK_URL_SCHEME] = @url.scheme if @url.scheme + + super "forwarding to #{url}" + end + end + + # Rack::Recursive allows applications called down the chain to + # include data from other applications (by using + # rack['rack.recursive.include'][...] or raise a + # ForwardRequest to redirect internally. + + class Recursive + def initialize(app) + @app = app + end + + def call(env) + dup._call(env) + end + + def _call(env) + @script_name = env[SCRIPT_NAME] + @app.call(env.merge(RACK_RECURSIVE_INCLUDE => method(:include))) + rescue ForwardRequest => req + call(env.merge(req.env)) + end + + def include(env, path) + unless path.index(@script_name) == 0 && (path[@script_name.size] == ?/ || + path[@script_name.size].nil?) + raise ArgumentError, "can only include below #{@script_name}, not #{path}" + end + + env = env.merge(PATH_INFO => path, + SCRIPT_NAME => @script_name, + REQUEST_METHOD => GET, + "CONTENT_LENGTH" => "0", "CONTENT_TYPE" => "", + RACK_INPUT => StringIO.new("")) + @app.call(env) + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/reloader.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/reloader.rb new file mode 100644 index 0000000..a15064a --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/reloader.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# Copyright (C) 2009-2018 Michael Fellinger +# Rack::Reloader is subject to the terms of an MIT-style license. +# See MIT-LICENSE or https://opensource.org/licenses/MIT. + +require 'pathname' + +module Rack + + # High performant source reloader + # + # This class acts as Rack middleware. + # + # What makes it especially suited for use in a production environment is that + # any file will only be checked once and there will only be made one system + # call stat(2). + # + # Please note that this will not reload files in the background, it does so + # only when actively called. + # + # It is performing a check/reload cycle at the start of every request, but + # also respects a cool down time, during which nothing will be done. + class Reloader + def initialize(app, cooldown = 10, backend = Stat) + @app = app + @cooldown = cooldown + @last = (Time.now - cooldown) + @cache = {} + @mtimes = {} + @reload_mutex = Mutex.new + + extend backend + end + + def call(env) + if @cooldown and Time.now > @last + @cooldown + if Thread.list.size > 1 + @reload_mutex.synchronize{ reload! } + else + reload! + end + + @last = Time.now + end + + @app.call(env) + end + + def reload!(stderr = $stderr) + rotation do |file, mtime| + previous_mtime = @mtimes[file] ||= mtime + safe_load(file, mtime, stderr) if mtime > previous_mtime + end + end + + # A safe Kernel::load, issuing the hooks depending on the results + def safe_load(file, mtime, stderr = $stderr) + load(file) + stderr.puts "#{self.class}: reloaded `#{file}'" + file + rescue LoadError, SyntaxError => ex + stderr.puts ex + ensure + @mtimes[file] = mtime + end + + module Stat + def rotation + files = [$0, *$LOADED_FEATURES].uniq + paths = ['./', *$LOAD_PATH].uniq + + files.map{|file| + next if /\.(so|bundle)$/.match?(file) # cannot reload compiled files + + found, stat = figure_path(file, paths) + next unless found && stat && mtime = stat.mtime + + @cache[file] = found + + yield(found, mtime) + }.compact + end + + # Takes a relative or absolute +file+ name, a couple possible +paths+ that + # the +file+ might reside in. Returns the full path and File::Stat for the + # path. + def figure_path(file, paths) + found = @cache[file] + found = file if !found and Pathname.new(file).absolute? + found, stat = safe_stat(found) + return found, stat if found + + paths.find do |possible_path| + path = ::File.join(possible_path, file) + found, stat = safe_stat(path) + return ::File.expand_path(found), stat if found + end + + return false, false + end + + def safe_stat(file) + return unless file + stat = ::File.stat(file) + return file, stat if stat.file? + rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ESRCH + @cache.delete(file) and false + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/request.rb new file mode 100644 index 0000000..93526a0 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/request.rb @@ -0,0 +1,796 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' +require_relative 'media_type' + +module Rack + # Rack::Request provides a convenient interface to a Rack + # environment. It is stateless, the environment +env+ passed to the + # constructor will be directly modified. + # + # req = Rack::Request.new(env) + # req.post? + # req.params["data"] + + class Request + class << self + attr_accessor :ip_filter + + # The priority when checking forwarded headers. The default + # is [:forwarded, :x_forwarded], which means, check the + # +Forwarded+ header first, followed by the appropriate + # X-Forwarded-* header. You can revert the priority by + # reversing the priority, or remove checking of either + # or both headers by removing elements from the array. + # + # This should be set as appropriate in your environment + # based on what reverse proxies are in use. If you are not + # using reverse proxies, you should probably use an empty + # array. + attr_accessor :forwarded_priority + + # The priority when checking either the X-Forwarded-Proto + # or X-Forwarded-Scheme header for the forwarded protocol. + # The default is [:proto, :scheme], to try the + # X-Forwarded-Proto header before the + # X-Forwarded-Scheme header. Rack 2 had behavior + # similar to [:scheme, :proto]. You can remove either or + # both of the entries in array to ignore that respective header. + attr_accessor :x_forwarded_proto_priority + end + + @forwarded_priority = [:forwarded, :x_forwarded] + @x_forwarded_proto_priority = [:proto, :scheme] + + valid_ipv4_octet = /\.(25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])/ + + trusted_proxies = Regexp.union( + /\A127#{valid_ipv4_octet}{3}\z/, # localhost IPv4 range 127.x.x.x, per RFC-3330 + /\A::1\z/, # localhost IPv6 ::1 + /\Af[cd][0-9a-f]{2}(?::[0-9a-f]{0,4}){0,7}\z/i, # private IPv6 range fc00 .. fdff + /\A10#{valid_ipv4_octet}{3}\z/, # private IPv4 range 10.x.x.x + /\A172\.(1[6-9]|2[0-9]|3[01])#{valid_ipv4_octet}{2}\z/, # private IPv4 range 172.16.0.0 .. 172.31.255.255 + /\A192\.168#{valid_ipv4_octet}{2}\z/, # private IPv4 range 192.168.x.x + /\Alocalhost\z|\Aunix(\z|:)/i, # localhost hostname, and unix domain sockets + ) + + self.ip_filter = lambda { |ip| trusted_proxies.match?(ip) } + + ALLOWED_SCHEMES = %w(https http wss ws).freeze + + def initialize(env) + @env = env + @params = nil + end + + def params + @params ||= super + end + + def update_param(k, v) + super + @params = nil + end + + def delete_param(k) + v = super + @params = nil + v + end + + module Env + # The environment of the request. + attr_reader :env + + def initialize(env) + @env = env + # This module is included at least in `ActionDispatch::Request` + # The call to `super()` allows additional mixed-in initializers are called + super() + end + + # Predicate method to test to see if `name` has been set as request + # specific data + def has_header?(name) + @env.key? name + end + + # Get a request specific value for `name`. + def get_header(name) + @env[name] + end + + # If a block is given, it yields to the block if the value hasn't been set + # on the request. + def fetch_header(name, &block) + @env.fetch(name, &block) + end + + # Loops through each key / value pair in the request specific data. + def each_header(&block) + @env.each(&block) + end + + # Set a request specific value for `name` to `v` + def set_header(name, v) + @env[name] = v + end + + # Add a header that may have multiple values. + # + # Example: + # request.add_header 'Accept', 'image/png' + # request.add_header 'Accept', '*/*' + # + # assert_equal 'image/png,*/*', request.get_header('Accept') + # + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 + def add_header(key, v) + if v.nil? + get_header key + elsif has_header? key + set_header key, "#{get_header key},#{v}" + else + set_header key, v + end + end + + # Delete a request specific value for `name`. + def delete_header(name) + @env.delete name + end + + def initialize_copy(other) + @env = other.env.dup + end + end + + module Helpers + # The set of form-data media-types. Requests that do not indicate + # one of the media types present in this list will not be eligible + # for form-data / param parsing. + FORM_DATA_MEDIA_TYPES = [ + 'application/x-www-form-urlencoded', + 'multipart/form-data' + ] + + # The set of media-types. Requests that do not indicate + # one of the media types present in this list will not be eligible + # for param parsing like soap attachments or generic multiparts + PARSEABLE_DATA_MEDIA_TYPES = [ + 'multipart/related', + 'multipart/mixed' + ] + + # Default ports depending on scheme. Used to decide whether or not + # to include the port in a generated URI. + DEFAULT_PORTS = { 'http' => 80, 'https' => 443, 'coffee' => 80 } + + # The address of the client which connected to the proxy. + HTTP_X_FORWARDED_FOR = 'HTTP_X_FORWARDED_FOR' + + # The contents of the host/:authority header sent to the proxy. + HTTP_X_FORWARDED_HOST = 'HTTP_X_FORWARDED_HOST' + + HTTP_FORWARDED = 'HTTP_FORWARDED' + + # The value of the scheme sent to the proxy. + HTTP_X_FORWARDED_SCHEME = 'HTTP_X_FORWARDED_SCHEME' + + # The protocol used to connect to the proxy. + HTTP_X_FORWARDED_PROTO = 'HTTP_X_FORWARDED_PROTO' + + # The port used to connect to the proxy. + HTTP_X_FORWARDED_PORT = 'HTTP_X_FORWARDED_PORT' + + # Another way for specifying https scheme was used. + HTTP_X_FORWARDED_SSL = 'HTTP_X_FORWARDED_SSL' + + def body; get_header(RACK_INPUT) end + def script_name; get_header(SCRIPT_NAME).to_s end + def script_name=(s); set_header(SCRIPT_NAME, s.to_s) end + + def path_info; get_header(PATH_INFO).to_s end + def path_info=(s); set_header(PATH_INFO, s.to_s) end + + def request_method; get_header(REQUEST_METHOD) end + def query_string; get_header(QUERY_STRING).to_s end + def content_length; get_header('CONTENT_LENGTH') end + def logger; get_header(RACK_LOGGER) end + def user_agent; get_header('HTTP_USER_AGENT') end + + # the referer of the client + def referer; get_header('HTTP_REFERER') end + alias referrer referer + + def session + fetch_header(RACK_SESSION) do |k| + set_header RACK_SESSION, default_session + end + end + + def session_options + fetch_header(RACK_SESSION_OPTIONS) do |k| + set_header RACK_SESSION_OPTIONS, {} + end + end + + # Checks the HTTP request method (or verb) to see if it was of type DELETE + def delete?; request_method == DELETE end + + # Checks the HTTP request method (or verb) to see if it was of type GET + def get?; request_method == GET end + + # Checks the HTTP request method (or verb) to see if it was of type HEAD + def head?; request_method == HEAD end + + # Checks the HTTP request method (or verb) to see if it was of type OPTIONS + def options?; request_method == OPTIONS end + + # Checks the HTTP request method (or verb) to see if it was of type LINK + def link?; request_method == LINK end + + # Checks the HTTP request method (or verb) to see if it was of type PATCH + def patch?; request_method == PATCH end + + # Checks the HTTP request method (or verb) to see if it was of type POST + def post?; request_method == POST end + + # Checks the HTTP request method (or verb) to see if it was of type PUT + def put?; request_method == PUT end + + # Checks the HTTP request method (or verb) to see if it was of type TRACE + def trace?; request_method == TRACE end + + # Checks the HTTP request method (or verb) to see if it was of type UNLINK + def unlink?; request_method == UNLINK end + + def scheme + if get_header(HTTPS) == 'on' + 'https' + elsif get_header(HTTP_X_FORWARDED_SSL) == 'on' + 'https' + elsif forwarded_scheme + forwarded_scheme + else + get_header(RACK_URL_SCHEME) + end + end + + # The authority of the incoming request as defined by RFC3976. + # https://tools.ietf.org/html/rfc3986#section-3.2 + # + # In HTTP/1, this is the `host` header. + # In HTTP/2, this is the `:authority` pseudo-header. + def authority + forwarded_authority || host_authority || server_authority + end + + # The authority as defined by the `SERVER_NAME` and `SERVER_PORT` + # variables. + def server_authority + host = self.server_name + port = self.server_port + + if host + if port + "#{host}:#{port}" + else + host + end + end + end + + def server_name + get_header(SERVER_NAME) + end + + def server_port + get_header(SERVER_PORT) + end + + def cookies + hash = fetch_header(RACK_REQUEST_COOKIE_HASH) do |key| + set_header(key, {}) + end + + string = get_header(HTTP_COOKIE) + + unless string == get_header(RACK_REQUEST_COOKIE_STRING) + hash.replace Utils.parse_cookies_header(string) + set_header(RACK_REQUEST_COOKIE_STRING, string) + end + + hash + end + + def content_type + content_type = get_header('CONTENT_TYPE') + content_type.nil? || content_type.empty? ? nil : content_type + end + + def xhr? + get_header("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" + end + + # The `HTTP_HOST` header. + def host_authority + get_header(HTTP_HOST) + end + + def host_with_port(authority = self.authority) + host, _, port = split_authority(authority) + + if port == DEFAULT_PORTS[self.scheme] + host + else + authority + end + end + + # Returns a formatted host, suitable for being used in a URI. + def host + split_authority(self.authority)[0] + end + + # Returns an address suitable for being to resolve to an address. + # In the case of a domain name or IPv4 address, the result is the same + # as +host+. In the case of IPv6 or future address formats, the square + # brackets are removed. + def hostname + split_authority(self.authority)[1] + end + + def port + if authority = self.authority + _, _, port = split_authority(authority) + end + + port || forwarded_port&.last || DEFAULT_PORTS[scheme] || server_port + end + + def forwarded_for + forwarded_priority.each do |type| + case type + when :forwarded + if forwarded_for = get_http_forwarded(:for) + return(forwarded_for.map! do |authority| + split_authority(authority)[1] + end) + end + when :x_forwarded + if value = get_header(HTTP_X_FORWARDED_FOR) + return(split_header(value).map do |authority| + split_authority(wrap_ipv6(authority))[1] + end) + end + end + end + + nil + end + + def forwarded_port + forwarded_priority.each do |type| + case type + when :forwarded + if forwarded = get_http_forwarded(:for) + return(forwarded.map do |authority| + split_authority(authority)[2] + end.compact) + end + when :x_forwarded + if value = get_header(HTTP_X_FORWARDED_PORT) + return split_header(value).map(&:to_i) + end + end + end + + nil + end + + def forwarded_authority + forwarded_priority.each do |type| + case type + when :forwarded + if forwarded = get_http_forwarded(:host) + return forwarded.last + end + when :x_forwarded + if value = get_header(HTTP_X_FORWARDED_HOST) + return wrap_ipv6(split_header(value).last) + end + end + end + + nil + end + + def ssl? + scheme == 'https' || scheme == 'wss' + end + + def ip + remote_addresses = split_header(get_header('REMOTE_ADDR')) + external_addresses = reject_trusted_ip_addresses(remote_addresses) + + unless external_addresses.empty? + return external_addresses.last + end + + if (forwarded_for = self.forwarded_for) && !forwarded_for.empty? + # The forwarded for addresses are ordered: client, proxy1, proxy2. + # So we reject all the trusted addresses (proxy*) and return the + # last client. Or if we trust everyone, we just return the first + # address. + return reject_trusted_ip_addresses(forwarded_for).last || forwarded_for.first + end + + # If all the addresses are trusted, and we aren't forwarded, just return + # the first remote address, which represents the source of the request. + remote_addresses.first + end + + # The media type (type/subtype) portion of the CONTENT_TYPE header + # without any media type parameters. e.g., when CONTENT_TYPE is + # "text/plain;charset=utf-8", the media-type is "text/plain". + # + # For more information on the use of media types in HTTP, see: + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 + def media_type + MediaType.type(content_type) + end + + # The media type parameters provided in CONTENT_TYPE as a Hash, or + # an empty Hash if no CONTENT_TYPE or media-type parameters were + # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", + # this method responds with the following Hash: + # { 'charset' => 'utf-8' } + def media_type_params + MediaType.params(content_type) + end + + # The character set of the request body if a "charset" media type + # parameter was given, or nil if no "charset" was specified. Note + # that, per RFC2616, text/* media types that specify no explicit + # charset are to be considered ISO-8859-1. + def content_charset + media_type_params['charset'] + end + + # Determine whether the request body contains form-data by checking + # the request content-type for one of the media-types: + # "application/x-www-form-urlencoded" or "multipart/form-data". The + # list of form-data media types can be modified through the + # +FORM_DATA_MEDIA_TYPES+ array. + # + # A request body is also assumed to contain form-data when no + # content-type header is provided and the request_method is POST. + def form_data? + type = media_type + meth = get_header(RACK_METHODOVERRIDE_ORIGINAL_METHOD) || get_header(REQUEST_METHOD) + + (meth == POST && type.nil?) || FORM_DATA_MEDIA_TYPES.include?(type) + end + + # Determine whether the request body contains data by checking + # the request media_type against registered parse-data media-types + def parseable_data? + PARSEABLE_DATA_MEDIA_TYPES.include?(media_type) + end + + # Returns the data received in the query string. + def GET + rr_query_string = get_header(RACK_REQUEST_QUERY_STRING) + query_string = self.query_string + if rr_query_string == query_string + get_header(RACK_REQUEST_QUERY_HASH) + else + if rr_query_string + warn "query string used for GET parsing different from current query string. Starting in Rack 3.2, Rack will used the cached GET value instead of parsing the current query string.", uplevel: 1 + end + query_hash = parse_query(query_string, '&') + set_header(RACK_REQUEST_QUERY_STRING, query_string) + set_header(RACK_REQUEST_QUERY_HASH, query_hash) + end + end + + # Returns the data received in the request body. + # + # This method support both application/x-www-form-urlencoded and + # multipart/form-data. + def POST + if error = get_header(RACK_REQUEST_FORM_ERROR) + raise error.class, error.message, cause: error.cause + end + + begin + rack_input = get_header(RACK_INPUT) + + # If the form hash was already memoized: + if form_hash = get_header(RACK_REQUEST_FORM_HASH) + form_input = get_header(RACK_REQUEST_FORM_INPUT) + # And it was memoized from the same input: + if form_input.equal?(rack_input) + return form_hash + elsif form_input + warn "input stream used for POST parsing different from current input stream. Starting in Rack 3.2, Rack will used the cached POST value instead of parsing the current input stream.", uplevel: 1 + end + end + + # Otherwise, figure out how to parse the input: + if rack_input.nil? + set_header RACK_REQUEST_FORM_INPUT, nil + set_header(RACK_REQUEST_FORM_HASH, {}) + elsif form_data? || parseable_data? + if pairs = Rack::Multipart.parse_multipart(env, Rack::Multipart::ParamList) + set_header RACK_REQUEST_FORM_PAIRS, pairs + set_header RACK_REQUEST_FORM_HASH, expand_param_pairs(pairs) + else + form_vars = get_header(RACK_INPUT).read + + # Fix for Safari Ajax postings that always append \0 + # form_vars.sub!(/\0\z/, '') # performance replacement: + form_vars.slice!(-1) if form_vars.end_with?("\0") + + set_header RACK_REQUEST_FORM_VARS, form_vars + set_header RACK_REQUEST_FORM_HASH, parse_query(form_vars, '&') + end + + set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) + get_header RACK_REQUEST_FORM_HASH + else + set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) + set_header(RACK_REQUEST_FORM_HASH, {}) + end + rescue => error + set_header(RACK_REQUEST_FORM_ERROR, error) + raise + end + end + + # The union of GET and POST data. + # + # Note that modifications will not be persisted in the env. Use update_param or delete_param if you want to destructively modify params. + def params + self.GET.merge(self.POST) + end + + # Destructively update a parameter, whether it's in GET and/or POST. Returns nil. + # + # The parameter is updated wherever it was previous defined, so GET, POST, or both. If it wasn't previously defined, it's inserted into GET. + # + # env['rack.input'] is not touched. + def update_param(k, v) + found = false + if self.GET.has_key?(k) + found = true + self.GET[k] = v + end + if self.POST.has_key?(k) + found = true + self.POST[k] = v + end + unless found + self.GET[k] = v + end + end + + # Destructively delete a parameter, whether it's in GET or POST. Returns the value of the deleted parameter. + # + # If the parameter is in both GET and POST, the POST value takes precedence since that's how #params works. + # + # env['rack.input'] is not touched. + def delete_param(k) + post_value, get_value = self.POST.delete(k), self.GET.delete(k) + post_value || get_value + end + + def base_url + "#{scheme}://#{host_with_port}" + end + + # Tries to return a remake of the original request URL as a string. + def url + base_url + fullpath + end + + def path + script_name + path_info + end + + def fullpath + query_string.empty? ? path : "#{path}?#{query_string}" + end + + def accept_encoding + parse_http_accept_header(get_header("HTTP_ACCEPT_ENCODING")) + end + + def accept_language + parse_http_accept_header(get_header("HTTP_ACCEPT_LANGUAGE")) + end + + def trusted_proxy?(ip) + Rack::Request.ip_filter.call(ip) + end + + # like Hash#values_at + def values_at(*keys) + warn("Request#values_at is deprecated and will be removed in a future version of Rack. Please use request.params.values_at instead", uplevel: 1) + + keys.map { |key| params[key] } + end + + private + + def default_session; {}; end + + # Assist with compatibility when processing `X-Forwarded-For`. + def wrap_ipv6(host) + # Even thought IPv6 addresses should be wrapped in square brackets, + # sometimes this is not done in various legacy/underspecified headers. + # So we try to fix this situation for compatibility reasons. + + # Try to detect IPv6 addresses which aren't escaped yet: + if !host.start_with?('[') && host.count(':') > 1 + "[#{host}]" + else + host + end + end + + def parse_http_accept_header(header) + # It would be nice to use filter_map here, but it's Ruby 2.7+ + parts = header.to_s.split(',') + + parts.map! do |part| + part.strip! + next if part.empty? + + attribute, parameters = part.split(';', 2) + attribute.strip! + parameters&.strip! + quality = 1.0 + if parameters and /\Aq=([\d.]+)/ =~ parameters + quality = $1.to_f + end + [attribute, quality] + end + + parts.compact! + + parts + end + + # Get an array of values set in the RFC 7239 `Forwarded` request header. + def get_http_forwarded(token) + Utils.forwarded_values(get_header(HTTP_FORWARDED))&.[](token) + end + + def query_parser + Utils.default_query_parser + end + + def parse_query(qs, d = '&') + query_parser.parse_nested_query(qs, d) + end + + def parse_multipart + Rack::Multipart.extract_multipart(self, query_parser) + end + + def expand_param_pairs(pairs, query_parser = query_parser()) + params = query_parser.make_params + + pairs.each do |k, v| + query_parser.normalize_params(params, k, v) + end + + params.to_params_hash + end + + def split_header(value) + value ? value.strip.split(/[,\s]+/) : [] + end + + # ipv6 extracted from resolv stdlib, simplified + # to remove numbered match group creation. + ipv6 = Regexp.union( + /(?:[0-9A-Fa-f]{1,4}:){7} + [0-9A-Fa-f]{1,4}/x, + /(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: + (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?/x, + /(?:[0-9A-Fa-f]{1,4}:){6,6} + \d+\.\d+\.\d+\.\d+/x, + /(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: + (?:[0-9A-Fa-f]{1,4}:)* + \d+\.\d+\.\d+\.\d+/x, + /[Ff][Ee]80 + (?::[0-9A-Fa-f]{1,4}){7} + %[-0-9A-Za-z._~]+/x, + /[Ff][Ee]80: + (?: + (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: + (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? + | + :(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? + )? + :[0-9A-Fa-f]{1,4}%[-0-9A-Za-z._~]+/x) + + AUTHORITY = / + \A + (? + # Match IPv6 as a string of hex digits and colons in square brackets + \[(?
#{ipv6})\] + | + # Match any other printable string (except square brackets) as a hostname + (?
[[[:graph:]&&[^\[\]]]]*?) + ) + (:(?\d+))? + \z + /x + + private_constant :AUTHORITY + + def split_authority(authority) + return [] if authority.nil? + return [] unless match = AUTHORITY.match(authority) + return match[:host], match[:address], match[:port]&.to_i + end + + def reject_trusted_ip_addresses(ip_addresses) + ip_addresses.reject { |ip| trusted_proxy?(ip) } + end + + FORWARDED_SCHEME_HEADERS = { + proto: HTTP_X_FORWARDED_PROTO, + scheme: HTTP_X_FORWARDED_SCHEME + }.freeze + private_constant :FORWARDED_SCHEME_HEADERS + def forwarded_scheme + forwarded_priority.each do |type| + case type + when :forwarded + if (forwarded_proto = get_http_forwarded(:proto)) && + (scheme = allowed_scheme(forwarded_proto.last)) + return scheme + end + when :x_forwarded + x_forwarded_proto_priority.each do |x_type| + if header = FORWARDED_SCHEME_HEADERS[x_type] + split_header(get_header(header)).reverse_each do |scheme| + if allowed_scheme(scheme) + return scheme + end + end + end + end + end + end + + nil + end + + def allowed_scheme(header) + header if ALLOWED_SCHEMES.include?(header) + end + + def forwarded_priority + Request.forwarded_priority + end + + def x_forwarded_proto_priority + Request.x_forwarded_proto_priority + end + end + + include Env + include Helpers + end +end + +# :nocov: +require_relative 'multipart' unless defined?(Rack::Multipart) +# :nocov: diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/response.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/response.rb new file mode 100644 index 0000000..ece451d --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/response.rb @@ -0,0 +1,403 @@ +# frozen_string_literal: true + +require 'time' + +require_relative 'constants' +require_relative 'utils' +require_relative 'media_type' +require_relative 'headers' + +module Rack + # Rack::Response provides a convenient interface to create a Rack + # response. + # + # It allows setting of headers and cookies, and provides useful + # defaults (an OK response with empty headers and body). + # + # You can use Response#write to iteratively generate your response, + # but note that this is buffered by Rack::Response until you call + # +finish+. +finish+ however can take a block inside which calls to + # +write+ are synchronous with the Rack response. + # + # Your application's +call+ should end returning Response#finish. + class Response + def self.[](status, headers, body) + self.new(body, status, headers) + end + + CHUNKED = 'chunked' + STATUS_WITH_NO_ENTITY_BODY = Utils::STATUS_WITH_NO_ENTITY_BODY + + attr_accessor :length, :status, :body + attr_reader :headers + + # Initialize the response object with the specified +body+, +status+ + # and +headers+. + # + # If the +body+ is +nil+, construct an empty response object with internal + # buffering. + # + # If the +body+ responds to +to_str+, assume it's a string-like object and + # construct a buffered response object containing using that string as the + # initial contents of the buffer. + # + # Otherwise it is expected +body+ conforms to the normal requirements of a + # Rack response body, typically implementing one of +each+ (enumerable + # body) or +call+ (streaming body). + # + # The +status+ defaults to +200+ which is the "OK" HTTP status code. You + # can provide any other valid status code. + # + # The +headers+ must be a +Hash+ of key-value header pairs which conform to + # the Rack specification for response headers. The key must be a +String+ + # instance and the value can be either a +String+ or +Array+ instance. + def initialize(body = nil, status = 200, headers = {}) + @status = status.to_i + + unless headers.is_a?(Hash) + raise ArgumentError, "Headers must be a Hash!" + end + + @headers = Headers.new + # Convert headers input to a plain hash with lowercase keys. + headers.each do |k, v| + @headers[k] = v + end + + @writer = self.method(:append) + + @block = nil + + # Keep track of whether we have expanded the user supplied body. + if body.nil? + @body = [] + @buffered = true + # Body is unspecified - it may be a buffered response, or it may be a HEAD response. + @length = nil + elsif body.respond_to?(:to_str) + @body = [body] + @buffered = true + @length = body.to_str.bytesize + else + @body = body + @buffered = nil # undetermined as of yet. + @length = nil + end + + yield self if block_given? + end + + def redirect(target, status = 302) + self.status = status + self.location = target + end + + def chunked? + CHUNKED == get_header(TRANSFER_ENCODING) + end + + def no_entity_body? + # The response body is an enumerable body and it is not allowed to have an entity body. + @body.respond_to?(:each) && STATUS_WITH_NO_ENTITY_BODY[@status] + end + + # Generate a response array consistent with the requirements of the SPEC. + # @return [Array] a 3-tuple suitable of `[status, headers, body]` + # which is suitable to be returned from the middleware `#call(env)` method. + def finish(&block) + if no_entity_body? + delete_header CONTENT_TYPE + delete_header CONTENT_LENGTH + close + return [@status, @headers, []] + else + if block_given? + # We don't add the content-length here as the user has provided a block that can #write additional chunks to the body. + @block = block + return [@status, @headers, self] + else + # If we know the length of the body, set the content-length header... except if we are chunked? which is a legacy special case where the body might already be encoded and thus the actual encoded body length and the content-length are likely to be different. + if @length && !chunked? + @headers[CONTENT_LENGTH] = @length.to_s + end + return [@status, @headers, @body] + end + end + end + + alias to_a finish # For *response + + def each(&callback) + @body.each(&callback) + @buffered = true + + if @block + @writer = callback + @block.call(self) + end + end + + # Append a chunk to the response body. + # + # Converts the response into a buffered response if it wasn't already. + # + # NOTE: Do not mix #write and direct #body access! + # + def write(chunk) + buffered_body! + + @writer.call(chunk.to_s) + end + + def close + @body.close if @body.respond_to?(:close) + end + + def empty? + @block == nil && @body.empty? + end + + def has_header?(key) + raise ArgumentError unless key.is_a?(String) + @headers.key?(key) + end + def get_header(key) + raise ArgumentError unless key.is_a?(String) + @headers[key] + end + def set_header(key, value) + raise ArgumentError unless key.is_a?(String) + @headers[key] = value + end + def delete_header(key) + raise ArgumentError unless key.is_a?(String) + @headers.delete key + end + + alias :[] :get_header + alias :[]= :set_header + + module Helpers + def invalid?; status < 100 || status >= 600; end + + def informational?; status >= 100 && status < 200; end + def successful?; status >= 200 && status < 300; end + def redirection?; status >= 300 && status < 400; end + def client_error?; status >= 400 && status < 500; end + def server_error?; status >= 500 && status < 600; end + + def ok?; status == 200; end + def created?; status == 201; end + def accepted?; status == 202; end + def no_content?; status == 204; end + def moved_permanently?; status == 301; end + def bad_request?; status == 400; end + def unauthorized?; status == 401; end + def forbidden?; status == 403; end + def not_found?; status == 404; end + def method_not_allowed?; status == 405; end + def not_acceptable?; status == 406; end + def request_timeout?; status == 408; end + def precondition_failed?; status == 412; end + def unprocessable?; status == 422; end + + def redirect?; [301, 302, 303, 307, 308].include? status; end + + def include?(header) + has_header?(header) + end + + # Add a header that may have multiple values. + # + # Example: + # response.add_header 'vary', 'accept-encoding' + # response.add_header 'vary', 'cookie' + # + # assert_equal 'accept-encoding,cookie', response.get_header('vary') + # + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 + def add_header(key, value) + raise ArgumentError unless key.is_a?(String) + + if value.nil? + return get_header(key) + end + + value = value.to_s + + if header = get_header(key) + if header.is_a?(Array) + header << value + else + set_header(key, [header, value]) + end + else + set_header(key, value) + end + end + + # Get the content type of the response. + def content_type + get_header CONTENT_TYPE + end + + # Set the content type of the response. + def content_type=(content_type) + set_header CONTENT_TYPE, content_type + end + + def media_type + MediaType.type(content_type) + end + + def media_type_params + MediaType.params(content_type) + end + + def content_length + cl = get_header CONTENT_LENGTH + cl ? cl.to_i : cl + end + + def location + get_header "location" + end + + def location=(location) + set_header "location", location + end + + def set_cookie(key, value) + add_header SET_COOKIE, Utils.set_cookie_header(key, value) + end + + def delete_cookie(key, value = {}) + set_header(SET_COOKIE, + Utils.delete_set_cookie_header!( + get_header(SET_COOKIE), key, value + ) + ) + end + + def set_cookie_header + get_header SET_COOKIE + end + + def set_cookie_header=(value) + set_header SET_COOKIE, value + end + + def cache_control + get_header CACHE_CONTROL + end + + def cache_control=(value) + set_header CACHE_CONTROL, value + end + + # Specifies that the content shouldn't be cached. Overrides `cache!` if already called. + def do_not_cache! + set_header CACHE_CONTROL, "no-cache, must-revalidate" + set_header EXPIRES, Time.now.httpdate + end + + # Specify that the content should be cached. + # @param duration [Integer] The number of seconds until the cache expires. + # @option directive [String] The cache control directive, one of "public", "private", "no-cache" or "no-store". + def cache!(duration = 3600, directive: "public") + unless headers[CACHE_CONTROL] =~ /no-cache/ + set_header CACHE_CONTROL, "#{directive}, max-age=#{duration}" + set_header EXPIRES, (Time.now + duration).httpdate + end + end + + def etag + get_header ETAG + end + + def etag=(value) + set_header ETAG, value + end + + protected + + # Convert the body of this response into an internally buffered Array if possible. + # + # `@buffered` is a ternary value which indicates whether the body is buffered. It can be: + # * `nil` - The body has not been buffered yet. + # * `true` - The body is buffered as an Array instance. + # * `false` - The body is not buffered and cannot be buffered. + # + # @return [Boolean] whether the body is buffered as an Array instance. + def buffered_body! + if @buffered.nil? + if @body.is_a?(Array) + # The user supplied body was an array: + @body = @body.compact + @length = @body.sum{|part| part.bytesize} + @buffered = true + elsif @body.respond_to?(:each) + # Turn the user supplied body into a buffered array: + body = @body + @body = Array.new + @buffered = true + + body.each do |part| + @writer.call(part.to_s) + end + + body.close if body.respond_to?(:close) + else + # We don't know how to buffer the user-supplied body: + @buffered = false + end + end + + return @buffered + end + + def append(chunk) + chunk = chunk.dup unless chunk.frozen? + @body << chunk + + if @length + @length += chunk.bytesize + elsif @buffered + @length = chunk.bytesize + end + + return chunk + end + end + + include Helpers + + class Raw + include Helpers + + attr_reader :headers + attr_accessor :status + + def initialize(status, headers) + @status = status + @headers = headers + end + + def has_header?(key) + headers.key?(key) + end + + def get_header(key) + headers[key] + end + + def set_header(key, value) + headers[key] = value + end + + def delete_header(key) + headers.delete(key) + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/rewindable_input.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/rewindable_input.rb new file mode 100644 index 0000000..730c6a2 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/rewindable_input.rb @@ -0,0 +1,113 @@ +# -*- encoding: binary -*- +# frozen_string_literal: true + +require 'tempfile' + +require_relative 'constants' + +module Rack + # Class which can make any IO object rewindable, including non-rewindable ones. It does + # this by buffering the data into a tempfile, which is rewindable. + # + # Don't forget to call #close when you're done. This frees up temporary resources that + # RewindableInput uses, though it does *not* close the original IO object. + class RewindableInput + # Makes rack.input rewindable, for compatibility with applications and middleware + # designed for earlier versions of Rack (where rack.input was required to be + # rewindable). + class Middleware + def initialize(app) + @app = app + end + + def call(env) + env[RACK_INPUT] = RewindableInput.new(env[RACK_INPUT]) + @app.call(env) + end + end + + def initialize(io) + @io = io + @rewindable_io = nil + @unlinked = false + end + + def gets + make_rewindable unless @rewindable_io + @rewindable_io.gets + end + + def read(*args) + make_rewindable unless @rewindable_io + @rewindable_io.read(*args) + end + + def each(&block) + make_rewindable unless @rewindable_io + @rewindable_io.each(&block) + end + + def rewind + make_rewindable unless @rewindable_io + @rewindable_io.rewind + end + + def size + make_rewindable unless @rewindable_io + @rewindable_io.size + end + + # Closes this RewindableInput object without closing the originally + # wrapped IO object. Cleans up any temporary resources that this RewindableInput + # has created. + # + # This method may be called multiple times. It does nothing on subsequent calls. + def close + if @rewindable_io + if @unlinked + @rewindable_io.close + else + @rewindable_io.close! + end + @rewindable_io = nil + end + end + + private + + def make_rewindable + # Buffer all data into a tempfile. Since this tempfile is private to this + # RewindableInput object, we chmod it so that nobody else can read or write + # it. On POSIX filesystems we also unlink the file so that it doesn't + # even have a file entry on the filesystem anymore, though we can still + # access it because we have the file handle open. + @rewindable_io = Tempfile.new('RackRewindableInput') + @rewindable_io.chmod(0000) + @rewindable_io.set_encoding(Encoding::BINARY) + @rewindable_io.binmode + # :nocov: + if filesystem_has_posix_semantics? + raise 'Unlink failed. IO closed.' if @rewindable_io.closed? + @unlinked = true + end + # :nocov: + + buffer = "".dup + while @io.read(1024 * 4, buffer) + entire_buffer_written_out = false + while !entire_buffer_written_out + written = @rewindable_io.write(buffer) + entire_buffer_written_out = written == buffer.bytesize + if !entire_buffer_written_out + buffer.slice!(0 .. written - 1) + end + end + end + @rewindable_io.rewind + end + + def filesystem_has_posix_semantics? + RUBY_PLATFORM !~ /(mswin|mingw|cygwin|java)/ + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/runtime.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/runtime.rb new file mode 100644 index 0000000..a1bfa69 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/runtime.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative 'utils' + +module Rack + # Sets an "x-runtime" response header, indicating the response + # time of the request, in seconds + # + # You can put it right before the application to see the processing + # time, or before all the other middlewares to include time for them, + # too. + class Runtime + FORMAT_STRING = "%0.6f" # :nodoc: + HEADER_NAME = "x-runtime" # :nodoc: + + def initialize(app, name = nil) + @app = app + @header_name = HEADER_NAME + @header_name += "-#{name.to_s.downcase}" if name + end + + def call(env) + start_time = Utils.clock_time + _, headers, _ = response = @app.call(env) + + request_time = Utils.clock_time - start_time + + unless headers.key?(@header_name) + headers[@header_name] = FORMAT_STRING % request_time + end + + response + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/sendfile.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/sendfile.rb new file mode 100644 index 0000000..9c6e0c4 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/sendfile.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'utils' +require_relative 'body_proxy' + +module Rack + + # = Sendfile + # + # The Sendfile middleware intercepts responses whose body is being + # served from a file and replaces it with a server specific x-sendfile + # header. The web server is then responsible for writing the file contents + # to the client. This can dramatically reduce the amount of work required + # by the Ruby backend and takes advantage of the web server's optimized file + # delivery code. + # + # In order to take advantage of this middleware, the response body must + # respond to +to_path+ and the request must include an x-sendfile-type + # header. Rack::Files and other components implement +to_path+ so there's + # rarely anything you need to do in your application. The x-sendfile-type + # header is typically set in your web servers configuration. The following + # sections attempt to document + # + # === Nginx + # + # Nginx supports the x-accel-redirect header. This is similar to x-sendfile + # but requires parts of the filesystem to be mapped into a private URL + # hierarchy. + # + # The following example shows the Nginx configuration required to create + # a private "/files/" area, enable x-accel-redirect, and pass the special + # x-sendfile-type and x-accel-mapping headers to the backend: + # + # location ~ /files/(.*) { + # internal; + # alias /var/www/$1; + # } + # + # location / { + # proxy_redirect off; + # + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # + # proxy_set_header x-sendfile-type x-accel-redirect; + # proxy_set_header x-accel-mapping /var/www/=/files/; + # + # proxy_pass http://127.0.0.1:8080/; + # } + # + # Note that the x-sendfile-type header must be set exactly as shown above. + # The x-accel-mapping header should specify the location on the file system, + # followed by an equals sign (=), followed name of the private URL pattern + # that it maps to. The middleware performs a simple substitution on the + # resulting path. + # + # See Also: https://www.nginx.com/resources/wiki/start/topics/examples/xsendfile + # + # === lighttpd + # + # Lighttpd has supported some variation of the x-sendfile header for some + # time, although only recent version support x-sendfile in a reverse proxy + # configuration. + # + # $HTTP["host"] == "example.com" { + # proxy-core.protocol = "http" + # proxy-core.balancer = "round-robin" + # proxy-core.backends = ( + # "127.0.0.1:8000", + # "127.0.0.1:8001", + # ... + # ) + # + # proxy-core.allow-x-sendfile = "enable" + # proxy-core.rewrite-request = ( + # "x-sendfile-type" => (".*" => "x-sendfile") + # ) + # } + # + # See Also: http://redmine.lighttpd.net/wiki/lighttpd/Docs:ModProxyCore + # + # === Apache + # + # x-sendfile is supported under Apache 2.x using a separate module: + # + # https://tn123.org/mod_xsendfile/ + # + # Once the module is compiled and installed, you can enable it using + # XSendFile config directive: + # + # RequestHeader Set x-sendfile-type x-sendfile + # ProxyPassReverse / http://localhost:8001/ + # XSendFile on + # + # === Mapping parameter + # + # The third parameter allows for an overriding extension of the + # x-accel-mapping header. Mappings should be provided in tuples of internal to + # external. The internal values may contain regular expression syntax, they + # will be matched with case indifference. + + class Sendfile + def initialize(app, variation = nil, mappings = []) + @app = app + @variation = variation + @mappings = mappings.map do |internal, external| + [/^#{internal}/i, external] + end + end + + def call(env) + _, headers, body = response = @app.call(env) + + if body.respond_to?(:to_path) + case type = variation(env) + when /x-accel-redirect/i + path = ::File.expand_path(body.to_path) + if url = map_accel_path(env, path) + headers[CONTENT_LENGTH] = '0' + # '?' must be percent-encoded because it is not query string but a part of path + headers[type.downcase] = ::Rack::Utils.escape_path(url).gsub('?', '%3F') + obody = body + response[2] = Rack::BodyProxy.new([]) do + obody.close if obody.respond_to?(:close) + end + else + env[RACK_ERRORS].puts "x-accel-mapping header missing" + end + when /x-sendfile|x-lighttpd-send-file/i + path = ::File.expand_path(body.to_path) + headers[CONTENT_LENGTH] = '0' + headers[type.downcase] = path + obody = body + response[2] = Rack::BodyProxy.new([]) do + obody.close if obody.respond_to?(:close) + end + when '', nil + else + env[RACK_ERRORS].puts "Unknown x-sendfile variation: '#{type}'.\n" + end + end + response + end + + private + def variation(env) + @variation || + env['sendfile.type'] || + env['HTTP_X_SENDFILE_TYPE'] + end + + def map_accel_path(env, path) + if mapping = @mappings.find { |internal, _| internal =~ path } + path.sub(*mapping) + elsif mapping = env['HTTP_X_ACCEL_MAPPING'] + mapping.split(',').map(&:strip).each do |m| + internal, external = m.split('=', 2).map(&:strip) + new_path = path.sub(/^#{internal}/i, external) + return new_path unless path == new_path + end + path + end + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_exceptions.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_exceptions.rb new file mode 100644 index 0000000..9172a4d --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_exceptions.rb @@ -0,0 +1,407 @@ +# frozen_string_literal: true + +require 'erb' + +require_relative 'constants' +require_relative 'utils' +require_relative 'request' + +module Rack + # Rack::ShowExceptions catches all exceptions raised from the app it + # wraps. It shows a useful backtrace with the sourcefile and + # clickable context, the whole Rack environment and the request + # data. + # + # Be careful when you use this on public-facing sites as it could + # reveal information helpful to attackers. + + class ShowExceptions + CONTEXT = 7 + + Frame = Struct.new(:filename, :lineno, :function, + :pre_context_lineno, :pre_context, + :context_line, :post_context_lineno, + :post_context) + + def initialize(app) + @app = app + end + + def call(env) + @app.call(env) + rescue StandardError, LoadError, SyntaxError => e + exception_string = dump_exception(e) + + env[RACK_ERRORS].puts(exception_string) + env[RACK_ERRORS].flush + + if accepts_html?(env) + content_type = "text/html" + body = pretty(env, e) + else + content_type = "text/plain" + body = exception_string + end + + [ + 500, + { + CONTENT_TYPE => content_type, + CONTENT_LENGTH => body.bytesize.to_s, + }, + [body], + ] + end + + def prefers_plaintext?(env) + !accepts_html?(env) + end + + def accepts_html?(env) + Rack::Utils.best_q_match(env["HTTP_ACCEPT"], %w[text/html]) + end + private :accepts_html? + + def dump_exception(exception) + if exception.respond_to?(:detailed_message) + message = exception.detailed_message(highlight: false) + else + message = exception.message + end + string = "#{exception.class}: #{message}\n".dup + string << exception.backtrace.map { |l| "\t#{l}" }.join("\n") + string + end + + def pretty(env, exception) + req = Rack::Request.new(env) + + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. + path = path = (req.script_name + req.path_info).squeeze("/") + + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. + frames = frames = exception.backtrace.map { |line| + frame = Frame.new + if line =~ /(.*?):(\d+)(:in `(.*)')?/ + frame.filename = $1 + frame.lineno = $2.to_i + frame.function = $4 + + begin + lineno = frame.lineno - 1 + lines = ::File.readlines(frame.filename) + frame.pre_context_lineno = [lineno - CONTEXT, 0].max + frame.pre_context = lines[frame.pre_context_lineno...lineno] + frame.context_line = lines[lineno].chomp + frame.post_context_lineno = [lineno + CONTEXT, lines.size].min + frame.post_context = lines[lineno + 1..frame.post_context_lineno] + rescue + end + + frame + else + nil + end + }.compact + + template.result(binding) + end + + def template + TEMPLATE + end + + def h(obj) # :nodoc: + case obj + when String + Utils.escape_html(obj) + else + Utils.escape_html(obj.inspect) + end + end + + # :stopdoc: + + # adapted from Django + # Copyright (c) Django Software Foundation and individual contributors. + # Used under the modified BSD license: + # http://www.xfree86.org/3.3.6/COPYRIGHT2.html#5 + TEMPLATE = ERB.new(<<-'HTML'.gsub(/^ /, '')) + + + + + + <%=h exception.class %> at <%=h path %> + + + + + +
+

<%=h exception.class %> at <%=h path %>

+ <% if exception.respond_to?(:detailed_message) %> +

<%=h exception.detailed_message(highlight: false) %>

+ <% else %> +

<%=h exception.message %>

+ <% end %> + + + + + + +
Ruby + <% if first = frames.first %> + <%=h first.filename %>: in <%=h first.function %>, line <%=h frames.first.lineno %> + <% else %> + unknown location + <% end %> +
Web<%=h req.request_method %> <%=h(req.host + path)%>
+ +

Jump to:

+ +
+ +
+

Traceback (innermost first)

+
    + <% frames.each { |frame| %> +
  • + <%=h frame.filename %>: in <%=h frame.function %> + + <% if frame.context_line %> +
    + <% if frame.pre_context %> +
      + <% frame.pre_context.each { |line| %> +
    1. <%=h line %>
    2. + <% } %> +
    + <% end %> + +
      +
    1. <%=h frame.context_line %>...
    + + <% if frame.post_context %> +
      + <% frame.post_context.each { |line| %> +
    1. <%=h line %>
    2. + <% } %> +
    + <% end %> +
    + <% end %> +
  • + <% } %> +
+
+ +
+

Request information

+ +

GET

+ <% if req.GET and not req.GET.empty? %> + + + + + + + + + <% req.GET.sort_by { |k, v| k.to_s }.each { |key, val| %> + + + + + <% } %> + +
VariableValue
<%=h key %>
<%=h val.inspect %>
+ <% else %> +

No GET data.

+ <% end %> + +

POST

+ <% if ((req.POST and not req.POST.empty?) rescue (no_post_data = "Invalid POST data"; nil)) %> + + + + + + + + + <% req.POST.sort_by { |k, v| k.to_s }.each { |key, val| %> + + + + + <% } %> + +
VariableValue
<%=h key %>
<%=h val.inspect %>
+ <% else %> +

<%= no_post_data || "No POST data" %>.

+ <% end %> + + + + <% unless req.cookies.empty? %> + + + + + + + + + <% req.cookies.each { |key, val| %> + + + + + <% } %> + +
VariableValue
<%=h key %>
<%=h val.inspect %>
+ <% else %> +

No cookie data.

+ <% end %> + +

Rack ENV

+ + + + + + + + + <% env.sort_by { |k, v| k.to_s }.each { |key, val| %> + + + + + <% } %> + +
VariableValue
<%=h key %>
<%=h val.inspect %>
+ +
+ +
+

+ You're seeing this error because you use Rack::ShowExceptions. +

+
+ + + + HTML + + # :startdoc: + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_status.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_status.rb new file mode 100644 index 0000000..b6f75a0 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_status.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'erb' + +require_relative 'constants' +require_relative 'utils' +require_relative 'request' +require_relative 'body_proxy' + +module Rack + # Rack::ShowStatus catches all empty responses and replaces them + # with a site explaining the error. + # + # Additional details can be put into rack.showstatus.detail + # and will be shown as HTML. If such details exist, the error page + # is always rendered, even if the reply was not empty. + + class ShowStatus + def initialize(app) + @app = app + @template = ERB.new(TEMPLATE) + end + + def call(env) + status, headers, body = response = @app.call(env) + empty = headers[CONTENT_LENGTH].to_i <= 0 + + # client or server error, or explicit message + if (status.to_i >= 400 && empty) || env[RACK_SHOWSTATUS_DETAIL] + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. + req = req = Rack::Request.new(env) + + message = Rack::Utils::HTTP_STATUS_CODES[status.to_i] || status.to_s + + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. + detail = detail = env[RACK_SHOWSTATUS_DETAIL] || message + + html = @template.result(binding) + size = html.bytesize + + response[2] = Rack::BodyProxy.new([html]) do + body.close if body.respond_to?(:close) + end + + headers[CONTENT_TYPE] = "text/html" + headers[CONTENT_LENGTH] = size.to_s + end + + response + end + + def h(obj) # :nodoc: + case obj + when String + Utils.escape_html(obj) + else + Utils.escape_html(obj.inspect) + end + end + + # :stopdoc: + +# adapted from Django +# Copyright (c) Django Software Foundation and individual contributors. +# Used under the modified BSD license: +# http://www.xfree86.org/3.3.6/COPYRIGHT2.html#5 +TEMPLATE = <<'HTML' + + + + + <%=h message %> at <%=h req.script_name + req.path_info %> + + + + +
+

<%=h message %> (<%= status.to_i %>)

+ + + + + + + + + +
Request Method:<%=h req.request_method %>
Request URL:<%=h req.url %>
+
+
+

<%=h detail %>

+
+ +
+

+ You're seeing this error because you use Rack::ShowStatus. +

+
+ + +HTML + + # :startdoc: + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/static.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/static.rb new file mode 100644 index 0000000..5c9b676 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/static.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'files' +require_relative 'mime' + +module Rack + + # The Rack::Static middleware intercepts requests for static files + # (javascript files, images, stylesheets, etc) based on the url prefixes or + # route mappings passed in the options, and serves them using a Rack::Files + # object. This allows a Rack stack to serve both static and dynamic content. + # + # Examples: + # + # Serve all requests beginning with /media from the "media" folder located + # in the current directory (ie media/*): + # + # use Rack::Static, :urls => ["/media"] + # + # Same as previous, but instead of returning 404 for missing files under + # /media, call the next middleware: + # + # use Rack::Static, :urls => ["/media"], :cascade => true + # + # Serve all requests beginning with /css or /images from the folder "public" + # in the current directory (ie public/css/* and public/images/*): + # + # use Rack::Static, :urls => ["/css", "/images"], :root => "public" + # + # Serve all requests to / with "index.html" from the folder "public" in the + # current directory (ie public/index.html): + # + # use Rack::Static, :urls => {"/" => 'index.html'}, :root => 'public' + # + # Serve all requests normally from the folder "public" in the current + # directory but uses index.html as default route for "/" + # + # use Rack::Static, :urls => [""], :root => 'public', :index => + # 'index.html' + # + # Set custom HTTP Headers for based on rules: + # + # use Rack::Static, :root => 'public', + # :header_rules => [ + # [rule, {header_field => content, header_field => content}], + # [rule, {header_field => content}] + # ] + # + # Rules for selecting files: + # + # 1) All files + # Provide the :all symbol + # :all => Matches every file + # + # 2) Folders + # Provide the folder path as a string + # '/folder' or '/folder/subfolder' => Matches files in a certain folder + # + # 3) File Extensions + # Provide the file extensions as an array + # ['css', 'js'] or %w(css js) => Matches files ending in .css or .js + # + # 4) Regular Expressions / Regexp + # Provide a regular expression + # %r{\.(?:css|js)\z} => Matches files ending in .css or .js + # /\.(?:eot|ttf|otf|woff2|woff|svg)\z/ => Matches files ending in + # the most common web font formats (.eot, .ttf, .otf, .woff2, .woff, .svg) + # Note: This Regexp is available as a shortcut, using the :fonts rule + # + # 5) Font Shortcut + # Provide the :fonts symbol + # :fonts => Uses the Regexp rule stated right above to match all common web font endings + # + # Rule Ordering: + # Rules are applied in the order that they are provided. + # List rather general rules above special ones. + # + # Complete example use case including HTTP header rules: + # + # use Rack::Static, :root => 'public', + # :header_rules => [ + # # Cache all static files in public caches (e.g. Rack::Cache) + # # as well as in the browser + # [:all, {'cache-control' => 'public, max-age=31536000'}], + # + # # Provide web fonts with cross-origin access-control-headers + # # Firefox requires this when serving assets using a Content Delivery Network + # [:fonts, {'access-control-allow-origin' => '*'}] + # ] + # + class Static + def initialize(app, options = {}) + @app = app + @urls = options[:urls] || ["/favicon.ico"] + @index = options[:index] + @gzip = options[:gzip] + @cascade = options[:cascade] + root = options[:root] || Dir.pwd + + # HTTP Headers + @header_rules = options[:header_rules] || [] + # Allow for legacy :cache_control option while prioritizing global header_rules setting + @header_rules.unshift([:all, { CACHE_CONTROL => options[:cache_control] }]) if options[:cache_control] + + @file_server = Rack::Files.new(root) + end + + def add_index_root?(path) + @index && route_file(path) && path.end_with?('/') + end + + def overwrite_file_path(path) + @urls.kind_of?(Hash) && @urls.key?(path) || add_index_root?(path) + end + + def route_file(path) + @urls.kind_of?(Array) && @urls.any? { |url| path.index(url) == 0 } + end + + def can_serve(path) + route_file(path) || overwrite_file_path(path) + end + + def call(env) + path = env[PATH_INFO] + + if can_serve(path) + if overwrite_file_path(path) + env[PATH_INFO] = (add_index_root?(path) ? path + @index : @urls[path]) + elsif @gzip && env['HTTP_ACCEPT_ENCODING'] && /\bgzip\b/.match?(env['HTTP_ACCEPT_ENCODING']) + path = env[PATH_INFO] + env[PATH_INFO] += '.gz' + response = @file_server.call(env) + env[PATH_INFO] = path + + if response[0] == 404 + response = nil + elsif response[0] == 304 + # Do nothing, leave headers as is + else + response[1][CONTENT_TYPE] = Mime.mime_type(::File.extname(path), 'text/plain') + response[1]['content-encoding'] = 'gzip' + end + end + + path = env[PATH_INFO] + response ||= @file_server.call(env) + + if @cascade && response[0] == 404 + return @app.call(env) + end + + headers = response[1] + applicable_rules(path).each do |rule, new_headers| + new_headers.each { |field, content| headers[field] = content } + end + + response + else + @app.call(env) + end + end + + # Convert HTTP header rules to HTTP headers + def applicable_rules(path) + @header_rules.find_all do |rule, new_headers| + case rule + when :all + true + when :fonts + /\.(?:ttf|otf|eot|woff2|woff|svg)\z/.match?(path) + when String + path = ::Rack::Utils.unescape(path) + path.start_with?(rule) || path.start_with?('/' + rule) + when Array + /\.(#{rule.join('|')})\z/.match?(path) + when Regexp + rule.match?(path) + else + false + end + end + end + + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb new file mode 100644 index 0000000..0b94cc7 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative 'constants' +require_relative 'body_proxy' + +module Rack + + # Middleware tracks and cleans Tempfiles created throughout a request (i.e. Rack::Multipart) + # Ideas/strategy based on posts by Eric Wong and Charles Oliver Nutter + # https://groups.google.com/forum/#!searchin/rack-devel/temp/rack-devel/brK8eh-MByw/sw61oJJCGRMJ + class TempfileReaper + def initialize(app) + @app = app + end + + def call(env) + env[RACK_TEMPFILES] ||= [] + + begin + _, _, body = response = @app.call(env) + rescue Exception + env[RACK_TEMPFILES]&.each(&:close!) + raise + end + + response[2] = BodyProxy.new(body) do + env[RACK_TEMPFILES]&.each(&:close!) + end + + response + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/urlmap.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/urlmap.rb new file mode 100644 index 0000000..99c4d82 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/urlmap.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'set' + +require_relative 'constants' + +module Rack + # Rack::URLMap takes a hash mapping urls or paths to apps, and + # dispatches accordingly. Support for HTTP/1.1 host names exists if + # the URLs start with http:// or https://. + # + # URLMap modifies the SCRIPT_NAME and PATH_INFO such that the part + # relevant for dispatch is in the SCRIPT_NAME, and the rest in the + # PATH_INFO. This should be taken care of when you need to + # reconstruct the URL in order to create links. + # + # URLMap dispatches in such a way that the longest paths are tried + # first, since they are most specific. + + class URLMap + def initialize(map = {}) + remap(map) + end + + def remap(map) + @known_hosts = Set[] + @mapping = map.map { |location, app| + if location =~ %r{\Ahttps?://(.*?)(/.*)} + host, location = $1, $2 + @known_hosts << host + else + host = nil + end + + unless location[0] == ?/ + raise ArgumentError, "paths need to start with /" + end + + location = location.chomp('/') + match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", Regexp::NOENCODING) + + [host, location, match, app] + }.sort_by do |(host, location, _, _)| + [host ? -host.size : Float::INFINITY, -location.size] + end + end + + def call(env) + path = env[PATH_INFO] + script_name = env[SCRIPT_NAME] + http_host = env[HTTP_HOST] + server_name = env[SERVER_NAME] + server_port = env[SERVER_PORT] + + is_same_server = casecmp?(http_host, server_name) || + casecmp?(http_host, "#{server_name}:#{server_port}") + + is_host_known = @known_hosts.include? http_host + + @mapping.each do |host, location, match, app| + unless casecmp?(http_host, host) \ + || casecmp?(server_name, host) \ + || (!host && is_same_server) \ + || (!host && !is_host_known) # If we don't have a matching host, default to the first without a specified host + next + end + + next unless m = match.match(path.to_s) + + rest = m[1] + next unless !rest || rest.empty? || rest[0] == ?/ + + env[SCRIPT_NAME] = (script_name + location) + env[PATH_INFO] = rest + + return app.call(env) + end + + [404, { CONTENT_TYPE => "text/plain", "x-cascade" => "pass" }, ["Not Found: #{path}"]] + + ensure + env[PATH_INFO] = path + env[SCRIPT_NAME] = script_name + end + + private + def casecmp?(v1, v2) + # if both nil, or they're the same string + return true if v1 == v2 + + # if either are nil... (but they're not the same) + return false if v1.nil? + return false if v2.nil? + + # otherwise check they're not case-insensitive the same + v1.casecmp(v2).zero? + end + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/utils.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/utils.rb new file mode 100644 index 0000000..bbf4969 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/utils.rb @@ -0,0 +1,631 @@ +# -*- encoding: binary -*- +# frozen_string_literal: true + +require 'uri' +require 'fileutils' +require 'set' +require 'tempfile' +require 'time' +require 'erb' + +require_relative 'query_parser' +require_relative 'mime' +require_relative 'headers' +require_relative 'constants' + +module Rack + # Rack::Utils contains a grab-bag of useful methods for writing web + # applications adopted from all kinds of Ruby libraries. + + module Utils + ParameterTypeError = QueryParser::ParameterTypeError + InvalidParameterError = QueryParser::InvalidParameterError + ParamsTooDeepError = QueryParser::ParamsTooDeepError + DEFAULT_SEP = QueryParser::DEFAULT_SEP + COMMON_SEP = QueryParser::COMMON_SEP + KeySpaceConstrainedParams = QueryParser::Params + URI_PARSER = defined?(::URI::RFC2396_PARSER) ? ::URI::RFC2396_PARSER : ::URI::DEFAULT_PARSER + + class << self + attr_accessor :default_query_parser + end + # The default amount of nesting to allowed by hash parameters. + # This helps prevent a rogue client from triggering a possible stack overflow + # when parsing parameters. + self.default_query_parser = QueryParser.make_default(32) + + module_function + + # URI escapes. (CGI style space to +) + def escape(s) + URI.encode_www_form_component(s) + end + + # Like URI escaping, but with %20 instead of +. Strictly speaking this is + # true URI escaping. + def escape_path(s) + URI_PARSER.escape s + end + + # Unescapes the **path** component of a URI. See Rack::Utils.unescape for + # unescaping query parameters or form components. + def unescape_path(s) + URI_PARSER.unescape s + end + + # Unescapes a URI escaped string with +encoding+. +encoding+ will be the + # target encoding of the string returned, and it defaults to UTF-8 + def unescape(s, encoding = Encoding::UTF_8) + URI.decode_www_form_component(s, encoding) + end + + class << self + attr_accessor :multipart_total_part_limit + + attr_accessor :multipart_file_limit + + # multipart_part_limit is the original name of multipart_file_limit, but + # the limit only counts parts with filenames. + alias multipart_part_limit multipart_file_limit + alias multipart_part_limit= multipart_file_limit= + end + + # The maximum number of file parts a request can contain. Accepting too + # many parts can lead to the server running out of file handles. + # Set to `0` for no limit. + self.multipart_file_limit = (ENV['RACK_MULTIPART_PART_LIMIT'] || ENV['RACK_MULTIPART_FILE_LIMIT'] || 128).to_i + + # The maximum total number of parts a request can contain. Accepting too + # many can lead to excessive memory use and parsing time. + self.multipart_total_part_limit = (ENV['RACK_MULTIPART_TOTAL_PART_LIMIT'] || 4096).to_i + + def self.param_depth_limit + default_query_parser.param_depth_limit + end + + def self.param_depth_limit=(v) + self.default_query_parser = self.default_query_parser.new_depth_limit(v) + end + + if defined?(Process::CLOCK_MONOTONIC) + def clock_time + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + else + # :nocov: + def clock_time + Time.now.to_f + end + # :nocov: + end + + def parse_query(qs, d = nil, &unescaper) + Rack::Utils.default_query_parser.parse_query(qs, d, &unescaper) + end + + def parse_nested_query(qs, d = nil) + Rack::Utils.default_query_parser.parse_nested_query(qs, d) + end + + def build_query(params) + params.map { |k, v| + if v.class == Array + build_query(v.map { |x| [k, x] }) + else + v.nil? ? escape(k) : "#{escape(k)}=#{escape(v)}" + end + }.join("&") + end + + def build_nested_query(value, prefix = nil) + case value + when Array + value.map { |v| + build_nested_query(v, "#{prefix}[]") + }.join("&") + when Hash + value.map { |k, v| + build_nested_query(v, prefix ? "#{prefix}[#{k}]" : k) + }.delete_if(&:empty?).join('&') + when nil + escape(prefix) + else + raise ArgumentError, "value must be a Hash" if prefix.nil? + "#{escape(prefix)}=#{escape(value)}" + end + end + + def q_values(q_value_header) + q_value_header.to_s.split(',').map do |part| + value, parameters = part.split(';', 2).map(&:strip) + quality = 1.0 + if parameters && (md = /\Aq=([\d.]+)/.match(parameters)) + quality = md[1].to_f + end + [value, quality] + end + end + + def forwarded_values(forwarded_header) + return nil unless forwarded_header + forwarded_header = forwarded_header.to_s.gsub("\n", ";") + + forwarded_header.split(';').each_with_object({}) do |field, values| + field.split(',').each do |pair| + pair = pair.split('=').map(&:strip).join('=') + return nil unless pair =~ /\A(by|for|host|proto)="?([^"]+)"?\Z/i + (values[$1.downcase.to_sym] ||= []) << $2 + end + end + end + module_function :forwarded_values + + # Return best accept value to use, based on the algorithm + # in RFC 2616 Section 14. If there are multiple best + # matches (same specificity and quality), the value returned + # is arbitrary. + def best_q_match(q_value_header, available_mimes) + values = q_values(q_value_header) + + matches = values.map do |req_mime, quality| + match = available_mimes.find { |am| Rack::Mime.match?(am, req_mime) } + next unless match + [match, quality] + end.compact.sort_by do |match, quality| + (match.split('/', 2).count('*') * -10) + quality + end.last + matches&.first + end + + # Introduced in ERB 4.0. ERB::Escape is an alias for ERB::Utils which + # doesn't get monkey-patched by rails + if defined?(ERB::Escape) && ERB::Escape.instance_method(:html_escape) + define_method(:escape_html, ERB::Escape.instance_method(:html_escape)) + else + require 'cgi/escape' + # Escape ampersands, brackets and quotes to their HTML/XML entities. + def escape_html(string) + CGI.escapeHTML(string.to_s) + end + end + + def select_best_encoding(available_encodings, accept_encoding) + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html + + expanded_accept_encoding = [] + + accept_encoding.each do |m, q| + preference = available_encodings.index(m) || available_encodings.size + + if m == "*" + (available_encodings - accept_encoding.map(&:first)).each do |m2| + expanded_accept_encoding << [m2, q, preference] + end + else + expanded_accept_encoding << [m, q, preference] + end + end + + encoding_candidates = expanded_accept_encoding + .sort_by { |_, q, p| [-q, p] } + .map!(&:first) + + unless encoding_candidates.include?("identity") + encoding_candidates.push("identity") + end + + expanded_accept_encoding.each do |m, q| + encoding_candidates.delete(m) if q == 0.0 + end + + (encoding_candidates & available_encodings)[0] + end + + # :call-seq: + # parse_cookies_header(value) -> hash + # + # Parse cookies from the provided header +value+ according to RFC6265. The + # syntax for cookie headers only supports semicolons. Returns a map of + # cookie +key+ to cookie +value+. + # + # parse_cookies_header('myname=myvalue; max-age=0') + # # => {"myname"=>"myvalue", "max-age"=>"0"} + # + def parse_cookies_header(value) + return {} unless value + + value.split(/; */n).each_with_object({}) do |cookie, cookies| + next if cookie.empty? + key, value = cookie.split('=', 2) + cookies[key] = (unescape(value) rescue value) unless cookies.key?(key) + end + end + + # :call-seq: + # parse_cookies(env) -> hash + # + # Parse cookies from the provided request environment using + # parse_cookies_header. Returns a map of cookie +key+ to cookie +value+. + # + # parse_cookies({'HTTP_COOKIE' => 'myname=myvalue'}) + # # => {'myname' => 'myvalue'} + # + def parse_cookies(env) + parse_cookies_header env[HTTP_COOKIE] + end + + # A valid cookie key according to RFC2616. + # A can be any US-ASCII characters, except control characters, spaces, or tabs. It also must not contain a separator character like the following: ( ) < > @ , ; : \ " / [ ] ? = { }. + VALID_COOKIE_KEY = /\A[!#$%&'*+\-\.\^_`|~0-9a-zA-Z]+\z/.freeze + private_constant :VALID_COOKIE_KEY + + private def escape_cookie_key(key) + if key =~ VALID_COOKIE_KEY + key + else + warn "Cookie key #{key.inspect} is not valid according to RFC2616; it will be escaped. This behaviour is deprecated and will be removed in a future version of Rack.", uplevel: 2 + escape(key) + end + end + + # :call-seq: + # set_cookie_header(key, value) -> encoded string + # + # Generate an encoded string using the provided +key+ and +value+ suitable + # for the +set-cookie+ header according to RFC6265. The +value+ may be an + # instance of either +String+ or +Hash+. + # + # If the cookie +value+ is an instance of +Hash+, it considers the following + # cookie attribute keys: +domain+, +max_age+, +expires+ (must be instance + # of +Time+), +secure+, +http_only+, +same_site+ and +value+. For more + # details about the interpretation of these fields, consult + # [RFC6265 Section 5.2](https://datatracker.ietf.org/doc/html/rfc6265#section-5.2). + # + # An extra cookie attribute +escape_key+ can be provided to control whether + # or not the cookie key is URL encoded. If explicitly set to +false+, the + # cookie key name will not be url encoded (escaped). The default is +true+. + # + # set_cookie_header("myname", "myvalue") + # # => "myname=myvalue" + # + # set_cookie_header("myname", {value: "myvalue", max_age: 10}) + # # => "myname=myvalue; max-age=10" + # + def set_cookie_header(key, value) + case value + when Hash + key = escape_cookie_key(key) unless value[:escape_key] == false + domain = "; domain=#{value[:domain]}" if value[:domain] + path = "; path=#{value[:path]}" if value[:path] + max_age = "; max-age=#{value[:max_age]}" if value[:max_age] + expires = "; expires=#{value[:expires].httpdate}" if value[:expires] + secure = "; secure" if value[:secure] + httponly = "; httponly" if (value.key?(:httponly) ? value[:httponly] : value[:http_only]) + same_site = + case value[:same_site] + when false, nil + nil + when :none, 'None', :None + '; samesite=none' + when :lax, 'Lax', :Lax + '; samesite=lax' + when true, :strict, 'Strict', :Strict + '; samesite=strict' + else + raise ArgumentError, "Invalid :same_site value: #{value[:same_site].inspect}" + end + partitioned = "; partitioned" if value[:partitioned] + value = value[:value] + else + key = escape_cookie_key(key) + end + + value = [value] unless Array === value + + return "#{key}=#{value.map { |v| escape v }.join('&')}#{domain}" \ + "#{path}#{max_age}#{expires}#{secure}#{httponly}#{same_site}#{partitioned}" + end + + # :call-seq: + # set_cookie_header!(headers, key, value) -> header value + # + # Append a cookie in the specified headers with the given cookie +key+ and + # +value+ using set_cookie_header. + # + # If the headers already contains a +set-cookie+ key, it will be converted + # to an +Array+ if not already, and appended to. + def set_cookie_header!(headers, key, value) + if header = headers[SET_COOKIE] + if header.is_a?(Array) + header << set_cookie_header(key, value) + else + headers[SET_COOKIE] = [header, set_cookie_header(key, value)] + end + else + headers[SET_COOKIE] = set_cookie_header(key, value) + end + end + + # :call-seq: + # delete_set_cookie_header(key, value = {}) -> encoded string + # + # Generate an encoded string based on the given +key+ and +value+ using + # set_cookie_header for the purpose of causing the specified cookie to be + # deleted. The +value+ may be an instance of +Hash+ and can include + # attributes as outlined by set_cookie_header. The encoded cookie will have + # a +max_age+ of 0 seconds, an +expires+ date in the past and an empty + # +value+. When used with the +set-cookie+ header, it will cause the client + # to *remove* any matching cookie. + # + # delete_set_cookie_header("myname") + # # => "myname=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" + # + def delete_set_cookie_header(key, value = {}) + set_cookie_header(key, value.merge(max_age: '0', expires: Time.at(0), value: '')) + end + + def delete_cookie_header!(headers, key, value = {}) + headers[SET_COOKIE] = delete_set_cookie_header!(headers[SET_COOKIE], key, value) + + return nil + end + + # :call-seq: + # delete_set_cookie_header!(header, key, value = {}) -> header value + # + # Set an expired cookie in the specified headers with the given cookie + # +key+ and +value+ using delete_set_cookie_header. This causes + # the client to immediately delete the specified cookie. + # + # delete_set_cookie_header!(nil, "mycookie") + # # => "mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" + # + # If the header is non-nil, it will be modified in place. + # + # header = [] + # delete_set_cookie_header!(header, "mycookie") + # # => ["mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"] + # header + # # => ["mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"] + # + def delete_set_cookie_header!(header, key, value = {}) + if header + header = Array(header) + header << delete_set_cookie_header(key, value) + else + header = delete_set_cookie_header(key, value) + end + + return header + end + + def rfc2822(time) + time.rfc2822 + end + + # Parses the "Range:" header, if present, into an array of Range objects. + # Returns nil if the header is missing or syntactically invalid. + # Returns an empty array if none of the ranges are satisfiable. + def byte_ranges(env, size) + get_byte_ranges env['HTTP_RANGE'], size + end + + def get_byte_ranges(http_range, size) + # See + # Ignore Range when file size is 0 to avoid a 416 error. + return nil if size.zero? + return nil unless http_range && http_range =~ /bytes=([^;]+)/ + ranges = [] + $1.split(/,\s*/).each do |range_spec| + return nil unless range_spec.include?('-') + range = range_spec.split('-') + r0, r1 = range[0], range[1] + if r0.nil? || r0.empty? + return nil if r1.nil? + # suffix-byte-range-spec, represents trailing suffix of file + r0 = size - r1.to_i + r0 = 0 if r0 < 0 + r1 = size - 1 + else + r0 = r0.to_i + if r1.nil? + r1 = size - 1 + else + r1 = r1.to_i + return nil if r1 < r0 # backwards range is syntactically invalid + r1 = size - 1 if r1 >= size + end + end + ranges << (r0..r1) if r0 <= r1 + end + + return [] if ranges.map(&:size).sum > size + + ranges + end + + # :nocov: + if defined?(OpenSSL.fixed_length_secure_compare) + # Constant time string comparison. + # + # NOTE: the values compared should be of fixed length, such as strings + # that have already been processed by HMAC. This should not be used + # on variable length plaintext strings because it could leak length info + # via timing attacks. + def secure_compare(a, b) + return false unless a.bytesize == b.bytesize + + OpenSSL.fixed_length_secure_compare(a, b) + end + # :nocov: + else + def secure_compare(a, b) + return false unless a.bytesize == b.bytesize + + l = a.unpack("C*") + + r, i = 0, -1 + b.each_byte { |v| r |= v ^ l[i += 1] } + r == 0 + end + end + + # Context allows the use of a compatible middleware at different points + # in a request handling stack. A compatible middleware must define + # #context which should take the arguments env and app. The first of which + # would be the request environment. The second of which would be the rack + # application that the request would be forwarded to. + class Context + attr_reader :for, :app + + def initialize(app_f, app_r) + raise 'running context does not respond to #context' unless app_f.respond_to? :context + @for, @app = app_f, app_r + end + + def call(env) + @for.context(env, @app) + end + + def recontext(app) + self.class.new(@for, app) + end + + def context(env, app = @app) + recontext(app).call(env) + end + end + + # Every standard HTTP code mapped to the appropriate message. + # Generated with: + # curl -s https://www.iana.org/assignments/http-status-codes/http-status-codes-1.csv \ + # | ruby -rcsv -e "puts CSV.parse(STDIN, headers: true) \ + # .reject {|v| v['Description'] == 'Unassigned' or v['Description'].include? '(' } \ + # .map {|v| %Q/#{v['Value']} => '#{v['Description']}'/ }.join(','+?\n)" + HTTP_STATUS_CODES = { + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 103 => 'Early Hints', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', + 208 => 'Already Reported', + 226 => 'IM Used', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Content Too Large', + 414 => 'URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Range Not Satisfiable', + 417 => 'Expectation Failed', + 421 => 'Misdirected Request', + 422 => 'Unprocessable Content', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Too Early', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 511 => 'Network Authentication Required' + } + + # Responses with HTTP status codes that should not have an entity body + STATUS_WITH_NO_ENTITY_BODY = Hash[((100..199).to_a << 204 << 304).product([true])] + + SYMBOL_TO_STATUS_CODE = Hash[*HTTP_STATUS_CODES.map { |code, message| + [message.downcase.gsub(/\s|-/, '_').to_sym, code] + }.flatten] + + OBSOLETE_SYMBOLS_TO_STATUS_CODES = { + payload_too_large: 413, + unprocessable_entity: 422, + bandwidth_limit_exceeded: 509, + not_extended: 510 + }.freeze + private_constant :OBSOLETE_SYMBOLS_TO_STATUS_CODES + + OBSOLETE_SYMBOL_MAPPINGS = { + payload_too_large: :content_too_large, + unprocessable_entity: :unprocessable_content + }.freeze + private_constant :OBSOLETE_SYMBOL_MAPPINGS + + def status_code(status) + if status.is_a?(Symbol) + SYMBOL_TO_STATUS_CODE.fetch(status) do + fallback_code = OBSOLETE_SYMBOLS_TO_STATUS_CODES.fetch(status) { raise ArgumentError, "Unrecognized status code #{status.inspect}" } + message = "Status code #{status.inspect} is deprecated and will be removed in a future version of Rack." + if canonical_symbol = OBSOLETE_SYMBOL_MAPPINGS[status] + # message = "#{message} Please use #{canonical_symbol.inspect} instead." + # For now, let's not emit any warning when there is a mapping. + else + warn message, uplevel: 3 + end + fallback_code + end + else + status.to_i + end + end + + PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact) + + def clean_path_info(path_info) + parts = path_info.split PATH_SEPS + + clean = [] + + parts.each do |part| + next if part.empty? || part == '.' + part == '..' ? clean.pop : clean << part + end + + clean_path = clean.join(::File::SEPARATOR) + clean_path.prepend("/") if parts.empty? || parts.first.empty? + clean_path + end + + NULL_BYTE = "\0" + + def valid_path?(path) + path.valid_encoding? && !path.include?(NULL_BYTE) + end + + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/version.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/version.rb new file mode 100644 index 0000000..5b45e76 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/version.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Copyright (C) 2007-2019 Leah Neukirchen +# +# Rack is freely distributable under the terms of an MIT-style license. +# See MIT-LICENSE or https://opensource.org/licenses/MIT. + +# The Rack main module, serving as a namespace for all core Rack +# modules and classes. +# +# All modules meant for use in your application are autoloaded here, +# so it should be enough just to require 'rack' in your code. + +module Rack + RELEASE = "3.1.8" + + # Return the Rack release as a dotted string. + def self.release + RELEASE + end +end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/rack.gemspec b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/rack.gemspec new file mode 100644 index 0000000..ed37415 --- /dev/null +++ b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/rack.gemspec @@ -0,0 +1,31 @@ +# -*- encoding: utf-8 -*- +# stub: rack 3.1.8 ruby lib + +Gem::Specification.new do |s| + s.name = "rack".freeze + s.version = "3.1.8".freeze + + s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= + s.metadata = { "bug_tracker_uri" => "https://github.com/rack/rack/issues", "changelog_uri" => "https://github.com/rack/rack/blob/main/CHANGELOG.md", "documentation_uri" => "https://rubydoc.info/github/rack/rack", "source_code_uri" => "https://github.com/rack/rack" } if s.respond_to? :metadata= + s.require_paths = ["lib".freeze] + s.authors = ["Leah Neukirchen".freeze] + s.date = "2024-10-14" + s.description = "Rack provides a minimal, modular and adaptable interface for developing\nweb applications in Ruby. By wrapping HTTP requests and responses in\nthe simplest way possible, it unifies and distills the API for web\nservers, web frameworks, and software in between (the so-called\nmiddleware) into a single method call.\n".freeze + s.email = "leah@vuxu.org".freeze + s.extra_rdoc_files = ["README.md".freeze, "CHANGELOG.md".freeze, "CONTRIBUTING.md".freeze] + s.files = ["CHANGELOG.md".freeze, "CONTRIBUTING.md".freeze, "README.md".freeze] + s.homepage = "https://github.com/rack/rack".freeze + s.licenses = ["MIT".freeze] + s.required_ruby_version = Gem::Requirement.new(">= 2.4.0".freeze) + s.rubygems_version = "3.5.11".freeze + s.summary = "A modular Ruby webserver interface.".freeze + + s.installed_by_version = "3.5.22".freeze + + s.specification_version = 4 + + s.add_development_dependency(%q.freeze, ["~> 5.0".freeze]) + s.add_development_dependency(%q.freeze, [">= 0".freeze]) + s.add_development_dependency(%q.freeze, [">= 0".freeze]) + s.add_development_dependency(%q.freeze, [">= 0".freeze]) +end diff --git a/spikes/pdm/README.md b/spikes/pdm/README.md new file mode 100644 index 0000000..69f9732 --- /dev/null +++ b/spikes/pdm/README.md @@ -0,0 +1,83 @@ +# pdm vendor spike fixtures + +Tool versions (exact, recorded from the spike run on 2026-06-10): + +- **pdm 2.27.0** (installed via `python3 -m venv /tmp/pdmv && /tmp/pdmv/bin/pip install pdm`) +- **Python 3.14.3** (Homebrew, macOS arm64); `pdm init -n` pinned `requires-python = "==3.14.*"` +- Lockfile format: `lock_version = "4.5.0"`, default `strategy = ["inherit_metadata"]` + +Patched artifact: `six-1.16.0-py2.py3-none-any.whl` rebuilt from the PyPI wheel with a +marker comment appended to `six.py` +(`# socket-patch 9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f: patched six.py`) and the +dist-info `RECORD` rewritten (urlsafe-b64 sha256 + size). Vendored at +`.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl`. + +- patched wheel sha256: `7015f5a42a0f83fd1b7d3ca0ba10d8777a207c19b6ffebb39e2e1c03af6a281b` +- original PyPI wheel sha256: `8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254` + +Every `after/pdm.lock` in these pairs was generated by pdm itself (never hand-written). +Committable files only (`pyproject.toml`, `pdm.lock`, `.socket/`); no `.venv`/`.pdm-python`. + +## Pairs + +### direct-registry/ +- `before/`: fresh `pdm init -n` project (no dependency, no lock yet). +- `after/`: state after `pdm add six==1.16.0`. Shows the **registry lock shape**: no + `path` key; `files = [{file = "...", hash = "sha256:..."}]` listing the PyPI wheel + **and** sdist hashes. + +### direct-path-wheel/ +- `before/`: registry state (`pdm add six==1.16.0`). +- `after/`: state after + `pdm add ./.socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl`. + This is the D1 oracle shape: + - pyproject dependency becomes `file:///${PROJECT_ROOT}/.socket/...whl` + (NOTE: pdm **keeps** the old `"six==1.16.0"` entry alongside — both coexist). + - lock `[[package]]` gains `path = "./.socket/vendor/pypi//...whl"` + (project-relative, no `${PROJECT_ROOT}` in the lock) and `files = []` shrinks to a + single `{file = "", hash = "sha256:"}` entry. + +### transitive-registry/ +- `before/`: fresh `pdm init -n` project. +- `after/`: state after `pdm add python-dateutil==2.9.0.post0`. six is transitive and + pdm resolved it to **1.17.0** from the registry (latest matching `six>=1.5`). + +### transitive-path/ +- `before/`: registry state (`pdm add python-dateutil==2.9.0.post0`, six 1.17.0 transitive). +- `after/`: pyproject gains + ```toml + [tool.pdm.resolution.overrides] + six = "file:///${PROJECT_ROOT}/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + ``` + then `pdm lock` regenerates the lock: six pinned to 1.16.0 with the exact same + `path = "./..."` + single-hash `files` shape as direct-path-wheel. `pdm sync` in a + fresh venv with a cold `PDM_CACHE_DIR` installs the patched wheel (marker verified), + and `python-dateutil` imports it fine. + +## Key behavioral findings (pdm 2.27.0) + +- **Lock-only splice works**: with pyproject left as a plain registry requirement + (`six==1.16.0`), hand-editing only the six `[[package]]` entry to the path shape + (content_hash untouched) passes `pdm sync`, `pdm install --check`, and + `pdm install --frozen-lockfile` (all exit 0), installs the marker, and leaves the + lock byte-identical. Same result for the transitive case and for a + `--static-urls` lock (a `{file=..., hash=...}` entry is accepted inside a + `strategy = [..., "static_urls"]` lock). +- **Hash check is fail-closed for local path wheels**: tampering the vendored wheel + makes `pdm sync` fail with `unearth.errors.HashMismatchError` + (Expected/Actual sha256 printed), exit 1, package not installed. +- **Silent unpatch (lock-only splice)**: `pdm lock` rewrites the entry back to the + registry shape with no warning; `pdm update six` re-locks AND reinstalls the + unpatched wheel. Plain `pdm install` preserves (resolves from lockfile; lock + byte-stable because content_hash matches). +- **Override survives everything**: with `[tool.pdm.resolution.overrides]` in + pyproject, `pdm lock` and `pdm update six` keep the path entry byte-identically — + this closes the silent-unpatch hole and is itself the tool-generated route to a + transitive path lock. Note overrides change `content_hash` (they feed resolution). +- **Flags on this version**: `pdm install --check`, `pdm install --frozen-lockfile` + (alias `--no-lock`). `pdm lock --static-urls` works (deprecated alias of + `-S static_urls`). `pdm lock --no-hashes` does **not** exist; `-S no_hashes` → + `[PdmUsageError]: Invalid strategy flag: hashes, supported: cross_platform, + static_urls, direct_minimal_versions, inherit_metadata`. +- `content_hash` covers requirements only (identical between default and + static-urls locks of the same pyproject), so lock-entry splices don't invalidate it. diff --git a/spikes/pdm/direct-path-wheel/after/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/pdm/direct-path-wheel/after/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..2e6888ddc907d33d32239a84ea11080d3cb0b9b3 GIT binary patch literal 38806 zcmc(I&2J+~c4zhW>=>YZoP*)Dfov8AMb${Gl2jkvLTfxMiB-jJeKn=(>YD0yR+3Cf z*&;Jp%%ncrn6qJEv6saj_TbB6PHVuI{U`hv41AvB!Y3c?%l>{ZA~GV$Bq^y1SfZra37i#zJ~+4q0^n}7MI`#-yLhyVT(iD`7XvRYq#Qh!+QMQOGY z#mC8moz_-!@36U*46jDf$!X@*x=Y^L!_|i?YY*2Rde6f6$6ye}-a(j!qcn*tm4k3J zh|)AlVlPU)({L1auDp{`5NBa;**hMEp_d$c-P2%n5-xjL;sx=QHxy(q>106^NAZal zcwJ-($Vzq!GHG(0T?C^Lz&$TW)1(^(py&0H?syQ!S&%Wmxn9u!a5a}Fpjj7+FFnfaoO<*>VLr{up*MrV#u%>GzWh7Ok7ay@=&aH!77Q zdB-t2PetUS0NHw~k)!zdG^&(QfP=z47*+ zdyTy}-q)?Y?Pag|-9fu~c0VJFVT;5r`h` zd(2RiYBhoP+}mxow_bvB<5_E`b@XQ0d)_+Q1I^0w{kGTe4jS#F*4C??M%z1h)jrri zY$E$@klkzTJ#QnI=5BNEsE%Bb<~6^?i+A|4v9lvtHC}=Hw(z&LfAFT=dhzned%3@} z-9+NECOB(6+i7}|6$-Vr(`fB3d)tlO#tU-S_V&SWTfj7TuU|HqR6%|X{I_+~+TUX_ zw)Xdq+IU??DceUm>g(2FbJ=UOTZa_L^Y%W-Q=Gs6_10bmczaDng`yU&bBzFm0(*r* z=e%q;8#|x{_3RaZ>y?9okE#6OUmtz?CwK1f-(Su0F}uy9#&+YVu^VPVFUW$GZ{ZDK zn;YI*eYLU|48jc$&Z&|oD=$?!91jM;=xW0|xI#USy)`k{by)FW2#3&#`cZb}jWhm5 z1*^PF2I0yuI03oSEE}d94<4Mr1B^R$U_9t(S2ldWyc%PW9Bq_1IQ3;E90XB+1NN^R z;BSV)Lp>RtRCZJ)-|%2?m4kke9Ver~hWBdk>%INg5OyC9EIN)tklzl^!+tWP3-b=) zW5%hsvEe=TR=k5z(i@9QdVmj|eptZ6SGd#xJnxCO6T~NDl!};I5_np>A^ds)z6Ea` z_fV%BaD`zEPXLmdStzFftBXsB{lk{m7!F79B|W}F0G|F_KskcP>k=S$#jYTbz7usu z!H7z8rIn+-%5~Zf-;X2s?-f<@4e!^RYxSqg-e;TZXvglWhgI{iR=;1V)aza}pcPm% zhz2M1^fU_ly|f-B4~AF6=)pPdpq`$eR6Nhy$k<$E7K0#E_5&1_E|$O#1TyFY3~wGR z^5E*ARhnTo3eKZ+rK@NdBNEeNmVb@X$KBJ-L6D+ZEoER1g()_UB)6?{Rz_jagF{A3 znz{u_r9_>ecM?AM>vL5hH(|?YDogpHuv}6QV}Xx*=|p1<@wAb(GzQzqyi`sTic#$7 zMsx+%wQ_=ZBA%(GS=IYNr=N5lDCwFI$8zgfttUBFDu>V~+StHUuyR+^7puLhdtM9e zHEKcBL%XgV&=nf!Sn6|Hfj{ulL6V?lMg2u(8Hp4SK^!AoLTyXoSF#I~Owx6w)=Eev zE|Cu>2^v;}4HqLgI8f4n-J}=ddytW3;|%)ez~L=XCDQUO!P z$rv0%_M;HgCtA;c{P%zUhZwCU|NX_R))TQnbLaJG81`+uxmtf%c@f6pD9G4|br^;E z7liR@59{ks>T64tHtcVu1;;rag)rO=FB^@+%29B#;SI0WR$}O71);e~S+^+@TzhaAR<77Z{f@hp zyXb^`ysihxNu&y+_$ZwZ&_jF-McX#d^e=AH{Qx;w-N)&GfR>5bi?R6)yC9%b(g|TN zU~wme>SG1@i2V>-s_XVdgo%&W8M*6ytnLVeqdO$3m+)a75>T^U67(5v-6a7b_Famd z5_So0*(m`%w_C#Q%m;N#K+p7I;9RTKY92Sj?~XjvY})p6G>*{^XWZs?D1cb09FNfF z_Wk2=27l@M2qEeGVXtY@NAKQO@0E&9m&QIx`l(5X5Xfb2LV_+e-LnBmuX5PzYKX|U z;`@R0LXg|0SDhMa@xC;1qtp5Lbrop3WmkBrvxE{r8n4puc-)u51BT6NFiJyD!(*^Q z1t93Kv#*ZM+?zFI4bJL@BtwC>4}b7BH_^j5c(XqH^Ey92D*e8(_5nHKal8`U*SQ92a zDPmo^H%H)FLAqRzkd9#oeU|CH=q?<-~1 zLH{DSLRgA2cgWI`w_36!B$TlK#D(pz6$LWgu!acG62h_*6B_QM1{TJ%vR z5(+SoMENDhH4y>&V&?te$_L<2p`R_;EJZrrceO;C32>Y|_|9ln%qL&+mRe z33}*1TAfXPg2nG4(1UXjIS0L-k3K++-V0-wNmol79&fKYltgmeTY?2=>PPjGl%he7q+8^RQ-(GDSBf%Y-HLg-kw_2@#iQN3HlgIYaIB zeE|Eutx8iS&!7qsEPFPr=-&9Gk3nh-N3)9+oF`E)_0Z_akh_fY1}Eqvd7Z0*!QVyZ z1N3G^AE1R^ZFdw6(RkUcDHYhMVI3x*yq@nA!WL53yaoy?gX1MtZwSAhbD?#qWRNRx zm*Y&k%ATw;A#A~$p~Yn{>0=@y4lg!o`i^SQfelABhp1wG31!i?AXbnw<+fWaK7U99 zWeS|ci_V1K`B9q;U(HYS3@hcg@FLH_R@@Rm6vHW~F(guQ%A^Hn>W8Wb;z(I3(D;&Y zZnxB4Zo7PODCvqj$8yhu{+Ja46+p~DW1ts7N_nA-fs<49ZP^o43!=G})Lh%5VXbY| zc&Zm^J?HD(DTY$ALu-MOMKji)=~r#r#?iTA>_tX|POg+d3emi+YEqcgz$5L+_p3kT zvQcoq0wAB1hheD7H})S@_Na?t&u5xcp48%N!3i0~6hh!}N7#kKNHN{j@5zYM2xC)l zrje>+P*ss6d9|KW>O{ljSG!qVqZvqjQx7m(VO?sUN+-K>Dr27U0#(hfkIZ>)ofLqD z^^#Dzs#tMUZI|NACJ!J}=Gj1`hMKQPl`z8Z)82ro{5hB=s{)^5Hk2~dYG8AKX|e)N zk!=d+C8ajG%q~z4QBoL}9Ruf7p&70WY#?v|PcxmF5=I^n8b72Ebvr~U!0nk300Wnt zb~*^MQx$`t1tZZ``&%q*SiK#S1`#wu0l!r?+zBDAy^&_hhkaSxR zG{q|P08&5XtdEQ-HxJeyu3`2XGcKs=9Kn>h4fBbasj?Uw+lVS&FKpfkY{2lz?{X&MXZU?<^ApN z9Mv+wRP?0{l6muG?>;)EebP>y4C(evI$fW6#*DSmGyhKK`PQ=fE3aCwdcVqX(Qjom z=`h4#t-z4U#^FRe)tlvH^PpbK^H>&Zn)H0|7lw)4!@Wa*mWdPF9__d$=x=TIspATyebJj zM?r*95#rm^%n7TAQm%Z*z@|zfW5r#JGO+Fjh`vcC zml+jm%Ib7(Zdk}de2prAsv#C$5Wb;lLo--sltwYvb#%iT@RmJ^4tB+BIE5mYiY)$L z*3p@6cLY#(TBY|$hVo*&9XZ37snE=fKP?szvh^=Y{bA5O!;J5Q;XBM4Cr=t}HJrfd z5Ud7LBjR*UJ)>&rO=*(awG;1kh_;opLF{p%tvH2D(LTVG;krj7uESyN!y+dRhpTD4 zyl{HNalQV?1DBW)YaTp$tcP4k++|P~@-#@cUo3ya!GCRkn*pr%Jc<2ZviHu zxFf>w5@Rd{V(P^qCl#!sgB3LF2wcjd4buy7f)c4vtt=d*D%9RJbxTSE>2lygf&{kn za1jhV5Y{WhGSWAiN5*e|pm4BQ=8VG8ri&m5C72HTB@yD?x76Kw)WX^DZE4p zlk(3?YDjbvA5*&+y&+^i5YCs*80qjP^1;IBr@6F_F@+jV5iC&0A{*wKL%OdsXh-pI zoRO;UYu-?GCKw$B7Z?!GaOzj{!7ZpYYZmA*{oD%c{yKnJ1#g+Hr|qY$gJ$~es@er92&r~UBKe?Lw# zK_5n#>mhrR&Ik2rMyc|820-^2wQvRo=6pumSlmj|?sFH5LD3Fz4d^`79rPv)s(HR9 zs^ZTe+qY|=TOChm&8-O*Xs#jr6fLMw8i0Rh$Pk|pmcTTXEQ(GBRa_?aSkPz!(tUMl zE010)_&>L#Ww2PYdmNn%Ib$b|bW7e3)O$HXu3Py5UdFdv)tJ}GgF62 z2~|H_8s*tyF-~}*vKwAui2I}|jOHvuU-^21y zO!l2}RhmFgycYpVi~gm|QGQ`cZitm-#|1zLLz1&7{4o&ys24gIl#HLT%+!2dKtUYB zmOxJ+7wf7K^#am@3^x=%KM=U~S zVj1i2$KhDOzmbPB5G?;2X&dKbDH&{3ISVr_BFOMi-nC52h#D6ISnIh=Vi`hq#-$OW zpdCg+8v$g2ZPPmd3b2nlTr6HNnou~fU_;8Y?&14tg!QKepN%cfH^;l!GIf& zj0tAQf+RVmD%;N;3*(sWwP9jE>o$adk zXXagzaiHcA%gzc`D+Mds_iWh}Fv~I-kNWUqs_BR3&42{P&8uokqdoyqRqrYuC^agL z5OPJ$T#^RDkW}xpxtvFA?zh<_{ZWEt@iy?8c-3WfQ_E;w4*H{E*9n1Cf^HzxOI-&@ zbMT#}Lfq7MyF2ZJE!|*1?V6AV1EC+VtdDDRu&u9`TwvA-gH;&!=<2~r%suIt-YgR1 z(!DC6*Vn3QB4K(Ei1W|6J;_Lp2{eJHLwjF8>at+2*qx`IqN%bnQ~$mYl@Ttr0zih> z+=T)%yqFI&bDVS5ICDP0R`;FRC52HPa7Iq1fS8~M@wl9duNeQbeAK5{=71gX-I?}E zMJV!u@-^Cjreqx|KVte%Ke0`}F+8hG~IDtDVvK$$u+bCSgh%;l_=LIzXi zc(0t6-PhA+Dn{*gMNr!L3Vp|$;*E)jpR+UHn{!U z@QuvSks1XOIo&j8!2g&;G7NYNR9C+4^Fmo8uax4xWqHT3o16Q5ot&|d`nGxJLt`j_ zcAx5LzG(@XKkSczc!)dbEgci{ex_$PL@c zFw+B;1hfshXId|+Bz9q$zzoflYrH0l8Rjc}lYZYP0k^rj z0SV4!^*)3ES9Wk^${Gq{jovUwu$P#n?%z_Aq}9g2DB1Z?I%?YI zK+TiivTVSFqgi@H3?O5wI*1+xdF%5~CN0~~!4FR50U~7_n zLY-QeIdPGvMY2x?^jURbhODkz85H_0YlA{wP#tD4c=P(82+ge!3gr4VLc>p22}5Xc zU(-(KeY|R6X-?D(`{DYPqfI1+5eNBVK6iRC?-149R8*E31~-R=YucQpa67ZJ`138# z!c7V5#>Qu9&9grXb!`)LP^PTV`L@tG%Q+uuU5zTbo0MTeTe2GgTYE6!SD0g$(*#ni z(AOup!Ci#|oUDO!UvUzG*(GA3XpL-M7B2QDkTmvi+0O7My6{ki?1?D0O#+JvO5({V z&o%MPh!F8~J9bS>+v+J?RpGCVocjmCwu-tg0gK^omBDuHvi;n8tH|4XBBp!G-aRQd zA9OL)qhd;)E&Y3#EO%7k%v#SFfu3#P*EDLGXvPGlr}Dyi6C8ckdsSdU4J;tQp>$Z+ zjB|x?bZ~Bz#=J@=?ny&W+zUB)X67MhMR2DLvf=2I&8E#$BygM{vyt;lrZXyRg)`?# zICJg#Bo1Yk>iCv?zDv3z%Hz=n3Z$6883i(sa@#&u#QVNJ5QHE3yT+!#VrI2!3~7zK zr$Tqx`{k(W{nC?CFXNmUb7YI+$>*Lh93T~UVUpO5z$a`jQPo$zap#fx#wUJL9|e*j z5sscx-}Ot5t{`~sqmbs5U-26h!;vm-$VJxXtq601Loun ztU;*+g1Wfy|SU_YHsP?`&rh4mA z)U{bpap;yv^H-`43K-3PUQ`Qa+nP zr-_pdOy**>tgen``Ay4P3`RbKb;9ZsVoVk#mMkl=c#u??9jR?^%z(REUtoJILmO(9 zR_5_Qe%bY`4;8}+-pUYj5Ma?}C3F!f=O&8UgaI9~iF$xVO8|k1)U4ZDaGJ0rt;2n&;T%j1Jf{Gdp4c{n!vT5a5k!jp)}PVIMq_oBIz8@ z!mG4~y}tMFpItb%k^4J{wpSM*fzSqFuw^aAVtfr&Mgcrv3+y5W6jT{LN5vN7D<~KL zJPI#ZEY~=cU!spisjK{I!bC!=Ld=F&-(3vhdNzW52E1*1jacU^rB?^0Qk;~?x)`P} zQi;NLHB!VEu4XGlp(~)0+!v@rVLRGTc`sImfpEpUP$9X}Tr6RIwo;gpfDkA5aYzw? zqqEpnM+c}-S~X3GH`SqaVo>7P7<lkwii&+Wm`l`ICY)vJnm{>&m6;r zeqw~vsus}~P2G(CTgCbhOS7|>_C%dpNf3}?FPj`;Q02O%cxBb7k=0~eiXn|H57j(n z#E1V&f?5g0gGO=Nf=Y?AH7>^kkV>s%>nc9hs=t0#UBXhG?wM}4`z{($*08AGH6N-# zw?Pk8SDI)1T*_iU7Y;B4RoTOw3;<2b-=#r%jn+s z-(%Q;fAGMAKs1bxvm1jUHoZyad38_^k_H<#_RmV9tp{b$243rw)JM$oBONCLyFoy1 z>$T1p$8AK@F@&IvkKabXqq=DD#`q<2;)%&z9mhL_(CgA$tr4RKLqyXfoM0$u>I|+z zO)xJ|j;$0Xu~=#5REQQs5UC_e`^It?*ps$(16{ORF{Yx?YCvN?(*|Y1^Y)?xrB3|Pjib1soof6toZ4w({ zC5>O@<#>WyXXemYlp95U`+SJKAP1wXTW4Yjl;-1x<8syvOv1=tjbWLS5TIoOxb5Ev z#sFVj%TPz8#l_)Xoymi}^5GCxApVf00M9!PBA$mKO5{T2>z!l^(p zj>*b8wylty@IPTtEL~m3}XbzCMS-Y zLFSXz*gxQn-PI6cpO&{{7t#(E`ZYu9wel5@7@S;LpGrVLX?ye)V+iv=4NE^Ry`l3C0E zwe3_dIN>1z#Eh2+%=91vfoX15k|l~oqo4*e$qoys^eInoD>!)OJsvn^bS2HMFu*Ao z`Phw&MVjjsaM&3_9o+KvxKMG9hhX&#*FBz~=}(5WD!254IaZW3>tTS%ItZ`|@B*7G zKYyy#Qt=DlDL!8L5Y~9foKvUy5p?LPoTKdsgrl;th#{2#djdfy0tBq$yCRtzKR@D_ zZgqAXx%Y5b8)sOg(}HMySv`dq+mb{6)tyclK`xL0Q#FU9W4Ln?TcXqofXt6S2UxMM z$J z&!&StUD<3?qzLi!UshMa%IN@Jo9Hw=Kgbc-EK`m_MMPNnqI40)&?*M+!Wy+QQNyT` zqJYSXax-A2=zvr*sMutc1U96Vojhwxf)91*)Vj{tSMBGfZjo(wd|^xMHly&_bDPm) zY@0ousQ!`$2Otj8mOwEy6#?YJgoG3|HtniyW;K}B1>Ca$qAy_wL3QLOUDvki#z?y< zQ~ek;WuO3f+Qh^;Wp5uca#O;4q=l!izmNla!%M9D$1$UvY?5O_O7En2yx&aEQVB_WQfJk&*E@ZZrvyj1r-OOL_i#-$V?U7w9XqQnug=E(k2FEt2Zy5M z6nO$I&i{hDhqESebi$((yuEuTWZ%701(bKJDv&F?d6;OdsTp<%pe9#UY{DqYFAAro z8DB;{ptO3n?@>OSjjx$5L4S3CD}M)ssbqiYF#>6TfD z$aE|m0F$aO63BH2l8|@Kv^(VrH{Q97D3W&yVV(%X_WdB1OXF~lDEttp;RqUBF%mFT z#FDZ>a0c(>$(fAW!{n?|9|*sM=pDrN=6tV%L(iphDg~)l-Fp9jQenI$5RGl)X&x0Rc>)>n?%77rhbl;;;Ai zp6@5Pr&p2o{d+vVT@VWNrz1vUPh?kysH-tGt=8a8AW@H2?Y|Mp02l|g=lLsJ7{bmV z-C6c9p`ob4_DOo>> z6HSL#&RB5TPh^Q<4qs>B39ZWqDBLzMtU&^O&90;XV+^RY&uj91D-ZUT2igr~Ajv)o zz*tCLrY+R+%rU1N!*W~%K%tZjpe4q(bKow}*&2md&p8DOA(&JA#qoeB=VNI&6jqVR zJbzXr2aJ_SDa9t{_UzcEXrGFu?MV)@QVefW1$~Tw=UEi&Rkd}P36$BKFy=B;U)`ly zgKsorTATOA+40KfD5M&U*}TUwhHhrcLHkTXp4or@etQ4DS!nEB%Be3?cn*!3Awna9 z74MxMvU%sN{92Em$^8oNkc$eaJ={_$Z-h|EA_s?_zGbuksTL9_S~B`oJ%Z)Nx zVf>7W6c#v-+nM2%6Zl|dUf=E4vN0WUy{}JGj&61oJd9smt~dv7+#Vu&Ky|$WK|bcv-HmAghR*WONBG z!YN#$TnYm(#jOzpOaRWpdJ?=dPGV{Asi~;RW&9Ra>XTH&Z+YdW9_QD$fS2k5-Ylk4 zag7}LjNl*b04vE&{i^Vvzf6J94RAV|kt&Cwv|Kc^wIv*{`Ac1mrl_a{vmHT29X3)j z=~m~M_Y+ZR;yKwuD&n1fFzEC!iGdZ|8}ffdF*y~mq}ueFjz|j8jF3VsbJxS+!?=YH zGx_xTvZ%sdVkGTD9YE^RKAGxAhgYpt=O{9Isp_I9**qt~%#gP~jWOAxl6ZxX7$Au7 zergvSy+7WboL?d}wS7xWMD`n#Yl5LLeDN2q@A6r8b~PXY7=-)6zyK;e>Z?>=du7`aZ51mlyJxhmyoRaq<8r+?YVoZ846pE@xOVas*S7gs@!iQcxJyAr^?)c`{i(Yn2Y0d9IcqVe}N6C!sxCOvsn-Gy~VZJ)UP! zuM{$@iy`-F`F2nQ=%~SROTY?D4m%6KIDXPu>plusR(tEg$|Jn3eBNE_tvn4M2VZnn zyX(Ej@PPsgaYKRi;Z^0}>ra37i#zJ~+4ui*z471v&nI{8@ZWzyB7jy_>#I-d4{_#y znyv7_g9q*A)_!|i;V-AB!P?^|n=kgh`eyCraBaVLwflP5JHOiLKi=tWpZ&D@q}?3s zy;=MANB?Q->A{a@$@2Q6C##=VN=fguwwil~O+z($diu@f>(}r1zw*1!&-c%Q*RQkI z7box6{Wk}##|NK(wf6Pe?wjwD$LZPf>cb~b%8BASiw1`L8;0t)-*jKJo^LgpgX7%Yf1X#C^l*Dt?HTFa|Xzj$0u_4UhUbH|YU?Mi(0W_U5` z9=^Xk?u{=W?{;E;`70dMA2!g)FFz`$tMs+J z3|)WnpMLSF)%G9%=3oBl{?G2*;lIDsHo!l>hxTXaUMpylQsoc-`smX?xpRmA{)))@ z_xn$Jpk1nJqm@Zg|M>6!{0}kE`R^|b9sK?MC;#hDXOI$AOw##m<3Ii!dEeo`YJtwn zKbt|Ppnyqwzx%g8{{6<^-MPbmKQGYxuZkXO(=>YZoP*)Dfov8AMb${Gl2jkvLTfxMiB-jJeKn=(>YD0yR+3Cf z*&;Jp%%ncrn6qJEv6saj_TbB6PHVuI{U`hv41AvB!Y3c?%l>{ZA~GV$Bq^y1SfZra37i#zJ~+4q0^n}7MI`#-yLhyVT(iD`7XvRYq#Qh!+QMQOGY z#mC8moz_-!@36U*46jDf$!X@*x=Y^L!_|i?YY*2Rde6f6$6ye}-a(j!qcn*tm4k3J zh|)AlVlPU)({L1auDp{`5NBa;**hMEp_d$c-P2%n5-xjL;sx=QHxy(q>106^NAZal zcwJ-($Vzq!GHG(0T?C^Lz&$TW)1(^(py&0H?syQ!S&%Wmxn9u!a5a}Fpjj7+FFnfaoO<*>VLr{up*MrV#u%>GzWh7Ok7ay@=&aH!77Q zdB-t2PetUS0NHw~k)!zdG^&(QfP=z47*+ zdyTy}-q)?Y?Pag|-9fu~c0VJFVT;5r`h` zd(2RiYBhoP+}mxow_bvB<5_E`b@XQ0d)_+Q1I^0w{kGTe4jS#F*4C??M%z1h)jrri zY$E$@klkzTJ#QnI=5BNEsE%Bb<~6^?i+A|4v9lvtHC}=Hw(z&LfAFT=dhzned%3@} z-9+NECOB(6+i7}|6$-Vr(`fB3d)tlO#tU-S_V&SWTfj7TuU|HqR6%|X{I_+~+TUX_ zw)Xdq+IU??DceUm>g(2FbJ=UOTZa_L^Y%W-Q=Gs6_10bmczaDng`yU&bBzFm0(*r* z=e%q;8#|x{_3RaZ>y?9okE#6OUmtz?CwK1f-(Su0F}uy9#&+YVu^VPVFUW$GZ{ZDK zn;YI*eYLU|48jc$&Z&|oD=$?!91jM;=xW0|xI#USy)`k{by)FW2#3&#`cZb}jWhm5 z1*^PF2I0yuI03oSEE}d94<4Mr1B^R$U_9t(S2ldWyc%PW9Bq_1IQ3;E90XB+1NN^R z;BSV)Lp>RtRCZJ)-|%2?m4kke9Ver~hWBdk>%INg5OyC9EIN)tklzl^!+tWP3-b=) zW5%hsvEe=TR=k5z(i@9QdVmj|eptZ6SGd#xJnxCO6T~NDl!};I5_np>A^ds)z6Ea` z_fV%BaD`zEPXLmdStzFftBXsB{lk{m7!F79B|W}F0G|F_KskcP>k=S$#jYTbz7usu z!H7z8rIn+-%5~Zf-;X2s?-f<@4e!^RYxSqg-e;TZXvglWhgI{iR=;1V)aza}pcPm% zhz2M1^fU_ly|f-B4~AF6=)pPdpq`$eR6Nhy$k<$E7K0#E_5&1_E|$O#1TyFY3~wGR z^5E*ARhnTo3eKZ+rK@NdBNEeNmVb@X$KBJ-L6D+ZEoER1g()_UB)6?{Rz_jagF{A3 znz{u_r9_>ecM?AM>vL5hH(|?YDogpHuv}6QV}Xx*=|p1<@wAb(GzQzqyi`sTic#$7 zMsx+%wQ_=ZBA%(GS=IYNr=N5lDCwFI$8zgfttUBFDu>V~+StHUuyR+^7puLhdtM9e zHEKcBL%XgV&=nf!Sn6|Hfj{ulL6V?lMg2u(8Hp4SK^!AoLTyXoSF#I~Owx6w)=Eev zE|Cu>2^v;}4HqLgI8f4n-J}=ddytW3;|%)ez~L=XCDQUO!P z$rv0%_M;HgCtA;c{P%zUhZwCU|NX_R))TQnbLaJG81`+uxmtf%c@f6pD9G4|br^;E z7liR@59{ks>T64tHtcVu1;;rag)rO=FB^@+%29B#;SI0WR$}O71);e~S+^+@TzhaAR<77Z{f@hp zyXb^`ysihxNu&y+_$ZwZ&_jF-McX#d^e=AH{Qx;w-N)&GfR>5bi?R6)yC9%b(g|TN zU~wme>SG1@i2V>-s_XVdgo%&W8M*6ytnLVeqdO$3m+)a75>T^U67(5v-6a7b_Famd z5_So0*(m`%w_C#Q%m;N#K+p7I;9RTKY92Sj?~XjvY})p6G>*{^XWZs?D1cb09FNfF z_Wk2=27l@M2qEeGVXtY@NAKQO@0E&9m&QIx`l(5X5Xfb2LV_+e-LnBmuX5PzYKX|U z;`@R0LXg|0SDhMa@xC;1qtp5Lbrop3WmkBrvxE{r8n4puc-)u51BT6NFiJyD!(*^Q z1t93Kv#*ZM+?zFI4bJL@BtwC>4}b7BH_^j5c(XqH^Ey92D*e8(_5nHKal8`U*SQ92a zDPmo^H%H)FLAqRzkd9#oeU|CH=q?<-~1 zLH{DSLRgA2cgWI`w_36!B$TlK#D(pz6$LWgu!acG62h_*6B_QM1{TJ%vR z5(+SoMENDhH4y>&V&?te$_L<2p`R_;EJZrrceO;C32>Y|_|9ln%qL&+mRe z33}*1TAfXPg2nG4(1UXjIS0L-k3K++-V0-wNmol79&fKYltgmeTY?2=>PPjGl%he7q+8^RQ-(GDSBf%Y-HLg-kw_2@#iQN3HlgIYaIB zeE|Eutx8iS&!7qsEPFPr=-&9Gk3nh-N3)9+oF`E)_0Z_akh_fY1}Eqvd7Z0*!QVyZ z1N3G^AE1R^ZFdw6(RkUcDHYhMVI3x*yq@nA!WL53yaoy?gX1MtZwSAhbD?#qWRNRx zm*Y&k%ATw;A#A~$p~Yn{>0=@y4lg!o`i^SQfelABhp1wG31!i?AXbnw<+fWaK7U99 zWeS|ci_V1K`B9q;U(HYS3@hcg@FLH_R@@Rm6vHW~F(guQ%A^Hn>W8Wb;z(I3(D;&Y zZnxB4Zo7PODCvqj$8yhu{+Ja46+p~DW1ts7N_nA-fs<49ZP^o43!=G})Lh%5VXbY| zc&Zm^J?HD(DTY$ALu-MOMKji)=~r#r#?iTA>_tX|POg+d3emi+YEqcgz$5L+_p3kT zvQcoq0wAB1hheD7H})S@_Na?t&u5xcp48%N!3i0~6hh!}N7#kKNHN{j@5zYM2xC)l zrje>+P*ss6d9|KW>O{ljSG!qVqZvqjQx7m(VO?sUN+-K>Dr27U0#(hfkIZ>)ofLqD z^^#Dzs#tMUZI|NACJ!J}=Gj1`hMKQPl`z8Z)82ro{5hB=s{)^5Hk2~dYG8AKX|e)N zk!=d+C8ajG%q~z4QBoL}9Ruf7p&70WY#?v|PcxmF5=I^n8b72Ebvr~U!0nk300Wnt zb~*^MQx$`t1tZZ``&%q*SiK#S1`#wu0l!r?+zBDAy^&_hhkaSxR zG{q|P08&5XtdEQ-HxJeyu3`2XGcKs=9Kn>h4fBbasj?Uw+lVS&FKpfkY{2lz?{X&MXZU?<^ApN z9Mv+wRP?0{l6muG?>;)EebP>y4C(evI$fW6#*DSmGyhKK`PQ=fE3aCwdcVqX(Qjom z=`h4#t-z4U#^FRe)tlvH^PpbK^H>&Zn)H0|7lw)4!@Wa*mWdPF9__d$=x=TIspATyebJj zM?r*95#rm^%n7TAQm%Z*z@|zfW5r#JGO+Fjh`vcC zml+jm%Ib7(Zdk}de2prAsv#C$5Wb;lLo--sltwYvb#%iT@RmJ^4tB+BIE5mYiY)$L z*3p@6cLY#(TBY|$hVo*&9XZ37snE=fKP?szvh^=Y{bA5O!;J5Q;XBM4Cr=t}HJrfd z5Ud7LBjR*UJ)>&rO=*(awG;1kh_;opLF{p%tvH2D(LTVG;krj7uESyN!y+dRhpTD4 zyl{HNalQV?1DBW)YaTp$tcP4k++|P~@-#@cUo3ya!GCRkn*pr%Jc<2ZviHu zxFf>w5@Rd{V(P^qCl#!sgB3LF2wcjd4buy7f)c4vtt=d*D%9RJbxTSE>2lygf&{kn za1jhV5Y{WhGSWAiN5*e|pm4BQ=8VG8ri&m5C72HTB@yD?x76Kw)WX^DZE4p zlk(3?YDjbvA5*&+y&+^i5YCs*80qjP^1;IBr@6F_F@+jV5iC&0A{*wKL%OdsXh-pI zoRO;UYu-?GCKw$B7Z?!GaOzj{!7ZpYYZmA*{oD%c{yKnJ1#g+Hr|qY$gJ$~es@er92&r~UBKe?Lw# zK_5n#>mhrR&Ik2rMyc|820-^2wQvRo=6pumSlmj|?sFH5LD3Fz4d^`79rPv)s(HR9 zs^ZTe+qY|=TOChm&8-O*Xs#jr6fLMw8i0Rh$Pk|pmcTTXEQ(GBRa_?aSkPz!(tUMl zE010)_&>L#Ww2PYdmNn%Ib$b|bW7e3)O$HXu3Py5UdFdv)tJ}GgF62 z2~|H_8s*tyF-~}*vKwAui2I}|jOHvuU-^21y zO!l2}RhmFgycYpVi~gm|QGQ`cZitm-#|1zLLz1&7{4o&ys24gIl#HLT%+!2dKtUYB zmOxJ+7wf7K^#am@3^x=%KM=U~S zVj1i2$KhDOzmbPB5G?;2X&dKbDH&{3ISVr_BFOMi-nC52h#D6ISnIh=Vi`hq#-$OW zpdCg+8v$g2ZPPmd3b2nlTr6HNnou~fU_;8Y?&14tg!QKepN%cfH^;l!GIf& zj0tAQf+RVmD%;N;3*(sWwP9jE>o$adk zXXagzaiHcA%gzc`D+Mds_iWh}Fv~I-kNWUqs_BR3&42{P&8uokqdoyqRqrYuC^agL z5OPJ$T#^RDkW}xpxtvFA?zh<_{ZWEt@iy?8c-3WfQ_E;w4*H{E*9n1Cf^HzxOI-&@ zbMT#}Lfq7MyF2ZJE!|*1?V6AV1EC+VtdDDRu&u9`TwvA-gH;&!=<2~r%suIt-YgR1 z(!DC6*Vn3QB4K(Ei1W|6J;_Lp2{eJHLwjF8>at+2*qx`IqN%bnQ~$mYl@Ttr0zih> z+=T)%yqFI&bDVS5ICDP0R`;FRC52HPa7Iq1fS8~M@wl9duNeQbeAK5{=71gX-I?}E zMJV!u@-^Cjreqx|KVte%Ke0`}F+8hG~IDtDVvK$$u+bCSgh%;l_=LIzXi zc(0t6-PhA+Dn{*gMNr!L3Vp|$;*E)jpR+UHn{!U z@QuvSks1XOIo&j8!2g&;G7NYNR9C+4^Fmo8uax4xWqHT3o16Q5ot&|d`nGxJLt`j_ zcAx5LzG(@XKkSczc!)dbEgci{ex_$PL@c zFw+B;1hfshXId|+Bz9q$zzoflYrH0l8Rjc}lYZYP0k^rj z0SV4!^*)3ES9Wk^${Gq{jovUwu$P#n?%z_Aq}9g2DB1Z?I%?YI zK+TiivTVSFqgi@H3?O5wI*1+xdF%5~CN0~~!4FR50U~7_n zLY-QeIdPGvMY2x?^jURbhODkz85H_0YlA{wP#tD4c=P(82+ge!3gr4VLc>p22}5Xc zU(-(KeY|R6X-?D(`{DYPqfI1+5eNBVK6iRC?-149R8*E31~-R=YucQpa67ZJ`138# z!c7V5#>Qu9&9grXb!`)LP^PTV`L@tG%Q+uuU5zTbo0MTeTe2GgTYE6!SD0g$(*#ni z(AOup!Ci#|oUDO!UvUzG*(GA3XpL-M7B2QDkTmvi+0O7My6{ki?1?D0O#+JvO5({V z&o%MPh!F8~J9bS>+v+J?RpGCVocjmCwu-tg0gK^omBDuHvi;n8tH|4XBBp!G-aRQd zA9OL)qhd;)E&Y3#EO%7k%v#SFfu3#P*EDLGXvPGlr}Dyi6C8ckdsSdU4J;tQp>$Z+ zjB|x?bZ~Bz#=J@=?ny&W+zUB)X67MhMR2DLvf=2I&8E#$BygM{vyt;lrZXyRg)`?# zICJg#Bo1Yk>iCv?zDv3z%Hz=n3Z$6883i(sa@#&u#QVNJ5QHE3yT+!#VrI2!3~7zK zr$Tqx`{k(W{nC?CFXNmUb7YI+$>*Lh93T~UVUpO5z$a`jQPo$zap#fx#wUJL9|e*j z5sscx-}Ot5t{`~sqmbs5U-26h!;vm-$VJxXtq601Loun ztU;*+g1Wfy|SU_YHsP?`&rh4mA z)U{bpap;yv^H-`43K-3PUQ`Qa+nP zr-_pdOy**>tgen``Ay4P3`RbKb;9ZsVoVk#mMkl=c#u??9jR?^%z(REUtoJILmO(9 zR_5_Qe%bY`4;8}+-pUYj5Ma?}C3F!f=O&8UgaI9~iF$xVO8|k1)U4ZDaGJ0rt;2n&;T%j1Jf{Gdp4c{n!vT5a5k!jp)}PVIMq_oBIz8@ z!mG4~y}tMFpItb%k^4J{wpSM*fzSqFuw^aAVtfr&Mgcrv3+y5W6jT{LN5vN7D<~KL zJPI#ZEY~=cU!spisjK{I!bC!=Ld=F&-(3vhdNzW52E1*1jacU^rB?^0Qk;~?x)`P} zQi;NLHB!VEu4XGlp(~)0+!v@rVLRGTc`sImfpEpUP$9X}Tr6RIwo;gpfDkA5aYzw? zqqEpnM+c}-S~X3GH`SqaVo>7P7<lkwii&+Wm`l`ICY)vJnm{>&m6;r zeqw~vsus}~P2G(CTgCbhOS7|>_C%dpNf3}?FPj`;Q02O%cxBb7k=0~eiXn|H57j(n z#E1V&f?5g0gGO=Nf=Y?AH7>^kkV>s%>nc9hs=t0#UBXhG?wM}4`z{($*08AGH6N-# zw?Pk8SDI)1T*_iU7Y;B4RoTOw3;<2b-=#r%jn+s z-(%Q;fAGMAKs1bxvm1jUHoZyad38_^k_H<#_RmV9tp{b$243rw)JM$oBONCLyFoy1 z>$T1p$8AK@F@&IvkKabXqq=DD#`q<2;)%&z9mhL_(CgA$tr4RKLqyXfoM0$u>I|+z zO)xJ|j;$0Xu~=#5REQQs5UC_e`^It?*ps$(16{ORF{Yx?YCvN?(*|Y1^Y)?xrB3|Pjib1soof6toZ4w({ zC5>O@<#>WyXXemYlp95U`+SJKAP1wXTW4Yjl;-1x<8syvOv1=tjbWLS5TIoOxb5Ev z#sFVj%TPz8#l_)Xoymi}^5GCxApVf00M9!PBA$mKO5{T2>z!l^(p zj>*b8wylty@IPTtEL~m3}XbzCMS-Y zLFSXz*gxQn-PI6cpO&{{7t#(E`ZYu9wel5@7@S;LpGrVLX?ye)V+iv=4NE^Ry`l3C0E zwe3_dIN>1z#Eh2+%=91vfoX15k|l~oqo4*e$qoys^eInoD>!)OJsvn^bS2HMFu*Ao z`Phw&MVjjsaM&3_9o+KvxKMG9hhX&#*FBz~=}(5WD!254IaZW3>tTS%ItZ`|@B*7G zKYyy#Qt=DlDL!8L5Y~9foKvUy5p?LPoTKdsgrl;th#{2#djdfy0tBq$yCRtzKR@D_ zZgqAXx%Y5b8)sOg(}HMySv`dq+mb{6)tyclK`xL0Q#FU9W4Ln?TcXqofXt6S2UxMM z$J z&!&StUD<3?qzLi!UshMa%IN@Jo9Hw=Kgbc-EK`m_MMPNnqI40)&?*M+!Wy+QQNyT` zqJYSXax-A2=zvr*sMutc1U96Vojhwxf)91*)Vj{tSMBGfZjo(wd|^xMHly&_bDPm) zY@0ousQ!`$2Otj8mOwEy6#?YJgoG3|HtniyW;K}B1>Ca$qAy_wL3QLOUDvki#z?y< zQ~ek;WuO3f+Qh^;Wp5uca#O;4q=l!izmNla!%M9D$1$UvY?5O_O7En2yx&aEQVB_WQfJk&*E@ZZrvyj1r-OOL_i#-$V?U7w9XqQnug=E(k2FEt2Zy5M z6nO$I&i{hDhqESebi$((yuEuTWZ%701(bKJDv&F?d6;OdsTp<%pe9#UY{DqYFAAro z8DB;{ptO3n?@>OSjjx$5L4S3CD}M)ssbqiYF#>6TfD z$aE|m0F$aO63BH2l8|@Kv^(VrH{Q97D3W&yVV(%X_WdB1OXF~lDEttp;RqUBF%mFT z#FDZ>a0c(>$(fAW!{n?|9|*sM=pDrN=6tV%L(iphDg~)l-Fp9jQenI$5RGl)X&x0Rc>)>n?%77rhbl;;;Ai zp6@5Pr&p2o{d+vVT@VWNrz1vUPh?kysH-tGt=8a8AW@H2?Y|Mp02l|g=lLsJ7{bmV z-C6c9p`ob4_DOo>> z6HSL#&RB5TPh^Q<4qs>B39ZWqDBLzMtU&^O&90;XV+^RY&uj91D-ZUT2igr~Ajv)o zz*tCLrY+R+%rU1N!*W~%K%tZjpe4q(bKow}*&2md&p8DOA(&JA#qoeB=VNI&6jqVR zJbzXr2aJ_SDa9t{_UzcEXrGFu?MV)@QVefW1$~Tw=UEi&Rkd}P36$BKFy=B;U)`ly zgKsorTATOA+40KfD5M&U*}TUwhHhrcLHkTXp4or@etQ4DS!nEB%Be3?cn*!3Awna9 z74MxMvU%sN{92Em$^8oNkc$eaJ={_$Z-h|EA_s?_zGbuksTL9_S~B`oJ%Z)Nx zVf>7W6c#v-+nM2%6Zl|dUf=E4vN0WUy{}JGj&61oJd9smt~dv7+#Vu&Ky|$WK|bcv-HmAghR*WONBG z!YN#$TnYm(#jOzpOaRWpdJ?=dPGV{Asi~;RW&9Ra>XTH&Z+YdW9_QD$fS2k5-Ylk4 zag7}LjNl*b04vE&{i^Vvzf6J94RAV|kt&Cwv|Kc^wIv*{`Ac1mrl_a{vmHT29X3)j z=~m~M_Y+ZR;yKwuD&n1fFzEC!iGdZ|8}ffdF*y~mq}ueFjz|j8jF3VsbJxS+!?=YH zGx_xTvZ%sdVkGTD9YE^RKAGxAhgYpt=O{9Isp_I9**qt~%#gP~jWOAxl6ZxX7$Au7 zergvSy+7WboL?d}wS7xWMD`n#Yl5LLeDN2q@A6r8b~PXY7=-)6zyK;e>Z?>=du7`aZ51mlyJxhmyoRaq<8r+?YVoZ846pE@xOVas*S7gs@!iQcxJyAr^?)c`{i(Yn2Y0d9IcqVe}N6C!sxCOvsn-Gy~VZJ)UP! zuM{$@iy`-F`F2nQ=%~SROTY?D4m%6KIDXPu>plusR(tEg$|Jn3eBNE_tvn4M2VZnn zyX(Ej@PPsgaYKRi;Z^0}>ra37i#zJ~+4ui*z471v&nI{8@ZWzyB7jy_>#I-d4{_#y znyv7_g9q*A)_!|i;V-AB!P?^|n=kgh`eyCraBaVLwflP5JHOiLKi=tWpZ&D@q}?3s zy;=MANB?Q->A{a@$@2Q6C##=VN=fguwwil~O+z($diu@f>(}r1zw*1!&-c%Q*RQkI z7box6{Wk}##|NK(wf6Pe?wjwD$LZPf>cb~b%8BASiw1`L8;0t)-*jKJo^LgpgX7%Yf1X#C^l*Dt?HTFa|Xzj$0u_4UhUbH|YU?Mi(0W_U5` z9=^Xk?u{=W?{;E;`70dMA2!g)FFz`$tMs+J z3|)WnpMLSF)%G9%=3oBl{?G2*;lIDsHo!l>hxTXaUMpylQsoc-`smX?xpRmA{)))@ z_xn$Jpk1nJqm@Zg|M>6!{0}kE`R^|b9sK?MC;#hDXOI$AOw##m<3Ii!dEeo`YJtwn zKbt|Ppnyqwzx%g8{{6<^-MPbmKQGYxuZkXO(=1.5", +] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[[package]] +name = "six" +version = "1.16.0" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +path = "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" +summary = "Python 2 and 3 compatibility utilities" +groups = ["default"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:7015f5a42a0f83fd1b7d3ca0ba10d8777a207c19b6ffebb39e2e1c03af6a281b"}, +] diff --git a/spikes/pdm/transitive-path/after/pyproject.toml b/spikes/pdm/transitive-path/after/pyproject.toml new file mode 100644 index 0000000..4a10e79 --- /dev/null +++ b/spikes/pdm/transitive-path/after/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "transitive-path" +version = "0.1.0" +description = "Default template for PDM package" +authors = [ + {name = "Mikola Lysenko",email = "mikolalysenko@gmail.com"}, +] +dependencies = ["python-dateutil==2.9.0.post0"] +requires-python = "==3.14.*" +readme = "README.md" +license = {text = "MIT"} + + +[tool.pdm] +distribution = false + +[tool.pdm.resolution.overrides] +six = "file:///${PROJECT_ROOT}/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" diff --git a/spikes/pdm/transitive-path/before/pdm.lock b/spikes/pdm/transitive-path/before/pdm.lock new file mode 100644 index 0000000..f9bc582 --- /dev/null +++ b/spikes/pdm/transitive-path/before/pdm.lock @@ -0,0 +1,36 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata"] +lock_version = "4.5.0" +content_hash = "sha256:b35b8b182ba39eb4b0e832cc853dd574342a4a4cb9ed441209d23928a52ae106" + +[[metadata.targets]] +requires_python = "==3.14.*" + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +summary = "Extensions to the standard Python datetime module" +groups = ["default"] +dependencies = [ + "six>=1.5", +] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[[package]] +name = "six" +version = "1.17.0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +summary = "Python 2 and 3 compatibility utilities" +groups = ["default"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] diff --git a/spikes/pdm/transitive-path/before/pyproject.toml b/spikes/pdm/transitive-path/before/pyproject.toml new file mode 100644 index 0000000..ccaa54f --- /dev/null +++ b/spikes/pdm/transitive-path/before/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "transitive-path" +version = "0.1.0" +description = "Default template for PDM package" +authors = [ + {name = "Mikola Lysenko",email = "mikolalysenko@gmail.com"}, +] +dependencies = ["python-dateutil==2.9.0.post0"] +requires-python = "==3.14.*" +readme = "README.md" +license = {text = "MIT"} + + +[tool.pdm] +distribution = false diff --git a/spikes/pdm/transitive-registry/after/pdm.lock b/spikes/pdm/transitive-registry/after/pdm.lock new file mode 100644 index 0000000..f9bc582 --- /dev/null +++ b/spikes/pdm/transitive-registry/after/pdm.lock @@ -0,0 +1,36 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata"] +lock_version = "4.5.0" +content_hash = "sha256:b35b8b182ba39eb4b0e832cc853dd574342a4a4cb9ed441209d23928a52ae106" + +[[metadata.targets]] +requires_python = "==3.14.*" + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +summary = "Extensions to the standard Python datetime module" +groups = ["default"] +dependencies = [ + "six>=1.5", +] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[[package]] +name = "six" +version = "1.17.0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +summary = "Python 2 and 3 compatibility utilities" +groups = ["default"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] diff --git a/spikes/pdm/transitive-registry/after/pyproject.toml b/spikes/pdm/transitive-registry/after/pyproject.toml new file mode 100644 index 0000000..90f6379 --- /dev/null +++ b/spikes/pdm/transitive-registry/after/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "transitive-registry" +version = "0.1.0" +description = "Default template for PDM package" +authors = [ + {name = "Mikola Lysenko",email = "mikolalysenko@gmail.com"}, +] +dependencies = ["python-dateutil==2.9.0.post0"] +requires-python = "==3.14.*" +readme = "README.md" +license = {text = "MIT"} + + +[tool.pdm] +distribution = false diff --git a/spikes/pdm/transitive-registry/before/pyproject.toml b/spikes/pdm/transitive-registry/before/pyproject.toml new file mode 100644 index 0000000..069d1fc --- /dev/null +++ b/spikes/pdm/transitive-registry/before/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "transitive-registry" +version = "0.1.0" +description = "Default template for PDM package" +authors = [ + {name = "Mikola Lysenko",email = "mikolalysenko@gmail.com"}, +] +dependencies = [] +requires-python = "==3.14.*" +readme = "README.md" +license = {text = "MIT"} + + +[tool.pdm] +distribution = false diff --git a/spikes/pipenv/README.md b/spikes/pipenv/README.md new file mode 100644 index 0000000..66a9908 --- /dev/null +++ b/spikes/pipenv/README.md @@ -0,0 +1,123 @@ +# pipenv vendor-v2 spike fixtures + +Tool versions used to generate every `Pipfile.lock` in this tree: + +- pipenv **2026.6.2** (installed via `python3 -m venv /tmp/pev && /tmp/pev/bin/pip install pipenv`) +- pip **26.0** (driving pipenv; pipenv vendors/patches its own pip internally) +- Python **3.14.3** (CPython, macOS arm64, Homebrew); virtualenv seeded pip **26.1.1** into project venvs +- Lock/install runs used `PIPENV_VENV_IN_PROJECT=1` and fresh (cold) `PIPENV_CACHE_DIR` / `PIP_CACHE_DIR` per run + +Vendored artifact convention under test: a patched wheel at +`.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl`. +The patch appends to `six.py`: + +```python +# socket-patch-marker: 9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f +SOCKET_PATCH_MARKER = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f" +``` + +with `RECORD` regenerated (correct per-file sha256/size), rebuilt with `zipfile` (deterministic +timestamps). Hashes (see `artifacts/SHA256SUMS`): + +- original registry wheel: `8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254` +- patched wheel: `573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0` +- six 1.16.0 sdist (registry, appears in pipenv-generated locks): `1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926` + +Every `Pipfile.lock` file in the pair directories was generated by `pipenv lock` itself +(never hand-written). The `Pipfile.lock.lock-only-edit` files are the ONE exception and are +clearly suffixed: they are the registry lock with ONLY the `default.six` entry hand-replaced +(JSON emitted with `json.dumps(obj, indent=4, sort_keys=True) + "\n"`, which byte-matches +pipenv's own serializer — verified by re-rendering a pipenv-written lock). + +## Pairs + +### direct-registry/ +`Pipfile` pins `six = "==1.16.0"` from PyPI; `Pipfile.lock` is the pristine `pipenv lock` +output. Baseline "before" state for the lock-only patch flow. six entry has +`hashes` (registry sdist+wheel), `index: "pypi"`, `markers`, `version: "==1.16.0"`. + +### direct-file/ +`Pipfile` declares `six = {file = "./.socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl"}`. +`Pipfile.lock` is the `pipenv lock` output for it. Shows the V1 lock shape verbatim: + +```json +"six": { + "file": "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'" +} +``` + +Key facts: key is `file` (not `path`), `./` prefix kept, NO `version` key, NO `index` key, +and — surprise — `hashes` are the **PyPI registry hashes** (sdist + original wheel), not the +local patched wheel's hash. pipenv parsed name/version from the wheel filename and fetched +index hashes. `pipenv sync` still installs the local (patched) wheel and exits 0, because +file-ref entries are installed via a separate pip invocation that never passes hashes (see +tamper note below). + +`Pipfile.lock.lock-only-edit` (hand-edited): the direct-registry lock with only `default.six` +replaced by the file-ref shape and `hashes` set to the patched wheel's sha256, `index`/`version` +dropped, `markers` kept, `_meta` untouched. From a fresh checkout + cold caches, +`pipenv sync`, `pipenv install --deploy`, and `pipenv verify` all exit 0, the venv imports the +marker, and the lock stays byte-identical. + +### transitive-registry/ +`Pipfile` pins `python-dateutil = "==2.8.2"`; `Pipfile.lock` is pristine `pipenv lock` output. +six appears FLAT in `default` (resolved to 1.17.0, with `hashes`+`markers`+`version` but no +`index` key — transitive entries omit `index`). Baseline for the transitive lock-only flow. + +### transitive-file/ +`Pipfile` keeps `python-dateutil = "==2.8.2"` and PROMOTES six to a direct file ref +(`six = {file = "./.socket/vendor/pypi//..."}`); `Pipfile.lock` is the `pipenv lock` +output: dateutil keeps its registry entry, six gets the same file-ref shape as direct-file +(again with registry hashes). `pipenv sync` installs the patched 1.16.0 wheel next to +dateutil 2.8.2 and `import dateutil.parser` works. + +`Pipfile.lock.lock-only-edit` (hand-edited): the transitive-registry lock with only +`default.six` swapped to the vendored file ref (1.17.0 registry entry -> patched 1.16.0 wheel). +Fresh `pipenv sync` / `install --deploy` / `verify` all exit 0, marker present, lock +byte-stable. + +## Behavioral findings (verbatim-tested with the versions above) + +1. **No hash verification for `file` entries (tamper NOT caught).** pipenv installs file-ref + lock entries in a separate "Editable Requirements" install phase; the temp requirements + line is just `./.socket/...whl ; ` — no `--hash`, no `--require-hashes`: + + ``` + Writing supplied requirement line to temporary file: + "./.socket/vendor/pypi/9f6b2c4e-.../six-1.16.0-py2.py3-none-any.whl ; python_version >= '2.7' ..." + Install Phase: Editable Requirements + $ .venv/bin/python .../pip install -i https://pypi.org/simple --no-input --upgrade --no-deps -r /tmp/...-reqs.txt + ``` + + A tampered wheel (sha256 `7c7da793...`, lock said `573ecfcc...`) installed cleanly: + `pipenv sync` exit 0, `pipenv install --deploy` exit 0, `pipenv verify` exit 0, and the + tampered code was importable. The `hashes` array on a `file` entry is decorative. + Raw pip CAN verify local wheels (`pip install --require-hashes -r req.txt` with + ` --hash=sha256:...` fails closed with "THESE PACKAGES DO NOT MATCH THE HASHES"); + pipenv simply never engages that path for file refs. + +2. **Relative `file` refs resolve against the Pipfile's directory, not CWD.** Verified by + running `pipenv sync` from a project subdirectory (works, no `.socket` at CWD) and from an + unrelated directory via `PIPENV_PIPFILE=/abs/path/Pipfile` with a decoy wheel planted at + `$CWD/.socket/vendor/pypi//` — the project's wheel (marker `9f6b2c4e...`) was + installed, never the decoy. + +3. **Silent unpatch matrix** (starting from the lock-only-edited state): + - `pipenv lock` — REGENERATES: six entry reverts to the registry entry; vendored ref gone. + - `pipenv update six` — REGENERATES and worse: rewrites the Pipfile pin `six = "==1.16.0"` + to `six = "*"`, then locks/installs registry six 1.17.0. + - bare `pipenv install` — lock UNCHANGED (its `_meta` hash still matches the Pipfile), the + vendored patched wheel stays installed. + +4. **Lock serializer**: byte-identical to `json.dumps(obj, indent=4, sort_keys=True) + "\n"` + (4-space indent, all keys sorted at every level, single trailing newline, default + separators). After `pipenv sync`/`install --deploy`/`verify`, the lock's sha256 is + unchanged. + +5. **`pipenv verify` / `--deploy`** only compare `_meta.hash` (derived from the Pipfile) + against the lock; editing only `default.*` keeps both green. diff --git a/spikes/pipenv/artifacts/SHA256SUMS b/spikes/pipenv/artifacts/SHA256SUMS new file mode 100644 index 0000000..a68744a --- /dev/null +++ b/spikes/pipenv/artifacts/SHA256SUMS @@ -0,0 +1,2 @@ +8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 original-six-1.16.0-py2.py3-none-any.whl +573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0 patched-six-1.16.0-py2.py3-none-any.whl diff --git a/spikes/pipenv/artifacts/original-six-1.16.0-py2.py3-none-any.whl b/spikes/pipenv/artifacts/original-six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..fd942658a2f748ba433dd8632abb910a416e184f GIT binary patch literal 11053 zcmZ{q1CS<7n61CIZQHhO+s3r*p60Y|+vc=w+dXaDcHi0ExVv}%xPL`tR8&U1Ph@1A zQ<;@@6lFj_Q2_t|B!JJUSb6^OU1ko;|mfsoad>(y0uR{n1D2zGTO(%v+8oJl7 z>d~Sj05c3Kb?TD!UGk!&aG-kCZg=`NJ^#FJ@?aisUA$Y5#ycOAcQW^*@Z@s26F;r zPW@8Ep7{;I5f8Y6;gB<7aQIblW5QrCO6kl(>xdr2jHb?>YV1(X2rfcBoN5R8;IB8i zl)gtadgPcBE?T06>>>FBR8T+deKd3$K2R)gT+jts0{Xz;AxD@mZarRe(3f$*Kq^`1 zXn|4km}D11(mTUEF3q@rf@CnD{lhvdOf_T_dlu5)tHN=NNXUpw2GyoSG}(B3fLCAD z8ii1yfj2x)Q%chpwxCe?J5E1@DvU95fYG-X`lsUogl6pnUime&)25|2L%DR-6XmqO z$q|TaJ*`^R@A)>I5M%1(gFK742Ay)X0Nx_3RiT{_V=M|)i>_vNmfKTJ-5kHpHz!Um z^nDpeCa!PSkKLC*OkDl`cSF+ds9OGPziwmz6BlpCn_iY5YN&ZnWKZl2f7IZuJx1dG zgp4CUkn#RPWa2GTQOrz?Jii}i?kDjU$kCtIWKOKym|FjfB`&n`u;^HZ_>I%sgA5Ibm4R*zJeYttL`uhFXcFoHMK-*9yjxl-^hI_o*k#naN_;E${-c5F{Za&AD zFu>J#xVdn1V+KP976uGrpw4km4B?RCJW&n!@l1$QJR!ehYKD)^HPZ|48!DkXWAbVe zD}i4pUhX_d;VJfIP$x#lUa8bkz+gC!MZp@YM22?3PcS@2@9c4*9495PmA>w;H#FnX?mpmrvgNdqm={d|1r;Nb`HO|RX}uT7rTmp|OS zR`yCkj4opb*NMVx$N>+VG5!KB>T}g1$buWRSV}(u{2%J%V&I+5As7J?^a;1A3oNf` zO3W(xjh2XQW74Ja8d%a;Eb<6`9k}1m^~P?y{3U$`P7)#IHiuScB?*aZ=?!r_NW*iN zb-@ymX{KcB5^1hru>;^Mo~U_0njK;ucLU4^Z5RU2~tg|+;Eq75T3X2VAK6R zyokOMtJmNNHq;_~B&eUB{asSlz*MQA$^w6MAqjj@K5fGe(cFe`(FXU6j{KqbIKdnPl|V&iKfB+MyW0rZcNG z6@`7;BPyI36|~nhI-Q_~h90Kbqcva@Pf5VnHaW_>cGV!LZ*jV>!7?|AzWCU%8)dkL0c9c1T1)Xt z3s=ekp45Eh886I=_CcQ{%08*F4+Q`1(H~-;;>r1hW>yN>AkL6SGI#^w!)k9b8|0TX zzBG*!#_uA2vuQ5-BG!m*EFKGav@6&Td@}z&Q$a2oXjfLp(9Q`~6)FTRm7(ZWMKGir8Q z%bzm6>_y`3rdX)Zyuq4t0zbT{liS22^O6w7LE^nR1s6DyGelBtutd`z^Ys+V)%!16 zqFD2yfF<_i41&|gO(#u}ku<4}#zOHai~+)IQ4v-m6|a(Ht=jtwsv&G0VwY(B{TP7> z{g?F{UL2E521vhgH?zW(2YM%z#iO{ZoaM@wjw%|bvMG8LuiHE)$jERSmnqX}bY55< zD{OSk(}LfH<}WJhQdXb+@F-z1C3;EF!Dd$2T6=&9`RhabEa0{sbO(kQW<~KdIC37^ z={bo9WQ8EJ6vFi!FsWcEMGOEx6F^ZPz&D1mN#j?}twdnT0XpTfwVo24Y0y1v}P6>S%_c=Qf{Bv;ySB=3b3>7sTF7H}F3&k8b@W{c1+ zhUH7Dm(OWb1zn_?rmwX%)L(Mt%}a>XXl8vaj2fLYeUx@p=mSSo038kuTqA_P@nKIe z_Z4XYi0#ETHNbRH;(Np=(D=4CqEyjXL?y#q5NPhJvf=H<^cZo%;rXa;lNciy@3qf^ zv|)_IA};XLhA%m%WY9{m<$A+z+1;TpK z=vMiIXAh#hY-QAwdZ+@T)Hs`?0^cK)a3HcGwyd^n$P7k`*u=$)l!%)-@)9c=y^X6@ z#gnyUm3}B7UVv|FUCcD&aBvaKDmBdcyea+%OS0r2dHU-UnDJ~&d<3iv4O>&jmB5Ux zmClp-KEVjMfJP@EBWn3bv+c0VHO>KQm_qfDk}$fK|~|M0JYYEh{UnX4&dh z*F565WmY8dWaG+ zr99o^xQViTI&G%FJz?Kc(qBzQ6rIc$*)y*X;H|ZFLPX)dRim}EFWR_O>jQ89os{G7 z(hwX~9MaT+Os56n>Z*oRNsh#&lCZOroBL!p7(=&ey$J~ihsT_%H9*;~OHQ#Whgp2v z6D5D7B*j2%bEo(W5Hu3PK2ayS)tSJ^yc0bfbuOy@j8aAX;0A|89@H3R?4%X!Cln{T zJpzwZ)@?5SkzX>0WMQ}8#6LK#hH}A(R8w{yMLD}=oh2dcg|Wz@ex4PxIrsUoLaG~B z#T{8gToP&@VIfST+ViTjV99Mjl=|<7%3CSwFABqZYhg_H>pewE0qrP2K-KwP_etb& z(tRHse58+H2SvxCrdz&vjyp$#ADdtrJkuAhL?Jmm6NQg zkH@!LvntC59#6ot`ppY1ujx!cdFoqaMR%YXjbr9FSrG269^tnGN)2y!Uf?f`$>xa* zHdN(h2L9!^bus=K^!&i{=Jfs{(4PiEb;?DnV3HK^^PT*9**UtX_Cehj&1`1;uz8?U zj>Sh=3n5e(c8J`O0AoU34KY;d-wGq8BiA%kkftcJ*ut|CTg#Nul~SS+eao}j2eah) zM_S2SmMrNODLd9D;*N_#*!>&yXJ|!iU_07Q$v0|b!H~#`I7CSwrInSDGU*!)P^>DF zO;kP2bDVEKbj2)4s|p>Y3L~iHb=J7Kx5Rcf9Wf1g>0is?c4u^tK<^D6X2BCie-zl@ z66hGsuXZGAY2V3Tr*1}Je~sO!^7&G9{B=uT&7uiH+CGFGW}^LFc z7r1WN$uDAbn2mc4tAjA*#+bJVMnF38vIsv+62KFE4Jew}o62YK&Y1cjb8uk}9!qV58C8wB`4c z*+!iv$y=sq3F($k*v70th97&frGYYMK<2{?h1VTa_cgCkCr@E8(M;D1SsMIGX8Pb- zgupQ|CCRQXQ%3y;g&N5!puSZ%P=~t6?C|+Y4|vzwFO03KZq70bxh5DDa$_$^xCL7yt95bmGuwZ1y9MZQ1im*-eYUSnJ-ZPv@%OZZT89IV z>$hVxbZuW9T9+2u>n}jmLPds7A?kwdT{1PMR~&Y*ON?Q1qZ2}) zKSl%Ak~>bZ-?+f7-Ld1FAUEpw27w5QUKzveFD=YEE?mRh+M@t2w2 zE(q8TL0-C9XL{8Am^eo4g*r6f6^Q*NS;?F*pMSxf`&vDYi2pdc=S34FL*6?0ROdq! z^vriD^lpP=7p;BVdWN!cA+dj?gj@i>jVYZvqQ;Q_+(OQoOSCpFsgr7&=pRub~?%;LJfpD`7Mb@#^8ak z4D^SvYbdWyUr2xWPMq&Pj18MxwXJ_;R5n}7)m4PsIi17S-;?~P%OiQi7HWl)DP)kF zFFrp-u{5Q|Q29uJ7Z8~<2v1VPv44^v-0S@CM<%oNPMu>{D4+i5<+K;!o&l#fdv zGRhPCh}qUf%l_w9t)^1=+K`V z)v0^WBho|fPxat7xczb*YQ)C?#KX{^OVpq+KW!?3Om|H*z$TxYu~8ZH)XGR*7W=$M z=sW5lR(eMC$l$LwMo?N$3SUNSB+9>G8_iF0Gv&|*pnsRJKr-WuX%66h02R2>CPK`> ziMUJWHk^;-+_6FWW^B=}MysP!1BHOQuQH&i%jYU@-?|?oW%G!+5cS3Fdp@elZ+NOkP zMe)<4jrL_4Rp_krtD9}^)CKpbyDnC0_s{zso)Qh1L*^GE@~eO_B{55$Ys=F~*ck1a zHqn_|V~J_K?Z5>sZSKmKl3_qdHCgta)c2l$6SMH6D<4Ah-X(PZR1b})ZZw8Q>=n)| zuE_#bj1w18966g4LI4QoYNFd&TNqQBFGcq?iNfmn?+$9u$&RRxm~Jlzf&YYI738F}fQHI+L~Ua%~S1)^s3I;_Oty+jc2S;!2e# zmX-(A%SH1_5k=XZ>`R>)+KAv}Qj3WE?EsrZNmBMFl;7QN>@}+6oWuJ~X$%tv^E%62 z0m8+eJM-q6m>m5sD#txJk1btccW!}HTGDV?XbI%?hDnb}0Y3Kgt*E?o?t8q-;2Cx9 zK~$x(_*=$yYt;$MqYX)0jgW$e&E0h^$ zWiTJh_Hz1ZXtD3Fos61c5%wFRwd6GqI%&YC`iV`V+q;O*iyXI?kWvKD2;OU+I6+v# z@wv+&JlO18SoIuM?z|||S1s&*!&pbk6ClsV^TpyW5|wgpiL2V;jr#gH)%0>J*vaH( z$Z{1lZJ#!(^Qwu=ikun682J#n`AXK=GF3MBS`XopE zlgtxhqenWx$NzY&^S)B1Sw>tUXOo?=@|_qu>St_Kb1aQpLqovY%{-)4)BBmV=(tn& z6oFj3GGme`x^an_N47YdW*dX=sCt?8R&Bq+#P=O*f_sAVr*KR#E9O(6J*ug+0t4?qN)A%V~ltY9H&GvkG zve(mdatMnKaHZ_3{;&-94U|TZlA7hwh^IPN41BMST%wP?YXwZLcU;P~*;U={$E+IG zHZJk(DCb$v;f^Qht`cx!6`&K*gr5h#ZDe50!B8|1y_3T~w{Co;apY=T!N*} zfs|deoGpzgi2FUln`79<(mJQHqsEi>Qvyb}+(>kE+Fu1`!jh47!J~^}8@&<28)Z6A zX7Qy^!WaCUL@a+C0Kj~s|Bb%^NkIm}M)Zsn3qr%JqQ${4X{n0v8KRp3Y^|A?4-KGW zn?n_h&aEV^ebPS&v>uisT^N7ec#F;CpN6U=qdstnFU86simcl6;jC$&H#Wo_!F+AX zkl_~0Q*s8~s5=6@Ra?uGa#V3SwF^zzuYLMn< zHiW4C9_`Q?(+2ookhY+Tuu9uQqZqygwS@~lZnMT;Wsg~KB<_q35Heqp)PsMAfiHy& zzoWrIGM(@oHIRlEX;T3OrwlK%a@f@bEg2Wcd9XVT3I5sjCk}{T4vrG649|=oTrd+r zYQ$u2!!j(P#*hc64!O@KH8fep^8P4`Syz^++3>?J%V~kVTX`BZ+cf1MveYYQFJqyOPlIErQj z8&7K=K|Q^8BqRNF$;aQRW7g?-d^D_7fqV8Lo`o zNC08#|GYB9A_h0X)U20p4xIAB-mTO`<*twSW#mnV}2vDJgEZRl%hvT$O|In!hYRk*6u zW^+6ib!M}+H9PY6M{fRUDdYnCB)Vxq&Uu3eblRud5_?o7OZ%GoJu=*+B-~Rob4F=e zM14aZ_r7MOoY5|XHC@*WP3sCmVF26XgmS5LOGIUYiM)}bIEt#A%3LG#mX=^9fYuw> zPYxu*Nk+XM329-;4G253wLp*#+QK zj+QCEf_~C2eX&S|Xn0sR*MaglJ9_DL@HJj1_8Hrvq|U(tVdGfY*g6Dj#SGe!JK}*b zycH&G#@B@$q)u&SCWFfTkM;N_V(hhQV_U zO*7*lc(7g5!~0~GSee+3^hVj-VYJ6%Vg_H~JDOcyQYJF=Hex{H z`NPvT8CyV}4 zXln44Z|c=vj_KAQ2d;W@7)$E9C8Lkl=-0P|H5+ho!R$L5G!;pjGp0CwK#ywG9g=;* z;+(;xj@nC=RJ-rcGPTN{h&dQ*hgu}Y(R|q@PI;PH?@h)g`JAs#gBGlh>A6b8>^f?c z`WOc~dX9xNnv@zWV_|jaOr78ZA=pZ$j`8ihv8LGacQzLPoTE+N)%gr`R>jabi0>rThX85 zJY}x#`z_2UOme_*D=v*5qKM3RG5w^^s zJ)p`j6*%DdsGPMlKS>w0VR;t3;iRkx=|AODx9xtbn2$ z1zK+o+1_tFm0QZkP+FAof}_{y0V6Y2l8t{`Wk7HXDFj5clL&DNHh0_iJA@H3JuU)a z@CKf=n_v3k`zG+kllZhu^E-~5pw5ibz2XVcKhMH=HQ##oW-Z)|wdVd}k6VNy*=}#j z5(H=ySc$RP14Y0jQIzD?&o$A%8`6Vlal~MjI=?Ak-L6d@Y5$$DK!M;;x^9;)5%UJ4 zyqa}aT+liO$WqhpnnD1SHsl z2xw7G=yeE|k6HT7D7qm)(Dt=sv87-UutuA=B)`pwc za-OSE$4hg0| zKc|Zx$cV&pUXQ#c<6iI2Ph#|fI*#!1k9&>;@Q-7iHCK0w`P+b`aV{}G;z*0}6zD;Z zAsm3XdDcE@a7TWIHOuWXYQua1GKC)$5o#TBLi(;vF2Ol)^FzSNkkSX?EdvAw zt@*S;TNFcga5P8=8oS%FE)dTpe?qLR1KjHy7%S~i8s4Rfnp_d=B+H(+0xBc3dqgp9 z!wRj#NW>Hu47BfVwyrkEHc>6S*F*&850DvwUS)R=blmf5vyHPE-nWA3gO`yWzmV%O z%T`x^l`xJd2ng5tD8Rz|C};_6%3=QYEeRf8kC4Qu$rk8qA#E)f_<^f9FV9jvl3WZ$ z-Mvb#9J~Xu{rn|8hb_<;gB-jqWn8Cr{VBln!;|NJheX?_K@ft|PycZlS}%I*`Kg(4 zai|9wyu~xE1Kv=q${RizBbrHR zTUbJ27E8Chl->jQFUa)L-tUwQ4K&K^Fjpj=j`g6G4ujNv($cN%fq`Xqg?11xY_k$eMSlWidubx+`(DZe^A;M^5Vo4 z>M}bnBUGq__(FOnA<3x3ON!=RWSNTD2?}+TZc)?F(dbOe(dHMp9f6623+Sj`abT*k zyY)bc9=23J7G$Qie(9o-eL?$5eu4aN!9p)zu#$lQ03Z+m0N%d|R!&S+NK{BwC|Om> zew`iB_qeu|&vweH!m$nvG0s&A+fZC(q%83=JwcusBw}$iQSb8|kBTdthhtOUM!56+ zs^hwQpMq44O1VK`4&u@V-r18nrD6y-;EOniKX-h=;E<>OYF+)6D0E2>$J_{hT-^b@ z*qTSIeO7z{z&GRp z)MBB#Qb4AeSimosGr+(YnCz}*fc%_!D`s><7;BGHt4+VrTm4&ZMsH|R3W|T~+x+8? z&__S=8Z=Go1x<9bY#)kN$}XXs6^HmnHHO0<*DYHvHU;2|S_I`Qz0wKHIybhsTXpw}b(E3}??Tbl+Ca#Os4G56(@3qdQSZDxsD+uo?DQ zTap3s!t#Jc`tuNZ^Ys%DtmbHde^!!xTn>w|B(B@=6u+?)6Q@l4xZ-b`x|&L(CQj|R z&b2c}8i^>7^Pz<6HZL7JdTq2-R|%-jfExzvXR4<+PPEvH0yy;=SOy&E@~sEfJJ1zH2v=!I;t6(rEv6l;2?5hW?xCMR4?FrFyaUY?4R=5;)rAR#a zD@bR2@yVoIF!gkN@m&U)LB20|P@C9a&;vl^6sgT3{i!YBsXgyi;JGk$uZ^6_gL1F5 zAiME9?bwr`<-ZV@k#h4cLBuFyO@6$Jhw)gyyd;zC`=7FjI2UU8pge8x4*RB~C%)zE zYx>7i#*&xNpkx_Ftv#||`b&sod;|WszbfBGvkm_G%IJU04tRnDv=q zZi^?1>_FVF4N}fv=ci)Rh|WzPT;ER4z6XTH>z6_F=A?rrZ*xLMgq1D+hUszT@wHCA z*7|~vX}DXeD!QI&E-Uixvh%4+%CDNjJUuQTr0y4PMH)U1LSLF$W}Ch8A5JctDN{%w zznR}S3t*N(zOsrnOW&If-W^Y5Pl%mCGbKaBh+xisqgKd}&E&h>fD5u?#2sLICKMGh~#Uf(%+>VIyZNs!Uq>htr&z|u-wF@RK+ zZ>%wOYv2l)cMu72Xy{?IlG|sq<6UNlkKi~dD=W!xa4tQACgKuuWtH3QkZBGRS`^1% z;{7PkJP641ZIgs#H4`xT1*9kg1dIyu?{Uk&)BB%SA|?LPji0sv_I zMMm_WHUGL7`6ub0%fkPVmP!7F^nVwLf1>{Bfd4_UQ~V3+KmG7e&OaslKb)z5OY?T4?Iy^fdBvi literal 0 HcmV?d00001 diff --git a/spikes/pipenv/artifacts/patched-six-1.16.0-py2.py3-none-any.whl b/spikes/pipenv/artifacts/patched-six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..f9c9723259e022ecc8fc283cd5138ce7c797dd02 GIT binary patch literal 38859 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 '2.7' and python_version not in '3.0, 3.1, 3.2'" + } + }, + "develop": {} +} diff --git a/spikes/pipenv/direct-file/Pipfile.lock.lock-only-edit b/spikes/pipenv/direct-file/Pipfile.lock.lock-only-edit new file mode 100644 index 0000000..394c711 --- /dev/null +++ b/spikes/pipenv/direct-file/Pipfile.lock.lock-only-edit @@ -0,0 +1,28 @@ +{ + "_meta": { + "hash": { + "sha256": "55f44fe4c8bc29094f3076c7eddb912ca00f80c016020ffa2bcbd67ccc7114a1" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.14" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "six": { + "file": "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", + "hashes": [ + "sha256:573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'" + } + }, + "develop": {} +} diff --git a/spikes/pipenv/direct-registry/Pipfile b/spikes/pipenv/direct-registry/Pipfile new file mode 100644 index 0000000..792fe33 --- /dev/null +++ b/spikes/pipenv/direct-registry/Pipfile @@ -0,0 +1,10 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +six = "==1.16.0" + +[requires] +python_version = "3.14" diff --git a/spikes/pipenv/direct-registry/Pipfile.lock b/spikes/pipenv/direct-registry/Pipfile.lock new file mode 100644 index 0000000..7b6cd8d --- /dev/null +++ b/spikes/pipenv/direct-registry/Pipfile.lock @@ -0,0 +1,30 @@ +{ + "_meta": { + "hash": { + "sha256": "55f44fe4c8bc29094f3076c7eddb912ca00f80c016020ffa2bcbd67ccc7114a1" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.14" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==1.16.0" + } + }, + "develop": {} +} diff --git a/spikes/pipenv/transitive-file/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/pipenv/transitive-file/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..f9c9723259e022ecc8fc283cd5138ce7c797dd02 GIT binary patch literal 38859 zcmc(I&vP3|vYwuIy)Od3?!obFhijnN5U3>~k(4w$isinANKnLxBrpIt9IbW?HGn4B zLja9IH~3>-yz@KwxDMa;=6A6p?1TRa{{;?5_~v)@%?C%=7k^(?Rd+Se07yy^MvR8t zRhd~?nORv`Kf0>%?q@&$e%PB1{ZZ`2Eb%ND_>n5b7rrXC3`TKHis}2_0QA5vb8V)&4&sY&8moyP z#gm{%D(e914W@naVY3IpFfe5hUBnTA9QdC`s0X=SSHrL$obj(Ol^ajHgCM$CSN(v* zyVDqu5fgjdq*svN&p?`p?;>#F))qgidWRJ{oXYDbO3 zS{13aa0JJM_YKOJ<`*o1o zueFa!cfa0L71gLT+qK=3!%9;%PMVE+s|ptOK=`HromEX{}XVSCwY1MX~HR>qo36 z1q&RM05ZTlu4+0Ix(F)K4L~Tk6I9*uqxPzmLr{aFj!i`^c)3t07K8M~xJA%DN;Q)=R zqu&b!lYTZG_2MubL^dISpUNbJFl8?eCl(;OOAkAevTSIx+jr;HXTUi0dtFX=1n4B8F=`LvoknN zHWRjxa-s2l3q^yovLzXe&cYARzgIguY75!hi?g?x{USI2_15?5cRQ-Iy{=xYL1bqN zpsx!GKRTdHBxj#uV@UAm@19;>lnRq%q_HSZI)>MQ@HyC-7TbtUw zjVWc8dQ|zYRr^ELFgx^aqTnaL;141{m#eN9iOU|H=QCF#cWMxh&ew!|mdBDXsicbK zU9#T>*Ba90dO8YvVc*Z;98%GILvmEkc;Lk_=wTT?ZW2fAI7-DIyeqG`ruk1uJ5vlDh)eYjPEQMqxfjX2*o9ef}Rok0uYC~;m4pX6MqP6%*1eXez+#R45 zh3dkC1E>7LvyzBFeE~h{_r`4pfIs=2cr7hbq@%togG`oQhYc8R) zI&*5oZ2l%b+T=(XUK8wia+}tka3E6puC%7>!36)N=Z|AmhjE{wPpVEP;UtUl7!ulm zgc71eORZ&rjD&V#JbdHtv3m95PPATaJMt!G-yf*&Y&&_OITU}Soy{QVmZ$J+)+a?{ zzt>@YXApXQf8rYQOcpHuI2`$K4kBl--|rx*Dbaf&bO?J!+VFst{%pVlU3=*8;pgZp z=p@N26UrS@5%+_?mbhlNgFt0E@Jl0a=%>_c+MDXUb)!-GW=0G;Re5J{p^|_$6N9@r zDYRqUFKk5U3Ma-a*g!Oe=mRsq2S3{6I)~m*w|jono6OjrwmC$Du`#-J<*>_I(h|!| zg8=zQ+pNYr_fs_3G$O#UW3&kNrh}NCh9o+84F^|#DK*N-N3a(QONgCpIg|+z*Jwwz z(N%bf*4ya-xYJ3S(wxaZR&7SG_|IcQSJU$WVnjrP3NPMO81y4{dJ^|aeC3@Z3Rd0Q zjKM!f;luDs>jSjo5WB^*T-^(n^|^S3yN3HqB{zV>rfy3!_Ud zgG7Nxh!y)s+MZHnLfC@0K#S`t9AGps@~?Mj`i^R%DZ1p6LsYZAhPoJA5GzQSa{Db- zpFgC5G6hcJS!cq0W*U>>tNn?dVWrITuagq8&X&LqAgbY1)EW{2oH`l7+4i9uf;dt( z3Ut0Cn%FJ1mzX?X97?9)F0tGzZ!l$pKm!mnFc=s`kWx}9YvANm=dCM2O+gITlA35+ zG;Fl3J5T*0z2|bBJJrxic4)0ovIso&XXe$|wsmx_82f=0p;Ia)5J5B_iiQ-%5qP9M zbvniG6WM6EUjvX&+QTqZ4$E7qY0VxfW0xg&PWjcQ~2`4%RSMhMmfUB_)0zQ?x7Vt%6+4pBJ6Y@x;|KN+Z& zUkV;)Li`)d_^_)sLpGV!(F59TL6qSvMU~S*V&08L4mpc&ay*#m%tSmhP>Cf8%;hM& z9vLEZd4Kb_j%pcTYWmU#$+XqFdWu2mfV3m0K)OAfPSemS_#;uGd9flaJWf-!>IGh-#dcTs=GN{-5GSR1>kU(2*#x8 z(4JDFs)Y}Z4UFh*A({(>shk##KNB}I;|z=_w1$lfU(RW#a~HMD1-p(U${B%uu%)LR zr3paa`L{Wt=fn#zE28~@WS5H4c6GDF$OEk$vyX~}*wKk=W=>c|lzino1~yj{nJeyL zmVtAW9-?oONn}Qa+PXS}n|oF=6JMhVplOJOXM}HQrlA?EGl+r_XH$EA33%&DqJtyx z8cwCir6!C2=M8k``yBx^gH{^)otab$fvD4DtaPyYfFP4S z7J^L2C<)4`rKR#PVn}@m*=YJdPJ;OD^U7Fu_nO$O29LoAv=me+ew{DfR zy2x#IiF8bjg%`5H3Pj;_f|UUoZb)rNE`px0L?Zw)c_t+2{dyB0i#FM&NOd?yU`i2= zGwl{&5{f$_^lvc70yE=O1`(tu!;3>s8d$*uE9lq}xa36}b`;LFNFvTN#I>7 z_F6JATdQ;6l6eV2CMDTxdP;N_AFE@Sy&+^W5zd$H6zT9Ll99rgr=_&cJj}4o5zJ5z zG@S&}eO*917>%bfsdhSsH#D6YMkn4iCIk$eeppBil^*4ggD=C{V6LQx-gvH788I0E z{#if*&7*hSCzVhntN*W4r%W~gOVPXe_$hjKN%2_LH1a{zIz&V)d>|qoPr{xbEv!7& zYR^TfOT>SZ0O^MXwa}fWX`|$Nh9vwI7H}|kTBI+NwF>q~FwmhWK;ti}BYfbrm&a+c z@UsXrx)}I3ogb%REa>9^YdvI7()pmX7St+fX8?4c(Fzw}V9jT;ht*}0cAtA#hebcc z<=17XhZs#*RQr5KRL!47PT%f;p6Yl;Yi>(OftDJ=Pr-@?WdQhRfeev_umq;5Ws!9< zc43bd4Q3$SSC_W-=%tMRb8DIhi{LZ!yJvo{_7qd%rvEoR73W!ZYFVr_7{0 zvIHNca28yW^58rD>2Qd}z6*5x;tOZrvjDkO|E;W1er0QJiRBe{5yxYMA>n1<|KN#! z^a~vfYR2qxn!SR8ID}mxHiMjPt5%d3@o>=Lx-HkXHO3IDU3kAm?31LF6OjD!K_oS= zgZ9y(F)rbMI4GOUd}LOC5TFFhai-=vClAvfdcmMG3@{VQbyZ?ZQu(NMRDF|4Ay8R> zRuBq?i1xFTYS|ij#Ai6r4i;XqMkuoQ2^JH zvodIBQiNRr$|FR{AGF(zmOp_vAf5RXF=}`t0_4%yeOt?tm`hkip(Ue=ERnplRdjld zpm#YClW-o#Ix&y+AE*9Qz`v1)JdlY$o){bFW3B|SSM1V{jffz_mb@F8<`K0n2C%8; zvWa;J@g)|awSwB_aS2$bj~!5<_~yOj$&e_Uyk+Q!p#7A@KHlcZI55jdVF{5DNZ*=i z%e-47wOnQ3mJ$?ONx9Q@GS6S+!Yv~h2RF3)w*F)JVd_$>_>x$$`J;7G=Ib&nbndc2 zO?-9xGV^LnP7{|=>W`$iYhRiNi_O<%c<9!{N$(;IVXabu%U!1I+7ubAaPVz^MN2t@ zxx9$skbC~D3C1XbDON|zif}NO6%k)L=0`PLev*-%g}OfjndC{@TUm0Jae~?G2)$4@ z6eEf+kM#Q+`>x42P|JwLml>;-ie>G4vFtLKd4)_T1NbrB^+WS^LIU&VMZKg^o`ERp zcO4Jp8kIr7GGj<%T1?VHSd#jEF_+7TEzMg@vNH+0VVnj&Gq0wuZfYK_o8e$G?l~cl zPB0yWekq#(X$ij5RfwDV?&z@D*fkvn)NTkFFcA8lyE(Bp2Rmx};WbvBFj<9pkDi&V z#M+aI>Fp*lZrv*adV8y=7ZTXODN4!I&HOF&F8lX%=t#aGOKrF@j< zSmuNs@!gg7LP01pD@85Mn>S`#^Z62V=u!T1PSz|$Y5{lj${Kj?xJ~XMvw*U6WY;9K z4Vc?m3z-7u>d84nu>h%M1;Ub@2Bo5m2%54L3C)eIRvM0ZhPCH71qZz~#{S!fqf1$x zAElGLZ_yTa@DRRH7zR=kPa>!LmJIkG!a$}0AAsu0*L_|oYm^mH+z+hpbmW%iKHsHe zY@~i@*~!!xDq!5Fep+r?g4P)irq(*{fjb>Nuuy~eX~lNpup^t1(^ix|nM$**IZW!u z6w&QboaP3(`6I4_r1a9K<>D0eVH7mEO)=7^JK)AiW4VVSq*UxomI(BI`WIB$F_yBK zJ}qaMd!eyo);s5x41;t(izk7R9}hyAW&`K}R z`)oI_Ris15xlSb8=P&Kya-x8=mtKmQ-)}GF3!NWgD@^|TlT6I8w49tyKHICBCTK@T z>0c9sxGd4cBmyubtaYvo2P8cHY~g?owlm>?B+Y4ZVK{KoJgk}+-$d|RPMg#87x*Sl z>O2$|Q{ORKYC8;D_&z3{A(O{JzTFCgbG5yj-3so5>{c+B+HF2x_qAK0ve<6Rl5dA^ zslD2#jD>bI@T}b$5-aU?f_(#6Jo#*vYv^&QI}K$`;+#GgS}&R;zQVGB8M-Ogcx@32 z%vZ)HgF%M`-0tcgB)FE<{}ck;*}C-BFEF)t*_r|O_ zXa{$9?54Jf6yYACtBch4uoVSg*-aiqg8paEKl3n;U!@B>z$*3Nftn<(CMHJ7&ZpAR z%RUXgPX55U0sH3mQy7uC{!bK010Qf)hm-!%`NI|D&^X0@SEH{czNeP;LTz&Qu$u}# z1D<%j81)N!6?|R!DMF&wKMNsI*Jq(*>$ewht@?9R@bgfT8h##9l=`P?t@SAcIHCMW z_@BoAuGhQp_dkus$w>Cceo8H<2JxGp=BdzvvF&aWU>{)W#nY!%JrK})1K%4>WsAps zdmu!wv)%XIi{8fu*xOH{Fs7Hr$e$Yp({j_NRh5@PT8B%Y9h~)G$F27ZV>FFR|HrGl z(a4XZ7e!uoi=++qgJNleyEK|!#NB=4X+vj8L~S7M8dF>F*{Is4ubv*pr!r%#9QvOz zu+{`;BWs;{_s|+s@m;fbBd1lGG8Tl^!0ipN42hLTSxS8f0q$H%TT=RjHnp;F;-btH zDSgVIFKP=5WOdiZpwS=L8Z`2X=CFXl`?m*8XlaAcAa`#O27bOtSVF7En(1=h=W7-z z&6$>wX577TG>OD8qLFOobC(xUcWv@zHx-R#fx#_d;f^+!Qh1ozrTEJ&FNK>E*1e4{ zrM1ldQm8wdpo21Jg-+f|=Pc)ZL}fjz=zgRO3)+>h5%6gb7W^`6?DC~agdO_!L>fQx zhxp_Vhc4kW6#JD(3Po$=gw4v${sfZFo@ zMrq9}cj2Bi%)-5pgJ)(La#jStv_UbPol1*o@)QXi)Wu@t{F3>CGM~cP^CXuWm5L)sYudXIy6fs! zlcM^Sl3K6hoEdv$i{`1rZ^CeZ)TjrO#McOX!p9}L`AQx?B+_|&VxGdP?V; zmjqoyV4=@s<0+d5#c-yJALOEF`<8_{AB0_eRHH%63n9`E^|Dn6hEj*T8X${RSX2SG zLIyokgXG1{v`mvf0k8=!kW&YNG4GrvPVB+!U-9F`4!8011TGL9VP|Z?sQ(x?>zEQs zL9@wXlmebpq>U!|=rD-OB!i<|_y7NyY&E`ya`CSM|C-fujYIn- z`dHMu$XpvH5?WVcF}%)qF@)>I2$Bu>wC|;xK05Lw4uvhtqcp{ig%?#5~aCV!un#Rupj{;PU7Q`B1*wvv8j&^(4n;M znhU_U0Zyx0MPD>^ zKl&du>))>}E+QK<)9AaUoFE{=w`_8RL6Q5GMjM+}jl`JdVn}DpLp3iL@!|iHpohXd zXmr}s$2?+V{ua060Z6Bo@#!iuOU2*4DXw9wPVdt6+XEMkC|kgF&4(&5eb93~l;#;f zH?rB!g#!#hHA65b3qaFyxeP*i13x(6r&b4vAS8G4CRuNIsHx2{FOtE*k3sJsIK*MJ zyNK@M>W?^l0d$J9X*#IR`5yGc=J)EiJ8CnHe+|7RPfdxMIHvU`CFmrMHA(jS7GnmM zuj3;x#sXI~2Yei2<8X$yP06C99@8yp1#feu%@ifCb3m4Xw7NFM9W*VrVNuLubsA1C zy$NG&iPZTWP#>SjjC|FFtwo6CFH$$0Yuo0i*iwY~Y3%dI!7P&}fBYk+9ry_TQ7B|I3XjLk0AtY zdiEgz9{HicTjQ6=iAN}NcN|ad#;D7jp^O;a8zY*Y-~>ZK)5nKqT7rFna?(a&6SIwG zNrR{{1d&FfwQntVg*_Qtx6oO;6=TX8tpT*=GjC8Sc+y{Vq10lLbyL{oji`4ZfpL;Q zm3)3qm&w; z91cYt5ft9Am)pEJehW>k3TflScO2@3={8KLcav(8Fg**+#e2HSextt%&0~dID zlf0LlGoMJrQrN+SDZtBTl7LydOa)n#u$y0!5YqW69lXzyVT>TfB*gu}3eu)ntSM;W zrI?UX3DTm7m6kAtWx%X>#bXJMRt4VaF;*II#(1?YWsF@&&KXH+2fAGQX`Fl9#RoZ@ zp65Oot!EiyyOOiDOQvDTIotAV;B#2hhAA`NDR@4M85i5pkW(e;PpQjwqZf;l#nDDGo|D0L8E72p{*S%3a?uchM` zzLR}k`QVrOUhFw-nwg+OSLGaSM<5)PjZF-Z1lThOLJ=Tf75SQEV*Jd+G2Qy?IC76u z@p&+)l0gfi^>zK^O8`bcBByy`2023lOqCpt&fv~TY>iUO0J1#(9AM493HlaeoQ?HB ziU)=ocP%!MDpXT8i-4RGu|`C8in8p*-AmeXEU^8H8fb`o6uD+SF8~~UCt26H2L_Yda=8LX?(-b6Bs217jSnev)kC!i=m;z; zFXTI_0O-0^bdD9x@YBY*g9$sVhWOwa;}nvRRSr4b^f|{~rA$giayp#i#C)6~OD!R6 z=EAcWwHXFTF=&a|X6MUE0$IgUp*-JK_w3ADR&#)U?wSk9JJy_>-l68GllC^*s>}&8 z7{NOSeTAtK?rT;)u~>2;X~*C!4#t$}7}E&@n|2?=Rx?AmqT%w{mJ3%F+i#8@Jo1T~SL z3|*Ug7$g0rEcKgvMgUKnm^r8H!*fP%T6iyv@XYlWa$v82gMI%vW|WIfa!lwoZ+zn0 zYtA&h{y4tfmM6KTsXQH{ukkzA6K{+arH@iQ-fyPYLiHKI=}l24f>?)9Z^AH2zCOsPTT-pfShB>xQGq+d}{0_I65De$`V2XOKSGSi-5ybxN zKAS|MRH&VoPnwupGb52~#j?DE0LiQwo93b{-;LVPL+e8R=5N>1R&E%;fZo(!&SN&u z2_t4=$N)?7jBS8`y$UE*dDeU^Nb1^1FLKy3rkkVnB}Q`}Q!({OIkYp5Gqp)9e8)&) zv87s#a`sCa-j01hCoaCFzhnMtsY|7;EZ~Y$W->ViX9`0y=0VN0n@p=<+0v6u03|+Q zW={q*rj=lH^*=gmofJ+Yh{vTpIUU z{1B+&2pU{5;xSajma?ID3Gbxj3`XN&64qo)$iQ@fuRsQ*r}@ywznIJDK$aKu>FMnV z6UDN6&pH}%;{JL(OWNUF@-(%iIN6s82}mB*BrXj*8?Ldj0b3{Z7Bftl;T&%{k`O6< z$FC42um5pwwfk}goG3NjAtLL_cu5c3`Nr6APl@wFD6WmhD zD@jY&^xZ=UTe>p{yH82t-cx;?Xg}F(k{w!-7F5W)r~0~*up_;x#3bv~lD3yQJ|KV@ zbTcFn_^dZ#S^V`sJn3{o-0P`HcREjae7hiI=+8%t#GWZG579JZYFh8Xn?a%;Q?>tI z>;k|zs68oP+rk)j2I(%chZ*fU#iISDxZM0`W)O4}H-Ho~0JXs-W{It%SZlzs1iYe9 zSbMUrN?5spU$ztJygeh+passb!`3YAx9{V;dD+;;jzYE;ad;1lS>C+$1c}5uYzIy) zMQK;qtPXu4hh9qdPvS(=@vSo#91TL*Vwk{}8F)hLk^zOM9SmELK;LmFDZrQm${q9C zazDsJddmathBA=k4l2M{NM7bGH07CdP6Mf$2LX#R4i>zN|2pmc#|gB!3=m(M8;lq zUx$@IS3t z(b&0^(_E&o4~^L=LMws|_0de(d{i61GqY!Mzrsh9q62D$TPo#^5IR}p;LtO-jAkJ9 zMgmPs)*c;d`d#QbS*VE+hWgxT9<=rJf>B3#MhAMzeh!D{U&@uFIT&`T%Zk{F)~H9u z1I|nj$64r#d8#+sJKc$_WG;!KRAbRCEFGdfaO;XEE@hI3Bfla+aQgOeI(L#(-(&bvF3vD_)#*|HPr zSn0F3l$B)5xv?GF04pX4Nn;d=>k)cjLXJ0@8tG%W$##Uufj3f`>vV8wv0Ra6I|to7 zPu#R^j0}0GQ<;vV-W*8xL@0As}d?;N%!d`6Om-1 z86kyO=CQ)z!?=YHEBREBY^q2vF_QkF3?O}JpDgubz^f+=Ig5-@s=nw+KAw|cX3X24 zrdVvzN#=eYA;SBqU2u&4czd$BiKmP^+x0C?sG7d3$BeP{jxY`oc|#!0jL*0kN-*S7 zE18(F5giAJ=K3hL{pywaeG}gg;(YdgMP?y5C&;-|v+o;%VtkHoM|f&1z8zU(2bZ0# zwRz>_IzxQfz#fS^GDg zQlv`;oRv=CoqqGNI`Ahxu1dEh46tj2_cY>E=+~+OnNuu9d*W?T+ZQiVhoH1Q4TJNG z^dj=dY|FT{94g@0PPPwHMkoag!hK<40F55)Rhn;lW!oBU6+0|@mmC@;ElkfJ=j+8$ zi%Rupd4&hXwUfN|-64id?A1ei;9h8R*q(N^{ow2jOk$&~3`o*x320NMW=a;G7cXm< zA!ion-JVsXf|Fvc`>_P%<3!?GI*vjJ%l$4Hg<&6JhFH2zCfjFIrNd_S^%f+|p5o(4 zXfLg%ar!g^*S;0cGbk4_1(wB-`=#V_Pz2~`!E#H$2HHmNVuQDK`IFb`tFu?#t=+7zkH;Bf8YEc|LVW}$EQDk^oaldITB$S8=K|LSLNrpC?JYAcuK;v!`g23xK-W7 zGyvvxE@D;ct*Nc&o6k44o^L%@Z~V~@-VhC>0XI8Aqbd{{{zTRq5MiU;(p)iDb&d;s zU@X|)Fu~n-oB+JQsu_%!UJ`2@Sb+gD)*)itPA>M2j(RL>AJTNA@GQoD5iX9v;v&c| zz~as9h4tJ!5y8fwD_q2&F^ZUAK_Qr^#P;Y>fq6Dd%#~jwVdhB&vRF-l1&n%wsocB9 zxkqLXFrL7KI&#GfL=K$uETG|GgbhCBa=74(e|@Rec-qD8`3uAtkfdG$LK|aZ4R zp&rlZj(oH%P{0XmkV=L-fg$UV4azqnlDXFF3pl|{O(5wrbU`Q*LSc0g4Tb9kl*p?H znL_Y0+{X~&SnqQbiBtMnBP>c6+Q{;{{BW0b(Q1aj0;?i!QGhUkE(S6%SsB1sYPdD)9EzQMI{y0Lqm&wZmHbJ=R2P?PJg^?AM#B zq8gQEyS96BSZS)pNwZOJRZ;vN$R5{@_nRoC%4IFA2p%I%RlmiHY8_M#52dKe3Ak?x zf4lX@`)2L!L0cWv5BI7_d{YHyl{be~B}Ji9yN8w9(Yo5J997ic?#R&{huN@bFcU;v} zC~Dz4(Fi~&uoF}|;bpH{IRq`J=Qsmgp7Ak-|N1{){Om6uJ>tKAvB<|9RofM;?NyH8 zPr3hR<6C$G*ve~c9NR1$VI8Xp(N z{EG%wI6&VIpL-5+JX0xp{p{H}n)tMfSz^C&%xO>!%Pl17MusPoKZX1(9&fuuZk#W2TXM z{aU@mJQq&g!r}p&%rj&#!gBGD3zs^C=RH@4xKeM5S`pI>eekq(Px$Q&e2vGia+0M2 zSBO|!h#O|1oD8g~E{m_XYN|3uM2CQs?+}18*%_2JJYJ6g)(d0=f%LBU zc9Q6{X_l%sbj@GmGPAAnm+R__oo)1EPxZs5eb_2LEfmUS^yIXH)C_{*c{#cW{6Rk| z2jR2v?KpULMLQ@*SLX~EUhBJ)Qw)Mo+0Rf}x>y1~6G(3mi@9>izj52B)JR+ zJTY3RrA5vASr;3@pK0mZ5GUo&q9OOHZumAg!bC_Pl0bu~Ddjf*>F#e3b^ z3a~zkDGf2!+hl7=BqwY~!w``++i&s#p$(jIe31<10{rP!_jul}8 z_Z*Ru0hB2YnRP)}V4WeRJ|J-%OO)IxZ|Q;E7J@PmEMzsranKM$4Rq}EV2Rr7LZqo- zA6+=*W2Rps%Ldw^cF5)JUu%&FJ0pg8I$8)vm{8$rre?gr_zhuF$jJx{Z5Ui&q!s{3 zVhTnWh-7YYb{D0~W{1HZqn9*pVSH%pqhJCRyFn0BI#M#meTDi+_Pk~53-&Pnw_QVy zL8GE?rst5U<_&9zJY!q}E{nCu;vfLcD7FF<9eYy40ta~VRTyMH!CW+g=S+nDAO6pO z`SS?9Cjb4_qTUm+K=ttS!uJPhzqwg{UUiYJJS6^-xfWL5rQX~s($I6p2oGW&VStQyl>QAHh%N<*1N5v_uqvtqs#Tp=dWJn6SZ-TrTXnR zy|=af-AZ+M_J`i!%P*@hCqZNP_{G&$yB762KVl=t=!ctk2VaM^_02E8dYMmE^leH0 zVPkaretbRYwSK%g>rZc99(6~Zy>DJ_y$l*(e!aE#cKhq|w^#M|x9xA%H#eW>Q_Seb zQtkxb9(02D)wufIXzw?p_iw*>KYqV|a&>uf`c+h|Z@zusz4YI;Dww5Qf00jD>uY@- zy8erQ^vj>6BhP>M;!l4L|L)Nv{`&>AZGZm`+Lv)C6g-PFJ6cU~{sbz^lsZdkHYQCe z{qKLhfRfeZEUDQBnv#0?rv;=$eY13Cn@~#Uzb~SbQQ9oM+2)ke`= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==2.8.2" + }, + "six": { + "file": "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'" + } + }, + "develop": {} +} diff --git a/spikes/pipenv/transitive-file/Pipfile.lock.lock-only-edit b/spikes/pipenv/transitive-file/Pipfile.lock.lock-only-edit new file mode 100644 index 0000000..c6b652a --- /dev/null +++ b/spikes/pipenv/transitive-file/Pipfile.lock.lock-only-edit @@ -0,0 +1,37 @@ +{ + "_meta": { + "hash": { + "sha256": "58546015c76e8085bff3be981f626feed276df866834bb057ab1c118de09ff77" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.14" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==2.8.2" + }, + "six": { + "file": "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", + "hashes": [ + "sha256:573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'" + } + }, + "develop": {} +} diff --git a/spikes/pipenv/transitive-registry/Pipfile b/spikes/pipenv/transitive-registry/Pipfile new file mode 100644 index 0000000..7c24f2f --- /dev/null +++ b/spikes/pipenv/transitive-registry/Pipfile @@ -0,0 +1,10 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +python-dateutil = "==2.8.2" + +[requires] +python_version = "3.14" diff --git a/spikes/pipenv/transitive-registry/Pipfile.lock b/spikes/pipenv/transitive-registry/Pipfile.lock new file mode 100644 index 0000000..8fcb22c --- /dev/null +++ b/spikes/pipenv/transitive-registry/Pipfile.lock @@ -0,0 +1,38 @@ +{ + "_meta": { + "hash": { + "sha256": "58546015c76e8085bff3be981f626feed276df866834bb057ab1c118de09ff77" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.14" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==2.8.2" + }, + "six": { + "hashes": [ + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", + "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==1.17.0" + } + }, + "develop": {} +} diff --git a/spikes/pnpm/README.md b/spikes/pnpm/README.md new file mode 100644 index 0000000..210adb0 --- /dev/null +++ b/spikes/pnpm/README.md @@ -0,0 +1,76 @@ +# pnpm vendor v2 spike fixtures + +Mechanism under test: root `package.json` -> `"pnpm": {"overrides": {"left-pad@1.3.0": "file:.socket/vendor/npm//left-pad-1.3.0.tgz"}}`. + +## Exact tool versions + +- node v24.12.0, corepack 0.34.5 (macOS, Darwin 25.5.0) +- pnpm 9.15.9 (via `corepack pnpm@9`; also verified resolvable via `"packageManager": "pnpm@9.15.9"`) +- pnpm 10.34.1 (via `corepack pnpm@10`; also verified via `"packageManager": "pnpm@10.34.1"`) +- All installs used isolated `--store-dir` + `XDG_CACHE_HOME`/`XDG_DATA_HOME`/`XDG_STATE_HOME` (cold caches). + +**Both majors emit byte-identical `lockfileVersion: '9.0'` lockfiles in every scenario tested.** +Each pair below therefore has a single `after/pnpm-lock.yaml` valid for both; every `after` lock +was generated by `pnpm install` itself (never hand-written). The hand-edited locks produced by +`edit_lock.py` were verified byte-identical to the pnpm-generated ones before being trusted. + +## Vendored artifact + +`.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz` +- registry left-pad-1.3.0.tgz, `package/index.js` prepended with + `/* SOCKET_PATCHED 9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f left-pad@1.3.0 */`, repacked with `tar -czf` (entries under `package/`). +- patched sha512 (lock integrity): `sha512-VR8nCbFxvOcFX5Rxku2psjaj0+xzKdzFkcuqZJSHf597bMVomG100t6+cJkMBFRLhyVdSVwufbCwVzlCzZkUwg==` +- pristine sha512 (registry): `sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==` + +## Pairs + +### p1-multi-dep/ (P1, P2, P3, P5b, P6) +Direct `left-pad@1.3.0`, transitive via `consumer` (`file:` dir dep with `left-pad: ^1.3.0`), +scoping via `left-pad-old: npm:left-pad@1.2.0`. +- `before/`: pristine project + pnpm-generated registry lock. +- `after/`: override added to package.json + pnpm-generated lock + `.socket` tarball. +- Lock shape changes (the full surgical diff, see `edit_lock.py`): + 1. `overrides:` section inserted after `settings:`. + 2. Root importer `left-pad` **specifier AND version both rewritten** to + `file:.socket/vendor/npm//left-pad-1.3.0.tgz` (specifier does NOT stay `1.3.0`). + 3. `packages:` key `left-pad@1.3.0` -> `left-pad@file:.tgz`; resolution becomes + `{integrity: , tarball: file:.tgz}` (BOTH keys) plus a new + top-level `version: 1.3.0` line; the registry entry's `deprecated:` line is dropped. + 4. `snapshots:` key rekeyed the same way; dependents reference it as bare + `left-pad: file:.tgz` (no `name@` prefix) in their `dependencies`. + 5. `left-pad@1.2.0` entries untouched (scoping). +- P2: edited-by-script lock + package.json == pnpm-generated bytes; `pnpm install --frozen-lockfile` + (fresh store) exit 0, marker installed (direct + transitive), subsequent plain `pnpm install` + leaves lock byte-identical (sha256 `a7c36d374de4c705bdb43d7aee42d944656a3b0d9c5d2c08c5b41664d23ee156`). +- P3 (lock edited, package.json NOT): frozen install fails + `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH Cannot proceed with the frozen installation. The current "overrides" configuration doesn't match the value found in the lockfile` + (exit 1); plain install silently re-resolves, removes the `overrides:` section and installs + registry bytes (no warning that the patch was dropped). +- P5b: store pre-warmed with registry left-pad@1.3.0 does not shadow the patch; patched bytes still installed. + +### p4-single-dep-offline/ (P4, P5a) +Single dependency `left-pad: 1.3.0` + override. +- Fresh-checkout simulation: ONLY `package.json` + `pnpm-lock.yaml` + `.socket/` copied to an empty + dir; empty store; `pnpm install --frozen-lockfile --offline` -> exit 0, patched bytes. Both majors. +- P5a tamper: one byte flipped mid-tarball -> frozen install exit 1, nothing installed: + `ERR_PNPM_TARBALL_INTEGRITY Got unexpected checksum for ".../.socket/vendor/npm//left-pad-1.3.0.tgz". Wanted "sha512-VR8nCb...". Got "sha512-FaR7sF..."` + (pnpm 10's message additionally suggests `pnpm install --update-checksums`.) + +### p7-workspace/ (P7) +`pnpm-workspace.yaml` (`packages/*`), `packages/app` depends on `left-pad: ^1.3.0`, +override lives only in the ROOT package.json `pnpm.overrides`. +- Sub-importer fragment (note the per-importer re-relativized specifier vs root-relative version): + ```yaml + packages/app: + dependencies: + left-pad: + specifier: file:../../.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + version: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + ``` +- `packages/app/package.json` is NOT rewritten (still `^1.3.0`); frozen install passes; app gets patched bytes. + +## edit_lock.py + +The exact surgical transformation (package.json + 4 lock text edits) used in P2; integrity is +computed from the vendored tarball bytes (sha512, base64). Output verified byte-identical to +pnpm's own lock for both majors before any install was run against it. diff --git a/spikes/pnpm/edit_lock.py b/spikes/pnpm/edit_lock.py new file mode 100644 index 0000000..90de5c6 --- /dev/null +++ b/spikes/pnpm/edit_lock.py @@ -0,0 +1,51 @@ +import sys, base64, hashlib, json + +proj = sys.argv[1] +rel = ".socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" +spec = "file:" + rel + +# integrity computed from the actual vendored tarball (what the tool would do) +with open(f"{proj}/{rel}", "rb") as f: + integ = "sha512-" + base64.b64encode(hashlib.sha512(f.read()).digest()).decode() + +# 1) package.json: add pnpm.overrides +with open(f"{proj}/package.json") as f: + pkg = json.load(f) +pkg["pnpm"] = {"overrides": {"left-pad@1.3.0": spec}} +with open(f"{proj}/package.json", "w") as f: + json.dump(pkg, f, indent=2) + f.write("\n") + +# 2) pnpm-lock.yaml surgical text edits +with open(f"{proj}/pnpm-lock.yaml") as f: + lock = f.read() + +# a) insert overrides: section after the settings block +lock = lock.replace( + " excludeLinksFromLockfile: false\n\nimporters:", + f" excludeLinksFromLockfile: false\n\noverrides:\n left-pad@1.3.0: {spec}\n\nimporters:", + 1) + +# b) importer: direct dep specifier+version -> file: spec +lock = lock.replace( + " left-pad:\n specifier: 1.3.0\n version: 1.3.0\n", + f" left-pad:\n specifier: {spec}\n version: {spec}\n", + 1) + +# c) packages: entry rekeyed; resolution gets patched integrity + tarball; version field; no deprecated +lock = lock.replace( + " left-pad@1.3.0:\n" + " resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==}\n" + " deprecated: use String.prototype.padStart()\n", + f" left-pad@{spec}:\n" + f" resolution: {{integrity: {integ}, tarball: {spec}}}\n" + f" version: 1.3.0\n", + 1) + +# d) snapshots: consumer dep ref + rekeyed snapshot +lock = lock.replace(" left-pad: 1.3.0\n", f" left-pad: {spec}\n", 1) +lock = lock.replace(" left-pad@1.3.0: {}\n", f" left-pad@{spec}: {{}}\n", 1) + +with open(f"{proj}/pnpm-lock.yaml", "w") as f: + f.write(lock) +print("edited", proj, "integrity:", integ) diff --git a/spikes/pnpm/p1-multi-dep/after/consumer/package.json b/spikes/pnpm/p1-multi-dep/after/consumer/package.json new file mode 100644 index 0000000..f6ea562 --- /dev/null +++ b/spikes/pnpm/p1-multi-dep/after/consumer/package.json @@ -0,0 +1,7 @@ +{ + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/spikes/pnpm/p1-multi-dep/after/package.json b/spikes/pnpm/p1-multi-dep/after/package.json new file mode 100644 index 0000000..cfca5f4 --- /dev/null +++ b/spikes/pnpm/p1-multi-dep/after/package.json @@ -0,0 +1,15 @@ +{ + "name": "vendor-spike", + "version": "1.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer", + "left-pad": "1.3.0", + "left-pad-old": "npm:left-pad@1.2.0" + }, + "pnpm": { + "overrides": { + "left-pad@1.3.0": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } + } +} diff --git a/spikes/pnpm/p1-multi-dep/after/pnpm-lock.yaml b/spikes/pnpm/p1-multi-dep/after/pnpm-lock.yaml new file mode 100644 index 0000000..45eec80 --- /dev/null +++ b/spikes/pnpm/p1-multi-dep/after/pnpm-lock.yaml @@ -0,0 +1,45 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + +importers: + + .: + dependencies: + consumer: + specifier: file:./consumer + version: file:consumer + left-pad: + specifier: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + version: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + left-pad-old: + specifier: npm:left-pad@1.2.0 + version: left-pad@1.2.0 + +packages: + + consumer@file:consumer: + resolution: {directory: consumer, type: directory} + + left-pad@1.2.0: + resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} + deprecated: use String.prototype.padStart() + + left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-VR8nCbFxvOcFX5Rxku2psjaj0+xzKdzFkcuqZJSHf597bMVomG100t6+cJkMBFRLhyVdSVwufbCwVzlCzZkUwg==, tarball: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz} + version: 1.3.0 + +snapshots: + + consumer@file:consumer: + dependencies: + left-pad: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + + left-pad@1.2.0: {} + + left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: {} diff --git a/spikes/pnpm/p1-multi-dep/before/consumer/package.json b/spikes/pnpm/p1-multi-dep/before/consumer/package.json new file mode 100644 index 0000000..f6ea562 --- /dev/null +++ b/spikes/pnpm/p1-multi-dep/before/consumer/package.json @@ -0,0 +1,7 @@ +{ + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/spikes/pnpm/p1-multi-dep/before/package.json b/spikes/pnpm/p1-multi-dep/before/package.json new file mode 100644 index 0000000..67dcdd4 --- /dev/null +++ b/spikes/pnpm/p1-multi-dep/before/package.json @@ -0,0 +1,10 @@ +{ + "name": "vendor-spike", + "version": "1.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer", + "left-pad": "1.3.0", + "left-pad-old": "npm:left-pad@1.2.0" + } +} diff --git a/spikes/pnpm/p1-multi-dep/before/pnpm-lock.yaml b/spikes/pnpm/p1-multi-dep/before/pnpm-lock.yaml new file mode 100644 index 0000000..aa2d943 --- /dev/null +++ b/spikes/pnpm/p1-multi-dep/before/pnpm-lock.yaml @@ -0,0 +1,42 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + consumer: + specifier: file:./consumer + version: file:consumer + left-pad: + specifier: 1.3.0 + version: 1.3.0 + left-pad-old: + specifier: npm:left-pad@1.2.0 + version: left-pad@1.2.0 + +packages: + + consumer@file:consumer: + resolution: {directory: consumer, type: directory} + + left-pad@1.2.0: + resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} + deprecated: use String.prototype.padStart() + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + +snapshots: + + consumer@file:consumer: + dependencies: + left-pad: 1.3.0 + + left-pad@1.2.0: {} + + left-pad@1.3.0: {} diff --git a/spikes/pnpm/p4-single-dep-offline/after/package.json b/spikes/pnpm/p4-single-dep-offline/after/package.json new file mode 100644 index 0000000..c9a6d68 --- /dev/null +++ b/spikes/pnpm/p4-single-dep-offline/after/package.json @@ -0,0 +1,13 @@ +{ + "name": "vendor-offline", + "version": "1.0.0", + "private": true, + "dependencies": { + "left-pad": "1.3.0" + }, + "pnpm": { + "overrides": { + "left-pad@1.3.0": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } + } +} diff --git a/spikes/pnpm/p4-single-dep-offline/after/pnpm-lock.yaml b/spikes/pnpm/p4-single-dep-offline/after/pnpm-lock.yaml new file mode 100644 index 0000000..b216ede --- /dev/null +++ b/spikes/pnpm/p4-single-dep-offline/after/pnpm-lock.yaml @@ -0,0 +1,26 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + +importers: + + .: + dependencies: + left-pad: + specifier: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + version: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + +packages: + + left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-VR8nCbFxvOcFX5Rxku2psjaj0+xzKdzFkcuqZJSHf597bMVomG100t6+cJkMBFRLhyVdSVwufbCwVzlCzZkUwg==, tarball: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz} + version: 1.3.0 + +snapshots: + + left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: {} diff --git a/spikes/pnpm/p4-single-dep-offline/before/package.json b/spikes/pnpm/p4-single-dep-offline/before/package.json new file mode 100644 index 0000000..f8136c4 --- /dev/null +++ b/spikes/pnpm/p4-single-dep-offline/before/package.json @@ -0,0 +1,8 @@ +{ + "name": "vendor-offline", + "version": "1.0.0", + "private": true, + "dependencies": { + "left-pad": "1.3.0" + } +} diff --git a/spikes/pnpm/p4-single-dep-offline/before/pnpm-lock.yaml b/spikes/pnpm/p4-single-dep-offline/before/pnpm-lock.yaml new file mode 100644 index 0000000..952f295 --- /dev/null +++ b/spikes/pnpm/p4-single-dep-offline/before/pnpm-lock.yaml @@ -0,0 +1,23 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + +snapshots: + + left-pad@1.3.0: {} diff --git a/spikes/pnpm/p7-workspace/after/package.json b/spikes/pnpm/p7-workspace/after/package.json new file mode 100644 index 0000000..43aca72 --- /dev/null +++ b/spikes/pnpm/p7-workspace/after/package.json @@ -0,0 +1,10 @@ +{ + "name": "ws-root", + "version": "1.0.0", + "private": true, + "pnpm": { + "overrides": { + "left-pad@1.3.0": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } + } +} diff --git a/spikes/pnpm/p7-workspace/after/packages/app/package.json b/spikes/pnpm/p7-workspace/after/packages/app/package.json new file mode 100644 index 0000000..aa21462 --- /dev/null +++ b/spikes/pnpm/p7-workspace/after/packages/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "app", + "version": "1.0.0", + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/spikes/pnpm/p7-workspace/after/pnpm-lock.yaml b/spikes/pnpm/p7-workspace/after/pnpm-lock.yaml new file mode 100644 index 0000000..e94b09f --- /dev/null +++ b/spikes/pnpm/p7-workspace/after/pnpm-lock.yaml @@ -0,0 +1,28 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + +importers: + + .: {} + + packages/app: + dependencies: + left-pad: + specifier: file:../../.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + version: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + +packages: + + left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-VR8nCbFxvOcFX5Rxku2psjaj0+xzKdzFkcuqZJSHf597bMVomG100t6+cJkMBFRLhyVdSVwufbCwVzlCzZkUwg==, tarball: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz} + version: 1.3.0 + +snapshots: + + left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: {} diff --git a/spikes/pnpm/p7-workspace/after/pnpm-workspace.yaml b/spikes/pnpm/p7-workspace/after/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/spikes/pnpm/p7-workspace/after/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/spikes/pnpm/p7-workspace/before/package.json b/spikes/pnpm/p7-workspace/before/package.json new file mode 100644 index 0000000..3bb2881 --- /dev/null +++ b/spikes/pnpm/p7-workspace/before/package.json @@ -0,0 +1,5 @@ +{ + "name": "ws-root", + "version": "1.0.0", + "private": true +} diff --git a/spikes/pnpm/p7-workspace/before/packages/app/package.json b/spikes/pnpm/p7-workspace/before/packages/app/package.json new file mode 100644 index 0000000..aa21462 --- /dev/null +++ b/spikes/pnpm/p7-workspace/before/packages/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "app", + "version": "1.0.0", + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/spikes/pnpm/p7-workspace/before/pnpm-lock.yaml b/spikes/pnpm/p7-workspace/before/pnpm-lock.yaml new file mode 100644 index 0000000..63e2fa4 --- /dev/null +++ b/spikes/pnpm/p7-workspace/before/pnpm-lock.yaml @@ -0,0 +1,25 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + packages/app: + dependencies: + left-pad: + specifier: ^1.3.0 + version: 1.3.0 + +packages: + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + +snapshots: + + left-pad@1.3.0: {} diff --git a/spikes/pnpm/p7-workspace/before/pnpm-workspace.yaml b/spikes/pnpm/p7-workspace/before/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/spikes/pnpm/p7-workspace/before/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/spikes/poetry/README.md b/spikes/poetry/README.md new file mode 100644 index 0000000..a0ebdc4 --- /dev/null +++ b/spikes/poetry/README.md @@ -0,0 +1,95 @@ +# Poetry vendor-v2 spike fixtures + +Spike date: 2026-06-10. Host: macOS (Darwin 25.5.0), Python 3.14.3. + +Exact tool versions: +- `lock-2.1/` generated by **Poetry 2.4.1** (`python3 -m venv /tmp/poe2 && pip install poetry`), lock-version `2.1` +- `lock-2.0/` generated by **Poetry 1.8.5** (`pip install 'poetry<2'`), lock-version `2.0` +- Wheels downloaded with pip 26.0 (`pip3 download six==1.16.0 --no-deps`) + +## Wheels + +- `wheels/original/six-1.16.0-py2.py3-none-any.whl` + sha256 `8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254` (the real PyPI wheel) +- `wheels/patched/six-1.16.0-py2.py3-none-any.whl` + sha256 `0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1` + Built by: unzip original; append `\n# SOCKET-PATCHED\n` to `six.py`; rewrite its RECORD line + (`sha256=` base64.urlsafe nopad of new bytes + new size, here + `six.py,sha256=jCYos5OzBq4HcWl4E7kATbVtnHLzmsxE64HkNrjPry4,34567`); rezip with + `zip -X -r` keeping the canonical wheel filename. pip and poetry both install it cleanly. + +Every `*-path*` fixture carries this patched wheel at the vendor path +`.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl` +relative to `pyproject.toml`. + +## Fixture pairs (pyproject.toml + poetry.lock; ALL locks tool-generated via `poetry lock`) + +Same four pairs under each of `lock-2.1/` (Poetry 2.4.1) and `lock-2.0/` (Poetry 1.8.5): + +| pair | pyproject | shows | +|---|---|---| +| `direct-registry/` | `six = "1.16.0"` | registry baseline: two `files[]` hashes (whl+sdist), **no** `[package.source]` | +| `direct-path-wheel/` | `six = {path = ".socket/vendor/pypi//...whl"}` | vendored shape: `[package.source] type="file"` with **relative** url, single `files[]` entry = patched-wheel sha256 | +| `transitive-registry/` | `python-dateutil = "2.8.2"` (six transitive) | registry baseline for the transitive case; resolver picks six 1.17.0 | +| `transitive-path/` | dateutil + six path dep | the only *tool-generatable* lock where the transitive six comes from the file source (six must also be declared direct); same six entry shape as direct-path-wheel | + +All 8 fixtures re-verified post-generation: `poetry install` in a copy exits 0, leaves +poetry.lock byte-identical, and the `*-path*` pairs import six with the `# SOCKET-PATCHED` +marker (registry pairs install unpatched six, as expected). + +Lock-shape facts (P1): the `[package.source]` table renders **after** `files = [...]`, as the +last subtable of the `[[package]]` entry (before the next `[[package]]`/`[metadata]`). +Poetry 2 adds `groups = ["main"]`; 1.8 has no `groups` key. The url is relative to the +project root in both majors — even when `poetry add` was given an absolute path. + +## evidence-lockonly/ — NOT tool-generated locks (the decisive P2/P3 experiment) + +These four dirs are the *hand-spliced* artifacts proving the lock-only strategy: pyproject +stays pure-registry (`six = "1.16.0"` / `python-dateutil = "2.8.2"`), and ONLY the six +`[[package]]` entry in poetry.lock was replaced with the tool-generated file-source entry +(taken verbatim from the direct-path-wheel pair), `metadata.content-hash` untouched. + +Result on BOTH majors: `poetry install` (and `poetry sync` / `poetry install --sync`) +exit 0, install the PATCHED wheel, leave poetry.lock byte-unchanged, and +`poetry check --lock` passes. The transitive splice even installed six 1.16.0 where the +resolver had picked 1.17.0 — install is 100% lock-driven. + +## Claim results (run on both majors unless noted) + +- **P1** confirmed — see lock-shape facts above. Surprise: Poetry 2's `poetry add ` + writes an ABSOLUTE `file:///...` URL into PEP 621 `[project].dependencies` (not + committable); 1.8 writes a relative `{path = ...}`. The lock url is relative either way. + Fixture pyprojects therefore use the `[tool.poetry.dependencies]` path form on both majors. +- **P2** PASS (lock-only direct): Poetry 2.4.1 (both `[tool.poetry]` and PEP 621 pyprojects) + and 1.8.5: install + sync exit 0, marker present, lock byte-unchanged. +- **P3** PASS (lock-only transitive): both majors; dateutil from registry + six from file. +- **P4** PASS (fail-closed): tampered wheel + stale lock hash + empty POETRY_CACHE_DIR → + exit 1 on both majors with + `RuntimeError: Hash for six (...) from archive six-1.16.0-py2.py3-none-any.whl not found in known hashes (was: sha256:2597d578...)` + raised from `installation/executor.py:_validate_archive_hash`. +- **P5** silent-unpatch matrix on the P2 state: + - Poetry 2.4.1 plain `poetry lock`: PRESERVES file source (lock byte-identical). + - Poetry 2.4.1 `poetry lock --regenerate`: REWRITES six to registry. + - Poetry 1.8.5 `poetry lock --no-update`: PRESERVES. + - Poetry 1.8.5 plain `poetry lock` (full re-resolve in 1.x): REWRITES. + - `poetry update six`: REWRITES on both majors (silent, exit 0 — six version stays + 1.16.0 because pyproject pins it, but files[] revert to registry hashes → unpatched). + - `poetry add packaging`: PRESERVES on both majors (re-resolve keeps locked sources of + untouched packages). +- **P6** confirmed: `poetry check --lock` exits 0 on the spliced state (2.4.1 both + pyproject styles + 1.8.5). content-hash only covers pyproject, untouched by the splice. +- **P7** PASS: dir containing ONLY pyproject.toml + poetry.lock + .socket/, brand-new + empty POETRY_CACHE_DIR, fresh in-project venv → install exit 0, marker present + (both majors direct; also verified transitive on 2.4.1). +- **P8** both majors canonicalize: pyproject `PyYAML = "6.0.1"` locks as + `name = "pyyaml"` (PEP 503 lowercase); `files[]` entries keep the original + `PyYAML-6.0.1-*.whl` filename casing. + +## Caveats + +- `poetry update ` and full re-lock (`lock --regenerate` on 2.x, plain `lock` on 1.x) + silently revert the patch with exit 0; vendor v2 needs a drift check (the lock's six + `files[]` hash is the cheapest oracle). +- Poetry installs file-source wheels directly from the path; hash is verified on every + install (P4), so the committable guarantee holds with no extra flags — poetry has no + "looser" install mode to guard against. diff --git a/spikes/poetry/evidence-lockonly/lock-2.0-direct/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/evidence-lockonly/lock-2.0-direct/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..1816a1d901cd1de5053d0d215035b540ece1765c GIT binary patch literal 11196 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 literal 0 HcmV?d00001 diff --git a/spikes/poetry/evidence-lockonly/lock-2.0-direct/poetry.lock b/spikes/poetry/evidence-lockonly/lock-2.0-direct/poetry.lock new file mode 100644 index 0000000..7742924 --- /dev/null +++ b/spikes/poetry/evidence-lockonly/lock-2.0-direct/poetry.lock @@ -0,0 +1,20 @@ +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, +] + +[package.source] +type = "file" +url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9" +content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" diff --git a/spikes/poetry/evidence-lockonly/lock-2.0-direct/pyproject.toml b/spikes/poetry/evidence-lockonly/lock-2.0-direct/pyproject.toml new file mode 100644 index 0000000..b2d8496 --- /dev/null +++ b/spikes/poetry/evidence-lockonly/lock-2.0-direct/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +six = "1.16.0" diff --git a/spikes/poetry/evidence-lockonly/lock-2.0-transitive/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/evidence-lockonly/lock-2.0-transitive/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..1816a1d901cd1de5053d0d215035b540ece1765c GIT binary patch literal 11196 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 literal 0 HcmV?d00001 diff --git a/spikes/poetry/evidence-lockonly/lock-2.0-transitive/poetry.lock b/spikes/poetry/evidence-lockonly/lock-2.0-transitive/poetry.lock new file mode 100644 index 0000000..e3fc134 --- /dev/null +++ b/spikes/poetry/evidence-lockonly/lock-2.0-transitive/poetry.lock @@ -0,0 +1,34 @@ +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, +] + +[package.source] +type = "file" +url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9" +content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca" diff --git a/spikes/poetry/evidence-lockonly/lock-2.0-transitive/pyproject.toml b/spikes/poetry/evidence-lockonly/lock-2.0-transitive/pyproject.toml new file mode 100644 index 0000000..d43c93c --- /dev/null +++ b/spikes/poetry/evidence-lockonly/lock-2.0-transitive/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +python-dateutil = "2.8.2" diff --git a/spikes/poetry/evidence-lockonly/lock-2.1-direct/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/evidence-lockonly/lock-2.1-direct/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..1816a1d901cd1de5053d0d215035b540ece1765c GIT binary patch literal 11196 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 literal 0 HcmV?d00001 diff --git a/spikes/poetry/evidence-lockonly/lock-2.1-direct/poetry.lock b/spikes/poetry/evidence-lockonly/lock-2.1-direct/poetry.lock new file mode 100644 index 0000000..4bf41d2 --- /dev/null +++ b/spikes/poetry/evidence-lockonly/lock-2.1-direct/poetry.lock @@ -0,0 +1,21 @@ +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, +] + +[package.source] +type = "file" +url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9" +content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" diff --git a/spikes/poetry/evidence-lockonly/lock-2.1-direct/pyproject.toml b/spikes/poetry/evidence-lockonly/lock-2.1-direct/pyproject.toml new file mode 100644 index 0000000..b2d8496 --- /dev/null +++ b/spikes/poetry/evidence-lockonly/lock-2.1-direct/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +six = "1.16.0" diff --git a/spikes/poetry/evidence-lockonly/lock-2.1-transitive/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/evidence-lockonly/lock-2.1-transitive/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..1816a1d901cd1de5053d0d215035b540ece1765c GIT binary patch literal 11196 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 literal 0 HcmV?d00001 diff --git a/spikes/poetry/evidence-lockonly/lock-2.1-transitive/poetry.lock b/spikes/poetry/evidence-lockonly/lock-2.1-transitive/poetry.lock new file mode 100644 index 0000000..915c7a2 --- /dev/null +++ b/spikes/poetry/evidence-lockonly/lock-2.1-transitive/poetry.lock @@ -0,0 +1,36 @@ +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, +] + +[package.source] +type = "file" +url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9" +content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca" diff --git a/spikes/poetry/evidence-lockonly/lock-2.1-transitive/pyproject.toml b/spikes/poetry/evidence-lockonly/lock-2.1-transitive/pyproject.toml new file mode 100644 index 0000000..d43c93c --- /dev/null +++ b/spikes/poetry/evidence-lockonly/lock-2.1-transitive/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +python-dateutil = "2.8.2" diff --git a/spikes/poetry/lock-2.0/direct-path-wheel/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/lock-2.0/direct-path-wheel/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..1816a1d901cd1de5053d0d215035b540ece1765c GIT binary patch literal 11196 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 literal 0 HcmV?d00001 diff --git a/spikes/poetry/lock-2.0/direct-path-wheel/poetry.lock b/spikes/poetry/lock-2.0/direct-path-wheel/poetry.lock new file mode 100644 index 0000000..a1c59d8 --- /dev/null +++ b/spikes/poetry/lock-2.0/direct-path-wheel/poetry.lock @@ -0,0 +1,20 @@ +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, +] + +[package.source] +type = "file" +url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9" +content-hash = "5434ae139eccff13b64b1afa678ebd6ad51d0123b6147ab3449303014b0e29ef" diff --git a/spikes/poetry/lock-2.0/direct-path-wheel/pyproject.toml b/spikes/poetry/lock-2.0/direct-path-wheel/pyproject.toml new file mode 100644 index 0000000..c58f3ed --- /dev/null +++ b/spikes/poetry/lock-2.0/direct-path-wheel/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +six = {path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"} diff --git a/spikes/poetry/lock-2.0/direct-registry/poetry.lock b/spikes/poetry/lock-2.0/direct-registry/poetry.lock new file mode 100644 index 0000000..907d872 --- /dev/null +++ b/spikes/poetry/lock-2.0/direct-registry/poetry.lock @@ -0,0 +1,17 @@ +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9" +content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" diff --git a/spikes/poetry/lock-2.0/direct-registry/pyproject.toml b/spikes/poetry/lock-2.0/direct-registry/pyproject.toml new file mode 100644 index 0000000..b2d8496 --- /dev/null +++ b/spikes/poetry/lock-2.0/direct-registry/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +six = "1.16.0" diff --git a/spikes/poetry/lock-2.0/transitive-path/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/lock-2.0/transitive-path/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..1816a1d901cd1de5053d0d215035b540ece1765c GIT binary patch literal 11196 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 literal 0 HcmV?d00001 diff --git a/spikes/poetry/lock-2.0/transitive-path/poetry.lock b/spikes/poetry/lock-2.0/transitive-path/poetry.lock new file mode 100644 index 0000000..b8a9b1e --- /dev/null +++ b/spikes/poetry/lock-2.0/transitive-path/poetry.lock @@ -0,0 +1,34 @@ +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, +] + +[package.source] +type = "file" +url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9" +content-hash = "4bb72a51114ed762964be4c9ae3c2517f1ae92e4ce6d142c6009a2fc1e1e5d67" diff --git a/spikes/poetry/lock-2.0/transitive-path/pyproject.toml b/spikes/poetry/lock-2.0/transitive-path/pyproject.toml new file mode 100644 index 0000000..d35f922 --- /dev/null +++ b/spikes/poetry/lock-2.0/transitive-path/pyproject.toml @@ -0,0 +1,11 @@ +[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +python-dateutil = "2.8.2" +six = {path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"} diff --git a/spikes/poetry/lock-2.0/transitive-registry/poetry.lock b/spikes/poetry/lock-2.0/transitive-registry/poetry.lock new file mode 100644 index 0000000..9eb6721 --- /dev/null +++ b/spikes/poetry/lock-2.0/transitive-registry/poetry.lock @@ -0,0 +1,31 @@ +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9" +content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca" diff --git a/spikes/poetry/lock-2.0/transitive-registry/pyproject.toml b/spikes/poetry/lock-2.0/transitive-registry/pyproject.toml new file mode 100644 index 0000000..d43c93c --- /dev/null +++ b/spikes/poetry/lock-2.0/transitive-registry/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +python-dateutil = "2.8.2" diff --git a/spikes/poetry/lock-2.1/direct-path-wheel/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/lock-2.1/direct-path-wheel/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..1816a1d901cd1de5053d0d215035b540ece1765c GIT binary patch literal 11196 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 literal 0 HcmV?d00001 diff --git a/spikes/poetry/lock-2.1/direct-path-wheel/poetry.lock b/spikes/poetry/lock-2.1/direct-path-wheel/poetry.lock new file mode 100644 index 0000000..a0be459 --- /dev/null +++ b/spikes/poetry/lock-2.1/direct-path-wheel/poetry.lock @@ -0,0 +1,21 @@ +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, +] + +[package.source] +type = "file" +url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9" +content-hash = "5434ae139eccff13b64b1afa678ebd6ad51d0123b6147ab3449303014b0e29ef" diff --git a/spikes/poetry/lock-2.1/direct-path-wheel/pyproject.toml b/spikes/poetry/lock-2.1/direct-path-wheel/pyproject.toml new file mode 100644 index 0000000..c58f3ed --- /dev/null +++ b/spikes/poetry/lock-2.1/direct-path-wheel/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +six = {path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"} diff --git a/spikes/poetry/lock-2.1/direct-registry/poetry.lock b/spikes/poetry/lock-2.1/direct-registry/poetry.lock new file mode 100644 index 0000000..9f4f30d --- /dev/null +++ b/spikes/poetry/lock-2.1/direct-registry/poetry.lock @@ -0,0 +1,18 @@ +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9" +content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" diff --git a/spikes/poetry/lock-2.1/direct-registry/pyproject.toml b/spikes/poetry/lock-2.1/direct-registry/pyproject.toml new file mode 100644 index 0000000..b2d8496 --- /dev/null +++ b/spikes/poetry/lock-2.1/direct-registry/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +six = "1.16.0" diff --git a/spikes/poetry/lock-2.1/transitive-path/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/lock-2.1/transitive-path/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..1816a1d901cd1de5053d0d215035b540ece1765c GIT binary patch literal 11196 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 literal 0 HcmV?d00001 diff --git a/spikes/poetry/lock-2.1/transitive-path/poetry.lock b/spikes/poetry/lock-2.1/transitive-path/poetry.lock new file mode 100644 index 0000000..6d49955 --- /dev/null +++ b/spikes/poetry/lock-2.1/transitive-path/poetry.lock @@ -0,0 +1,36 @@ +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, +] + +[package.source] +type = "file" +url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9" +content-hash = "4bb72a51114ed762964be4c9ae3c2517f1ae92e4ce6d142c6009a2fc1e1e5d67" diff --git a/spikes/poetry/lock-2.1/transitive-path/pyproject.toml b/spikes/poetry/lock-2.1/transitive-path/pyproject.toml new file mode 100644 index 0000000..d35f922 --- /dev/null +++ b/spikes/poetry/lock-2.1/transitive-path/pyproject.toml @@ -0,0 +1,11 @@ +[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +python-dateutil = "2.8.2" +six = {path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"} diff --git a/spikes/poetry/lock-2.1/transitive-registry/poetry.lock b/spikes/poetry/lock-2.1/transitive-registry/poetry.lock new file mode 100644 index 0000000..a4f40d4 --- /dev/null +++ b/spikes/poetry/lock-2.1/transitive-registry/poetry.lock @@ -0,0 +1,33 @@ +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9" +content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca" diff --git a/spikes/poetry/lock-2.1/transitive-registry/pyproject.toml b/spikes/poetry/lock-2.1/transitive-registry/pyproject.toml new file mode 100644 index 0000000..d43c93c --- /dev/null +++ b/spikes/poetry/lock-2.1/transitive-registry/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +python-dateutil = "2.8.2" diff --git a/spikes/poetry/wheels/original/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/wheels/original/six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..fd942658a2f748ba433dd8632abb910a416e184f GIT binary patch literal 11053 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 literal 0 HcmV?d00001 diff --git a/spikes/poetry/wheels/patched/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/wheels/patched/six-1.16.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..1816a1d901cd1de5053d0d215035b540ece1765c GIT binary patch literal 11196 zcmZ{K1CS=mw)NMxIc-ndwr$(CIc-j3+V-?<+qP}n?*8Yz`~EoZ-nb_!vMQ^h_R6ds zxmT{JSn^UJpr`-<01^O@8r7y$-!8x*1OOmH0RZqnPn|5>Y3)7Ab-tX|8solne8FT> z*>W`#lb1a*TVyvl22`;&?waEFlkac2al(U07Ew!La%>(BKcCig03_nE^B7!nHIVvG zfjatbHJ}pra9q(#=_KmZV0GwMw0=`?|xNJIa@)K%+ zSzz@e!6-KFLuK166FCs~eDJ1!&!}^sY%fTscF+Eq(lQfL@iTjXX$M>ZktSt~`xtH8 zPn8;oJnCq7M20=)ZCqw<8AhAm*yr4Y3J*$6Kl+YfpQ5fmuI^IU!P#p*YH9=VnqS*` zA?ln0af}^>dZ$Dj7(wBce?K@i+VS|1ULC9y!MJEg90=a84)vbj;>{ZEtp~@KgQ98D z)F6iTZ5#pdM-o)Abj|D@lA}NE7v6X31Hy1{OdxSNEDU61w8y$Ze>BTt#c~hL!LnLb zpi$ zqPTK(z8oDJy7#mOLM60$KbkOq{BUA3;b*|pmiy9Y%U<4oMR<&!c@W8l>)QMk8V}X> z4P^QF^wE(yXm#e{Zg`sOD_jLF;Z?h`p2J_e)25(> zT}02q_7kDQ1_0hNEJ#uG@!$@Ts%8r4-HjJ_LpzW0h;3<(0rf5C%sq|C_nSy>(vU2=drfkz%2Z{hB zIs_{NM4r3kcG~&$kvPm}kgy$?tbI87Bsg72B#x>Q=Bi!6mZZ%6xZAVR1I_%LUhkDsH?=6k%#l<;B9ywTLc50hQ zUC_bIQ(5Bjcx(4KpxWVefSg)u2?t4u27>!eKTmjDH?k7&zd8)QNGn$1ve#w8t;9xU zlumP?S=b&p!3``ekdQhmh5>-M9gf3C~><(P;ppd1q3Eb9b-kkOTb05VWtmU^a z=|xG(IBp$eJaX>_e}DGm^!m);He@GmS+zoHq=t+T zsA{;)dkJY6W>8K@ZZT`VWTY~PngegtY}qo+q%N%)CW&8*-G~+!-(PvcUH;oTAK}h*Gq_zN5x>x-PNy( zGWKm=VA>k0=FY0=56B>QQes$BEA`$ttd$RFnR$nXyZomGe_~7u)co$1$o^oz*j_l3 zVcPh=<7(w;%Om3EIf&D2HA_7^VVIK5=XRBP9`lH52_l1MMsor~2U(CylvtOv|J?%Pv#jF=4sF z!TMyK)fvBUiI>G3*vpt#EjuZ#R=El=1eGi-G`Dcbk0|=k1a?T;M)uG#GoBNQ2SU0j z@z}<`VSRHtxYX|L-D-7q@Y*00G-2>)R1r?*)JYUl3n|#&6F4o`x$4oWANPa5eYT@! z5RUV20C$YW9P#E^aAqqYxG-$mg^6*fd@M1jP#>&^fzN}=<&v<6!Py8HIUp?nWHFWT z`&jzw6OazE?DTC#(EybA3-)Ja?zmeG7Rg71uR=)X=p{)8Dhd{FFx7aJeoZ`4i(gwv z>cdmROLXQtY@s%aaMnA0LgIWY*TOz=DLHX%PyikaOB&SJEm?z`1>aGGRkBT6LgVy; zas#7lBa#&p%Ak9piCIDGI-Ek>E~U~~0UGY%l+B{bSTMtWrR^4%9L^ODs8YNW9S|3< zjuV8byh9`+_gV^3u?`}o+; z0Oe_9?9o6G^mz^dw0OnyB92-#pr?_JmV9wu=lQv7x!8+u zCK!caTtOu+0|msm>w@gXZKKTVkO#KK@IJ*6+io7P8-lRJf9O@D5FTc1aFxDecS?v7G-x!WQ6cAx0loN?TW4qiys zt10R?(F^IijJc|J$M>=XIVOw(p5<)48N^R$SM~&=?rc*?h-?%45?`tQ68SU{6fGO0 z#C>{}ioAzib3E$B=l)*-`y7dOCM{Wn_+~?y9t*j27&iIS2npjuF0T?B7IH6QbN6T) zm26OIOdXr-?mA*eQ>v3haW{~aHyA$F<(5Rz(3~=|DlYfGvMW@v#i$K-ii%ZelA$bB zz7mYz0Tu(YC%p^R|BJnD)bh1Ei9LnL!nQ#dRs zTMORX0N)Ih28kSNXfHK-&bo;-|4gSn6-VxrO^Dhvp^?GzLeS0+K3C3ShOzAbPOR9_ zk9)%}K^7;oU;>l62Vsk;WZgMjrJP=|y1INr+626dlF(=Xo5or^FJ(`7(~v5wtbtt; zLN(I>9H)aiE5WsrR3*FwP7XXeRCp^^XJkW_>`vhLkk^v4JO~!S)sd4l`jZ-sMO0yh zvoDs8VP1r{O`#0PAJibjeT-cdNsl|jXriTZWp#&bpOJm7O}*kev&&F)vP5)BK??)i z?Ch8?K}MOoeOUa+AwTj)$)^CY8}cAsSGmm!k(8`aUVbE*#5YK4#aO($P^#JFPQK=L zw|r6mL=8pwYoq`_=OG02!eWjXu1zqP-I#Y8e8d5x2u)GRTwKF8#2UoP#wwQar&$d- za<-%bluTC6}jsWQ*c5-ZvB$kkv4{OKW;sDP zka1{^-kA^^TI4ps&(=#Q0XckZZozX{Mfg52`fgs>Rs^3n)W06JFqOy zsJ#(IKI+PcSDb=P+p~s^Dche<2ZWm@O)pWJxc#ziGVDP` z%I#?flT7^=0S#c7}`aNcN}J8TCV1y(ihALJM(E;;2&j$6anXc-Oo-vN3`qV^6$^*qCW2X z!UT&cZzPja4*N#gS}z&z;v0T%9(TwB=2c(8;p>9@?%^n3mN%4`dcZZxcPxjt&79Cq z^im?bp1}7o?E+*;bSk$he&(&ZU=n9;NZZ6mOY+w)G2(qoD5nMQ@yUSgklTo7J+iNs zGny9|hD#z(zQ=+-afdsTSmROcyxD@jP5wf90vI6E6BKjB74lW^4A@pEhw=j%z;5=*M@T1v`u;$3Wr2 z_*GrC2Q_iz(JrU|$TcW`bO9gn$!8Nk#J!v**xvcfiK-eP9L?)kBS!sblrqoR;8ox* z@#1Ns&-zT{$unQx;ustbvJk%USdf-(xasS@~?t zUcF@Qxs_(_yI|}ej85nYm^`rpJ+#%p^0tVp)RD^;?^Ehb+evnQ{ucfznV+-zX5vdi zG!lV8?vNjK#d!Kiv5<M~Z@sr=kj`f-+DlHOiA=aB`^l18^k7pfnid=K& zyHv1(7){$)-R)Xlgf87h=qcaV=!q><^lg+AfCK)~&u9dOiua>vVdEE`)BQRGe1F zjLMl1QL?pb=rv-~M+FwXpln&Pu-rW8Xyy3p+xGWz_ z^j?GSQcV<2>i)~{;QrM}gQqUn5d3A@g|J!JA(xT7W=qL5A6Cfk%-(HAY^&<*C(XDQ z&I~N>-7XYOj;{%?0zPhdGc8I%AQxpMN%&c*uBBaw6P zvbUvE1XJ@CYHG^;4fbb!+#Bip^4}3X3|cp^StTA782lMMn4}-fX{=2awU8Hw)wpm~ zRA4k;l``;Vy?O|kor$LA`QcfogA^Wnp|XTt-bHT7B5Tglg%SNf{<94 z@T)NtnE%Rz@u1GPG;!Z2^?oRaM_W7oIU?Z)W0RERh~4Xwx; zDF{$u3Y|yhaE1^?EHhyO(MnVrY(!@zD89xdtV)$bQE`tDUmhB zlMN|kHy3nF10f^*E|A4NcmfofK2W0@qmiY>uSBb$F{QkoK^gjSY*{d z-+3)qBrdI|5pE9n=AJ>@J{yyy?Z%=IzVC}nq|5HakPCUDJnez5;UL0B6=O>)+{e>y zv)-fuN7+Brl9s{1uS+>M`U*+na3t?rpm@f%mdkC9OmV~adm|U$x8;i!ccBVTXc<;x z4j|pRANJ77-QGo;R=<9HN?)C)I`aqS#{n0!0O_VHf-@xEhjxi0t+fiv;KwR86${c< z7`k24R|{wK{iY1rVq)vJZqfW6f0`Ce)YZ{t)|FNBG>E+%=O%r`7Wopuhv6rB(AqYj zkV{%hrRFY`Ws9UYq~fWgWJ-ZgRa<23_0!c)d_!`(vdN&-y|1%>Brts#6=JFs-FCl? z(Ad!$yfH}-x@p~D?X*~(ssW`FUME^>WX<>E%qDvWLHqtVQ{lIR`)P|+hg@7+V0-Z@ zpDG$BIY^T!3v&NYgomxojdTXSCKs!ui8pF<{jtNXfVa)0nruG(>@&KBU-fR5c7MlJ0p?>P~xfwbqQL3%GmgZlw9V zyNlGZhuBlg?E}NAvh5?a+(lct$)v_DC#?%Nm`8#j2+@8dr6l&i&9?jC3A>xhe^ZM{ z#z#pg;~bmfpba&_(vY%E?rGn?kUuRBq{yUyRL=cDKZ}^c*mF>G^ZbQH?z(4lx1Xn9 ziF79BoK-w`ukSzLZ9%DZH8yG$#3W%m^}wU$@?Ow9;Z(E=gF~)nKrfbaXeh?K4wTtP zDG=46e5pZ?^E|c>IVVIVQ5dQwN+NSb(5DNo&8TZZK)Nbh5sT`YLu8=iDHJ-l*MiWY zX9?O@d92SR^&k?uKGNQzz-*CHzo!$k6ro;g5Latm;{_>yf-$e%r}1@ck>IZFRHJhTy%UOVD0H93CgvYSk7piBKT9e87*zeD+dERlHQ>yC9B4%)aMJ z^;f4Cu}FCBStslr#u~B*CtkGJt$Qm(_S6zj!~rZ~{|)(qXV=ALa=6v~C9d{PN_nCj zh1Eg)b1IWPI!pCv&|YgP#STgqq>);Kj&23#cU)Xld=;6-j><9^LVV=}Gac54Nc=R) zvrHjs<~qfXXp*cUqIN0$I9dPD9;!*)&~>9))^*I8LNEuA=j2%`w#h>c=bM<4;azhe ztY7_wYWy>u z-+utXKDSt_UY?7vy60pF*Sg#~ogpB2-$RWdp*easS#pgNMwAob$&$sOqfp@*r6Oon zlKcBDIZ`(pjpZIU*>c3L*BlI=z|(|=+Sd)izf(JkK!G<5&%_5Zi~uQXIKb-#i3>7e z$mrA#w`~mf00UMmDlOo4cs4M|5?08@bwEq`@Uap%RwHdqkzrqI$!WOb%x}dw93ZfU zsMJXVMjDy3c_c_!B%#P7VOjMy+?y3EkkB5+>f&HPK7v4S6~>+Qijh4VMmXc#&!$iY z!ieHiflRequ1#_bzu~unT1&Ih^7nU)INb>IgN z(^I1f!r~o*ZW9~>rDnj>OzX|6YYy?{#~mr8V2k-=qa=`Li436%PBZ<^cHS@gfUL$I z`(p~Yq?Uq=6p|T3tRu(Q*U8bx=ar13rP=*q%nnk^wWg($o=5TH;$`Z4auG%#=q{(K zEZ@=sza30uHeqgG#k@^&3BmuX>JcQDY{&{|Xw>-+)$MDJjaX{^lN;z>}# z{b$Iu{9dut^ePjF(&H9=5;-h|^1(g{o~enTFfQV*#~_{KsHiQY)1Q5}$Fyw&Me?H} zxhBW)ynDCSCA_7i0?v}v;rgu5kEFpuab9gAeqh`kY#j9i?-LBk^#dFmMwT5UoK)ya zWjLa&vQ;ANIWRsiFISE&Py=ZKvDB!t=OxtQrT z`Do;&GV`e2g-7c-=qs4VZm-(nZ?4|&CR~E!DinE-n@7OANgHcKLh0I^*P0CUP_}Z@ zaOWSLNwxMygCIlEp}5GCvt9cSf%567hjAcoE3~y^@Ne`Km!NzS7mSba`>YZv?Zd z*o`_#jggDq;}bJ_$q4u8i+uHMhbsR%p`I@Is~S`fZG2_n=pnEoPM^<*37W1F}kxJ z*Qw#puagEK|IWPWR}X{}HTo(8Y!9_}QmaHJ;kfy-u5ck0_9c>@s{Yn7;UDJZeusyZ zBHh}n4c>XQWC)nO=dZ#|XPV=N%c8Ewia{%b^rvx8@aVisM>rB3(8~(PCu-&dnuvW5 zmucpok>p7&4_hTWW>~*)Ii1{s<2fIZ&!}Lbh~>V^)!)&dD2)Era&r*e`d$lpvP52g z3Z^I`5muNcD@d80^D|(pUD7HLO&~w@j+p3WvC}mNm#lb`t-E7Tx9^GBG91~Vr{lp9 zvIAd=V}9Vpo{YQa&p$P(bP0A$J%ME_7n#k1UJD$fO|5h!DX?z!dvCy(kca%RkQ3%0 z-(jXt=r5p?`aK$NpvCh1g%%=S7FlvG(uY?{fK@! z!weaS)HuDe&t(qYbgcFv5@offK0-jCLBIwn=$ipXIB~@!FKy)%$5DQv{_XEzdB~zv zDHllWBHCLRq#dutmmsR`ttC01I@nTj#-!7&Jzy3RUXTRBv`{{w$y=0X{=MSH{1vFM zEQvz`&OqVv@m)dAuRTJSSgYj9ZqVVTG|rtj)5Ll6eAGxBH1y4~ld=9vn*w^iC0J;U zhfq?>Dnzn*mTP_6`NlQU^+?&(J{0JvXX82!-g6qD2Gn#pGVM)y*8&Mh++diXcMXLC z2z!Ykc6>b23Tj7emOc?QHOHw#9v_A-5evR)MC#KAar#>{hW1JVDn1qRQsL4zy42|1 z#w#nT3e*HGrbIeEmeSw{)ObQuX_0u=>26fts%r7^0&&qqu2*qgRhqqqJ~JYF&E8}o zGCB69qrP2^N-(mLlWQdSu+{007UDquT$|`QH=TpX(q01Puc%11l{(t+9oZGmV9l(ai9)dq0USQO8$Fv+v z5yyZsN>1Hd2V!kcV|mMm2?v8$N*aRYla&c2*!#j$0QeWOF;B5ht#J<3M zk0B|*m7@hcg0KhTE6BUIUEh4fq*U4XOUW2+J4*LSvLoFkrzdqOO&0o-cp=*w(T>fR*s8(a`<#fzUd z1Ii*YyM!@q!t$)bh(+Y*^fYg;H!n9vHc-uftP1hZ?jzF!y-4ruYq{lAWg2GEy=?~5 z1}`E#d?MFi7B4UR7t#;O@d;LY%fZ5X%W3d!$Y9p`6b28jg^OcUX7cqkku?AAGr^Ic zm1U|JN-Th)>|7>O4Bm#=dis={!Q!j`g&e#kVOXtl^})ww;=y&dO|0o%#}C2otNXAB ztrNBR^w>y0KhOn~6!*kV3bF&mH=$ z?3zc)fNEUQ29`jO$-*@!se2#(6Ed}^`zt9;4UIfA%tZt87YkvXVLZ$WC`?BDC_mw1 zFF}a{e*hb04;7J?Q)b2W2>NeC)8t-7%%2$}iWd6vj5flJ6e%H&c~UA42~)AE&@J=D zy-V6@5Xu8s*JeNX8w^DlBx>MVm*Tsm6S-)zRKZnChdIzOa58H|dJN)vO3XD7y{q!whx%YuGl692L8{7q_q)e#}* z8|I%90{qp8{%KmycJ_MKCaxydw9f9%e`pW--#-m{@tm0y1ONbm0040R2}4FiSwL7o zSs+na!ETKe(dVeDna5_rvecm(3^B$<0!v?1X{b2 zuC-wM+hyBT=iYY`6$-^Vz8Q!MYj`IQ%B0c(oPbZFEZ*$VIlTj}n#(oS7sAj5erz)X z_)%4R@B%9?k=AL^IRMXqZ&-16|>E&prfd2j<<3}qYk>2&LixJ zQ*&BpFZ9nbF0PI7YL(Uf>Z#WbDV+SfsOAOI)ovADn#+Mv^3qH@CwFxp?ahw{)Fsj2 z9IGhUFQ6uK?WNzO>hZsM`Lp`y=mQhoRP>Oa(r-izt_h;;u&Xp_*SjlfWu|ln#wDP5 zC%(+yR|MXBkyoLqn$M}C8l`(sG?I1*TrJr|*DEmWS6nu2I9TL>&#Mp=FLjE>Fsofz zE)s@72yeqQ+zhIN-(fX#g59?QdL)$ z88onK#9nWG4;L)g5Xa^3_?%DKUljnRhTKIzhVHuu zz{x+cUN|AX7r*V7dz|ZMX#Yh>*V6PC3VWN6cK_hJE)!fb{O?58<@Of zq@P7qZ-su*Rmg8QGpDE2wV$ZENx8%_0uJwk5=pDrJD6wf)R!|_lbKcuO~iSC@?PSQ z+mRsl;4LMY^1&mOaK_Zp^1*ZNX9W2==R$2@eMS!ek&&k~jqsy1ho|(oQ-bHf)V?xs zED6fK)PU^7Yqw=hfR_D4Ttv#wxd0I%k2W%S5e?(AdVWqM-t#+Q5pv2?^G11G=N|M) zMUQ{Y+Ee$7C66X6q(aHik6e9Vz3>wg#rXPDqd>rTAb;zK|FmxYDKfwxNdc($|Eu>e zBj>+j{#ma4^(Fv-2)Gd7C;Uh8^6$g`r+oPf;9tW!{S(B$#LWMl|9{)9{|1`)LpuLe zNd52L{hvtxNuz%w`H=nt(*Ka^|IN;SMFI3&me|{}0iitH=NV literal 0 HcmV?d00001 diff --git a/spikes/yarn-berry-nm/README.md b/spikes/yarn-berry-nm/README.md new file mode 100644 index 0000000..9bf08be --- /dev/null +++ b/spikes/yarn-berry-nm/README.md @@ -0,0 +1,183 @@ +# Spike: yarn berry 4.x (node-modules linker) + vendored .socket tarball + +**Verdict: VIABLE. Berry's lock checksum is byte-for-byte reproducible offline from our +tarball — `checksum: 10c0/` is sha512 of a deterministic zip we can rebuild with +`rebuild_zip.py` (stdlib python, no yarn). Recipe pinned and verified on yarn 4.12.0 and +4.6.0, identical output, TZ-insensitive.** + +## Tool versions +- node v24.12.0 (nvm), corepack 0.34.5 +- yarn 4.12.0 and yarn 4.6.0, both via corepack `packageManager` pin +- Python 3.14.3 (rebuild script), macOS Darwin 25.5.0 arm64 +- bsd tar / shasum from macOS for tarball builds +- Fixture dep: left-pad@1.3.0 (orig tgz sha1 `5b8a3a7765dfe001261dde915589e782f8c94d1e`) +- Vendored path: `.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz`, + patched file = `index.js` with first line `/* socket-patch-marker 9f6b2c4e-... */` + +Every `after/yarn.lock` in `fixtures/` was generated by `yarn install` itself (never +hand-written). The only hand-edited lock is `fixtures/b1-omitted-checksum/before/yarn.lock` +(checksum line deleted — that hand-edit IS the B1 probe input). + +## B3 — resolutions ground truth (fixtures/b3-vendored-resolutions/) + +package.json change required: add a `resolutions` entry (the regular dependency stays a +registry range): + +```json +"dependencies": { "left-pad": "1.3.0" }, +"resolutions": { "left-pad": "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" } +``` + +`.yarnrc.yml`: `nodeLinker: node-modules`, `enableGlobalCache: true`, `enableTelemetry: false`. + +Lock entry yarn 4.12.0 writes (VERBATIM): + +``` +"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": + version: 1.3.0 + resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." + checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 + languageName: node + linkType: hard +``` + +- Lock KEY uses `::locator=%40workspace%3A.` (URL-encoded + `vendor-spike@workspace:.`) — the key embeds the workspace **name** from package.json. +- `hash=39ea9b` = **first 6 hex chars of sha512 of the tgz bytes** (verified: sha512 of + the patched tgz starts `39ea9bc99fb9`; tampering the tgz flipped it to `b4fd84`). +- `checksum: 10c0/<128 hex>` = sha512 of the **converted cache zip** (see B2), prefix + `10` = internal CACHE_VERSION, `c0` = compressionLevel 0 (yarn 4 default). +- Fresh clone of exactly {package.json, yarn.lock, .yarnrc.yml, .socket/} with empty + caches: `yarn install --immutable` passes — see B5 (passes even with network disabled). +- before/ = registry-only project, lock entry for comparison: + `"left-pad@npm:1.3.0"` / `resolution: "left-pad@npm:1.3.0"` / + `checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955`. + +## B1 — omitted-checksum probe: PASSES, does NOT fail (fixtures/b1-omitted-checksum/) + +Expectation (YN0028/YN0018 failure) was WRONG. With the `checksum:` line deleted from the +B3 lock, **cold cache**: + +``` +$ yarn install --immutable # yarn 4.12.0, empty YARN_GLOBAL_FOLDER +➤ YN0000: · Yarn 4.12.0 +➤ YN0000: ┌ Resolution step ... └ Completed +➤ YN0000: ┌ Fetch step +➤ YN0013: │ A package was added to the project (+ 11.32 KiB). +➤ YN0000: ┌ Link step ... └ Completed +➤ YN0000: · Done in 0s 52ms +EXIT=0 +``` + +`yarn install --immutable --check-cache` afterwards: also EXIT=0 (no YN0028, no YN0018). +Installed bytes ARE the patched ones (`/* socket-patch-marker 9f6b2c4e-... */` present in +`node_modules/left-pad/index.js`). Surprise #2: the `--immutable` run **rewrote +yarn.lock**, re-adding the checksum line (after/yarn.lock, tool-generated, is +byte-identical to the B3 lock). A missing checksum is "trust on first use + self-heal", +not an immutability violation. + +Tamper guards still hold (cold cache, intact committed lock): +- **Tampered tgz** (one byte of marker changed) → resolution recomputes `hash=b4fd84`, + post-resolution validation prints a lock diff and fails: + `➤ YN0028: │ The lockfile would have been modified by this install, which is explicitly forbidden.` EXIT=1. +- **Corrupted checksum hex** (present but wrong) → + `➤ YN0018: │ left-pad@file:...::hash=39ea9b&locator=...: The remote archive doesn't match the expected checksum` EXIT=1. + +So the verification chain for a committed vendored artifact is: lock `hash=` pins the tgz +bytes (sha512 prefix), lock `checksum:` pins the converted zip (full sha512). Omitting the +checksum weakens nothing for tamper-detection of the tgz (hash= still catches it) but we +should always emit the checksum anyway — it is reproducible (B2). + +## B2 — DECISIVE: checksum reproducible offline from the tgz. PASS. + +1. `sha512(cache zip) == lock checksum hex` — confirmed exactly: + `left-pad-file-8dfd6a0c16-10c0.zip` sha512 = `7785879d...8d7ca3` = the `10c0/` hex. +2. `rebuild_zip.py left-pad out.zip` (python stdlib, offline, no yarn) produces a + zip **byte-identical** (`cmp` clean) to yarn's cache zip. Verified for: + - yarn 4.12.0, macOS (this fixture) + - yarn 4.6.0: same lock checksum + same `hash=39ea9b` (cold cache, fresh install) + - TZ=Asia/Kathmandu fresh install: same checksum (DOS timestamps written as UTC, + not host-local time) + - `modeprobe.tgz` (files 0600/0664/0444/0755, dir 0700): byte-identical after + encoding yarn's mode normalization (below). + +### The recipe (everything that is in the zip, nothing else) +- **Name mapping**: strip first path component of each tar entry (`package/`), prefix + `node_modules//`. +- **Entry order**: tar order; parent directory entries are emitted on first need + (mkdirp): `node_modules/`, `node_modules//` appear before the first entry, + deeper dirs (e.g. `perf/`) at the tar position that first references them. +- **Compression**: stored (method 0) for every entry — that's the `c0` in `10c0`. +- **Timestamps**: every entry dosdate=0x08D6 dostime=0xAE40 = 1984-06-22 21:50:00, + i.e. yarn SAFE_TIME=456789000 rendered as UTC. No extended-timestamp extra field. +- **Modes (normalized by yarn, not copied from tar)**: files → `0o100644`, or + `0o100755` iff tar mode has any exec bit (0600→644, 0664→644, 0444→644, 0755→755); + dirs → always `0o40755` (0700→755). external_attr = mode << 16, low 16 bits 0 + (no MS-DOS dir bit). internal_attr = 0. +- **Local headers**: version-needed 10 (files) / 20 (dirs); flags 0x0000 (no data + descriptor, no UTF-8 flag for ASCII names); crc/sizes inline (0 for dirs); NO extra + field. +- **Central directory**: version-made-by 0x033F ((3<<8)|63 = UNIX, spec 6.3); NO extra + field, NO comments; one CDH per LFH in same order. +- **EOCD**: single disk, no zip64, no archive comment. Total file = LFHs+data, then + central dir, then EOCD. (left-pad zip: 11599 bytes, 13 entries.) +- **Checksum**: `10c0/` + sha512 hex of the whole zip file. (`10` = yarn's internal + CACHE_VERSION — not user-settable; bump risk on yarn major.) +- **Cache file name**: global cache `left-pad-file-8dfd6a0c16-10c0.zip`; project-local + mirror (`enableGlobalCache: false`) embeds the checksum head instead: + `left-pad-file-8dfd6a0c16-7785879d9a.zip`. + +## B4 — .yarnrc.yml knobs (fixtures/b4-compression-mixed/) + +- `compressionLevel: mixed` (or any non-zero) CHANGES the checksum AND the cache key: + lock gets `cacheKey: 10` and `checksum: 10/fdd30d4a91e92c85b92fd3f1757629b5c35516ab20058a1688a73181cd61dc03980cf5fbb7fe99d3af2a35b32fef2e07653173642e0839df9bd4b298f18fdb5d`; + files become deflate (method 8) where it helps. Reproducing THAT would require matching + yarn's embedded zlib bit-for-bit — do not support it; require/assume `compressionLevel: 0` + (the yarn 4 default) and treat a non-`10c0` cacheKey as "regenerate lock via yarn". +- `cacheVersion`: NOT a setting — `yarn config get cacheVersion` → + `Usage Error: Couldn't find a configuration settings named "cacheVersion"`. The `10` is + internal. +- Settings that exist but don't change fresh-install checksums: `cacheFolder`, + `enableGlobalCache`, `cacheMigrationMode`, `enableImmutableCache`. + +## B5 — strictest fresh-checkout proof: PASS + +Copied exactly `package.json + yarn.lock + .yarnrc.yml + .socket/` (B3 after/) into a +brand-new mktemp dir; `YARN_GLOBAL_FOLDER=`, `YARN_ENABLE_GLOBAL_CACHE=false`, +**and `YARN_ENABLE_NETWORK=false`** (stricter than asked): + +``` +$ yarn install --immutable --check-cache +➤ YN0000: · Yarn 4.12.0 +➤ YN0013: │ A package was added to the project (+ 11.32 KiB). +➤ YN0000: · Done in 0s 59ms +EXIT=0 +``` + +Marker present in `node_modules/left-pad/index.js`; the project-local +`.yarn/cache/left-pad-file-8dfd6a0c16-7785879d9a.zip` sha512 == lock checksum. Fully +offline: the file: protocol never touches the registry when the lock is complete. + +## Layout +- `rebuild_zip.py` — offline tgz→berry-cache-zip rebuilder (the recipe, executable). +- `fixtures/b3-vendored-resolutions/{before,after}` — registry project vs vendored + resolutions project; both locks yarn-generated. +- `fixtures/b1-omitted-checksum/{before,after}` — before = checksum line hand-deleted + (probe input); after = lock as yarn rewrote it under `--immutable` (self-healed). +- `fixtures/b2-zip-reproducibility/` — orig/patched/modeprobe tgz, yarn's cache zips, + and the byte-identical rebuilt zips (`cmp` verifiable: rebuilt-from-tgz.zip vs + yarn-cache-left-pad-file-8dfd6a0c16-10c0.zip; rebuilt-modeprobe.zip vs + yarn-cache-modeprobe-file-10c0.zip). +- `fixtures/b4-compression-mixed/{before,after}` — same project, compressionLevel + default(0) vs mixed; shows cacheKey `10c0` → `10` and checksum change. + +## Caveats for the design +- Lock key + resolution embed the root workspace name and relative tgz path; renaming the + package.json `name` or moving the tgz invalidates the entry (YN0028 under --immutable). +- The `10` cache version is yarn-internal and has historically bumped on yarn majors + (8→10 across v3→v4); the recipe must be re-validated per cacheKey, and a non-10c0 + cacheKey in the user's lock means "let yarn write the checksum" (which `--immutable` + tolerates for missing checksums per B1, but emit it ourselves when cacheKey is 10c0). +- nodeLinker: node-modules tested; pnp linker untested in this spike (cache/checksum + layer is linker-independent, but unverified here). +- Tarballs with symlink/hardlink entries untested (npm pack never emits them). diff --git a/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/after/yarn.lock b/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/after/yarn.lock new file mode 100644 index 0000000..ea10f55 --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/after/yarn.lock @@ -0,0 +1,21 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": + version: 1.3.0 + resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." + checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 + languageName: node + linkType: hard + +"vendor-spike@workspace:.": + version: 0.0.0-use.local + resolution: "vendor-spike@workspace:." + dependencies: + left-pad: "npm:1.3.0" + languageName: unknown + linkType: soft diff --git a/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/.yarnrc.yml b/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/.yarnrc.yml new file mode 100644 index 0000000..22efd3d --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/.yarnrc.yml @@ -0,0 +1,3 @@ +nodeLinker: node-modules +enableGlobalCache: true +enableTelemetry: false diff --git a/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/package.json b/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/package.json new file mode 100644 index 0000000..11fd5af --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/package.json @@ -0,0 +1,11 @@ +{ + "name": "vendor-spike", + "version": "1.0.0", + "packageManager": "yarn@4.12.0", + "dependencies": { + "left-pad": "1.3.0" + }, + "resolutions": { + "left-pad": "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } +} diff --git a/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/yarn.lock b/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/yarn.lock new file mode 100644 index 0000000..c247723 --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/yarn.lock @@ -0,0 +1,20 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": + version: 1.3.0 + resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." + languageName: node + linkType: hard + +"vendor-spike@workspace:.": + version: 0.0.0-use.local + resolution: "vendor-spike@workspace:." + dependencies: + left-pad: "npm:1.3.0" + languageName: unknown + linkType: soft diff --git a/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/rebuilt-from-tgz.zip b/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/rebuilt-from-tgz.zip new file mode 100644 index 0000000000000000000000000000000000000000..a0b1e8023aed65d424bc95be5275b89bdad00333 GIT binary patch literal 11599 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 ir{PVTs2tv!@kDqej6)-~J8q*Efm) literal 0 HcmV?d00001 diff --git a/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/rebuilt-modeprobe.zip b/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/rebuilt-modeprobe.zip new file mode 100644 index 0000000000000000000000000000000000000000..fbc7645e1ee8560464bb05a4a05ee3274dd83c0e GIT binary patch literal 1012 zcmWIWW@Zs#00D<}*EqloD8UP)^YT+t<8$*2@bp%mpk&Rm`D7sYs+-Ug1I(Z$jn8l!gq$?cJ&7Lc2m}2Xak~tb j2GC7Ik934-bMcr4O9BDjtZX11>_Er}OgDFddKef0ilp?C literal 0 HcmV?d00001 diff --git a/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-left-pad-file-8dfd6a0c16-10c0.zip b/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-left-pad-file-8dfd6a0c16-10c0.zip new file mode 100644 index 0000000000000000000000000000000000000000..a0b1e8023aed65d424bc95be5275b89bdad00333 GIT binary patch literal 11599 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 ir{PVTs2tv!@kDqej6)-~J8q*Efm) literal 0 HcmV?d00001 diff --git a/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-modeprobe-file-10c0.zip b/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-modeprobe-file-10c0.zip new file mode 100644 index 0000000000000000000000000000000000000000..fbc7645e1ee8560464bb05a4a05ee3274dd83c0e GIT binary patch literal 1012 zcmWIWW@Zs#00D<}*EqloD8UP)^YT+t<8$*2@bp%mpk&Rm`D7sYs+-Ug1I(Z$jn8l!gq$?cJ&7Lc2m}2Xak~tb j2GC7Ik934-bMcr4O9BDjtZX11>_Er}OgDFddKef0ilp?C literal 0 HcmV?d00001 diff --git a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/.yarnrc.yml b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/.yarnrc.yml new file mode 100644 index 0000000..22efd3d --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/.yarnrc.yml @@ -0,0 +1,3 @@ +nodeLinker: node-modules +enableGlobalCache: true +enableTelemetry: false diff --git a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/package.json b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/package.json new file mode 100644 index 0000000..11fd5af --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/package.json @@ -0,0 +1,11 @@ +{ + "name": "vendor-spike", + "version": "1.0.0", + "packageManager": "yarn@4.12.0", + "dependencies": { + "left-pad": "1.3.0" + }, + "resolutions": { + "left-pad": "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } +} diff --git a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/yarn.lock b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/yarn.lock new file mode 100644 index 0000000..ea10f55 --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/yarn.lock @@ -0,0 +1,21 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": + version: 1.3.0 + resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." + checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 + languageName: node + linkType: hard + +"vendor-spike@workspace:.": + version: 0.0.0-use.local + resolution: "vendor-spike@workspace:." + dependencies: + left-pad: "npm:1.3.0" + languageName: unknown + linkType: soft diff --git a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/.yarnrc.yml b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/.yarnrc.yml new file mode 100644 index 0000000..22efd3d --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/.yarnrc.yml @@ -0,0 +1,3 @@ +nodeLinker: node-modules +enableGlobalCache: true +enableTelemetry: false diff --git a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/package.json b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/package.json new file mode 100644 index 0000000..712413b --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/package.json @@ -0,0 +1,8 @@ +{ + "name": "vendor-spike", + "version": "1.0.0", + "packageManager": "yarn@4.12.0", + "dependencies": { + "left-pad": "1.3.0" + } +} diff --git a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/yarn.lock b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/yarn.lock new file mode 100644 index 0000000..a328cb0 --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/yarn.lock @@ -0,0 +1,21 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"left-pad@npm:1.3.0": + version: 1.3.0 + resolution: "left-pad@npm:1.3.0" + checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955 + languageName: node + linkType: hard + +"vendor-spike@workspace:.": + version: 0.0.0-use.local + resolution: "vendor-spike@workspace:." + dependencies: + left-pad: "npm:1.3.0" + languageName: unknown + linkType: soft diff --git a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/.yarnrc.yml b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/.yarnrc.yml new file mode 100644 index 0000000..c4d8047 --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/.yarnrc.yml @@ -0,0 +1,4 @@ +nodeLinker: node-modules +enableGlobalCache: true +enableTelemetry: false +compressionLevel: mixed diff --git a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/package.json b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/package.json new file mode 100644 index 0000000..11fd5af --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/package.json @@ -0,0 +1,11 @@ +{ + "name": "vendor-spike", + "version": "1.0.0", + "packageManager": "yarn@4.12.0", + "dependencies": { + "left-pad": "1.3.0" + }, + "resolutions": { + "left-pad": "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } +} diff --git a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/yarn.lock b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/yarn.lock new file mode 100644 index 0000000..8c7cdc2 --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/yarn.lock @@ -0,0 +1,21 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10 + +"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": + version: 1.3.0 + resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." + checksum: 10/fdd30d4a91e92c85b92fd3f1757629b5c35516ab20058a1688a73181cd61dc03980cf5fbb7fe99d3af2a35b32fef2e07653173642e0839df9bd4b298f18fdb5d + languageName: node + linkType: hard + +"vendor-spike@workspace:.": + version: 0.0.0-use.local + resolution: "vendor-spike@workspace:." + dependencies: + left-pad: "npm:1.3.0" + languageName: unknown + linkType: soft diff --git a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/.yarnrc.yml b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/.yarnrc.yml new file mode 100644 index 0000000..22efd3d --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/.yarnrc.yml @@ -0,0 +1,3 @@ +nodeLinker: node-modules +enableGlobalCache: true +enableTelemetry: false diff --git a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/package.json b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/package.json new file mode 100644 index 0000000..11fd5af --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/package.json @@ -0,0 +1,11 @@ +{ + "name": "vendor-spike", + "version": "1.0.0", + "packageManager": "yarn@4.12.0", + "dependencies": { + "left-pad": "1.3.0" + }, + "resolutions": { + "left-pad": "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } +} diff --git a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/yarn.lock b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/yarn.lock new file mode 100644 index 0000000..ea10f55 --- /dev/null +++ b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/yarn.lock @@ -0,0 +1,21 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": + version: 1.3.0 + resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." + checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 + languageName: node + linkType: hard + +"vendor-spike@workspace:.": + version: 0.0.0-use.local + resolution: "vendor-spike@workspace:." + dependencies: + left-pad: "npm:1.3.0" + languageName: unknown + linkType: soft diff --git a/spikes/yarn-berry-nm/rebuild_zip.py b/spikes/yarn-berry-nm/rebuild_zip.py new file mode 100644 index 0000000..32ea3fe --- /dev/null +++ b/spikes/yarn-berry-nm/rebuild_zip.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Rebuild a yarn berry 4.x (cacheKey 10c0, compressionLevel 0) cache zip +from an npm-style tarball, byte-identically. Offline; stdlib only. + +Usage: rebuild_zip.py + +Recipe (empirically derived from yarn 4.12.0 cache zips, libzip-wasm output): +- Entry order : tar order. Parent dirs are emitted on first need (mkdirp), + i.e. `node_modules/` + `node_modules//` appear before + the first entry that needs them; deeper dirs appear at the + tar position that first references them. +- Name mapping : strip the first path component of each tar entry (npm uses + `package/`), prefix with `node_modules//`. +- Compression : 0 (stored) for every entry -> cacheKey suffix `c0`. +- mtime : DOS time of 1984-06-22 21:50:00 (yarn SAFE_TIME=456789000), + written as UTC -> dosdate=0x08D6 dostime=0xAE40. +- Flags : 0x0000 (no data descriptor, no UTF-8 flag for ASCII names). +- Local header : version-needed = 10 for files, 20 for directories; + no extra field, sizes+crc inline (crc=0/sizes=0 for dirs). +- Central dir : version-made-by = 0x033F (UNIX, spec 6.3 -> (3<<8)|63); + internal attrs = 0; external attrs = (unix mode) << 16, + files NORMALIZED to 0o100644, or 0o100755 if tar mode has + any exec bit (yarn discards other perm bits); dirs always + 0o40755 regardless of tar mode; + no extra field, no comment. +- EOCD : single disk, no zip64, no archive comment. +""" +import sys, tarfile, struct + +DOSTIME = 0xAE40 # 21:50:00 +DOSDATE = 0x08D6 # 1984-06-22 + +def rebuild(tgz_path, pkg_name, out_path): + prefix = f"node_modules/{pkg_name}" + entries = [] # (name, is_dir, mode, data) + seen_dirs = set() + + def mkdirp(dirpath): # dirpath WITHOUT trailing slash + parts = dirpath.split('/') + for i in range(1, len(parts) + 1): + d = '/'.join(parts[:i]) + '/' + if d not in seen_dirs: + seen_dirs.add(d) + entries.append((d, True, 0o40755, b'')) + + with tarfile.open(tgz_path, 'r:gz') as tf: + for m in tf: + stripped = '/'.join(m.name.split('/')[1:]).rstrip('/') + if m.isdir(): + mkdirp(prefix + ('/' + stripped if stripped else '')) + elif m.isfile(): + target = f"{prefix}/{stripped}" + mkdirp(target.rsplit('/', 1)[0]) + data = tf.extractfile(m).read() + mode = 0o100755 if (m.mode & 0o111) else 0o100644 + entries.append((target, False, mode, data)) + + import zlib + blob = bytearray(); central = bytearray(); offsets = [] + for name, is_dir, mode, data in entries: + offsets.append(len(blob)) + crc = 0 if is_dir else zlib.crc32(data) & 0xFFFFFFFF + vneed = 20 if is_dir else 10 + nb = name.encode() + blob += struct.pack('<4sHHHHHIIIHH', b'PK\x03\x04', vneed, 0, 0, + DOSTIME, DOSDATE, crc, len(data), len(data), + len(nb), 0) + nb + data + for (name, is_dir, mode, data), lho in zip(entries, offsets): + crc = 0 if is_dir else zlib.crc32(data) & 0xFFFFFFFF + vneed = 20 if is_dir else 10 + nb = name.encode() + central += struct.pack('<4sHHHHHHIIIHHHHHII', b'PK\x01\x02', 0x033F, + vneed, 0, 0, DOSTIME, DOSDATE, crc, len(data), + len(data), len(nb), 0, 0, 0, 0, mode << 16, + lho) + nb + eocd = struct.pack('<4sHHHHIIH', b'PK\x05\x06', 0, 0, len(entries), + len(entries), len(central), len(blob), 0) + with open(out_path, 'wb') as f: + f.write(blob + central + eocd) + +if __name__ == '__main__': + rebuild(sys.argv[1], sys.argv[2], sys.argv[3]) diff --git a/spikes/yarn-classic/README.md b/spikes/yarn-classic/README.md new file mode 100644 index 0000000..099a370 --- /dev/null +++ b/spikes/yarn-classic/README.md @@ -0,0 +1,134 @@ +# yarn classic (1.22.x) vendored-tarball spike fixtures + +Spike for socket-patch vendor v2: can a lock-only rewrite make yarn classic install a +patched, committed tarball from `.socket/vendor/npm//-.tgz`, +checksum-verified, with cold caches, offline? + +**Answer: yes.** All claims confirmed (Y5 with one caveat about the unsatisfiable +`^1.3.2` range, see below). + +## Tool versions + +- node v24.12.0 (Darwin 25.5.0, arm64) +- corepack 0.34.5 +- yarn 1.22.22 (via `corepack yarn`, pinned by `"packageManager": "yarn@1.22.22"`) +- yarn 4.12.0 (berry sniff fixture only) +- patched tarball built with macOS bsdtar (`tar czf`, `COPYFILE_DISABLE=1`), + digests via `shasum -a 1` / `openssl dgst -sha512 -binary | base64` + +## The patched artifact + +`left-pad@1.3.0` from registry.npmjs.org, unpacked, marker line +`/* SOCKET-PATCHED left-pad@1.3.0 marker:9f6b2c4e */` prepended to +`package/index.js`, repacked with `package/` prefix. + +- registry tgz sha1: `5b8a3a7765dfe001261dde915589e782f8c94d1e` +- patched tgz sha1: `fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6` +- patched tgz sha512 SRI: `sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw==` + +## Lockfile entry recipe (the v2 rewrite) + +``` +left-pad@^1.3.0: + version "1.3.0" + resolved "file:./.socket/vendor/npm//left-pad-1.3.0.tgz#" + integrity sha512- +``` + +- `resolved` spellings that work: `file:./#` and `./#`. + A path with **no** `./`/`file:` prefix does NOT work: yarn treats it as + registry-relative and requests `https://registry.yarnpkg.com/.socket/...` (404). +- The `#` fragment is the sha1 of the tgz bytes; yarn enforces it even when + the `integrity` line is absent (substituting a wrong tarball fails + `Integrity check failed` either way). +- yarn's own serializer round-trips this entry byte-for-byte (verified by forcing a + lock re-save with `yarn add isarray@2.0.5` + `yarn remove isarray`): every + `after/yarn.lock` here was emitted by yarn 1.22.22 itself, not hand-written. + +## Fixture pairs + +### y1-file-dep-ground-truth/ +Ground truth for how yarn classic natively records a `file:` tarball dep. +`package.json` has `"lp": "file:./lp.tgz"` (lp.tgz = the patched tarball); +`yarn.lock` is exactly what `yarn install` wrote: + +``` +"lp@file:./lp.tgz": + version "1.3.0" + resolved "file:./lp.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" +``` + +Key shape `"@file:./"`, `file:` prefix kept in `resolved`, `#sha1` +fragment present, **no `integrity` line** for native file: deps. + +### y2-lock-rewrite/ (before -> after) +- `before/`: registry project (`left-pad: ^1.3.0`), yarn-generated lock pointing at + `https://registry.yarnpkg.com/...#5b8a3a...` with the registry sha512. +- `after/`: same package.json; lock's left-pad block rewritten to the recipe above; + patched tarball committed at + `.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz`. + +Replay: `rm -rf node_modules && YARN_CACHE_FOLDER=$(mktemp -d) corepack yarn install --offline --frozen-lockfile` +-> exit 0, `node_modules/left-pad/index.js` carries the marker, yarn.lock +byte-unchanged. Also passes with HTTP(S)_PROXY pointed at a dead port (zero network). + +### y4-tamper/ (failure fixture) +`after/` is y2's after but `.socket/.../left-pad-1.3.0.tgz` is the **unpatched +registry tarball** (valid gzip, wrong hashes). Frozen install MUST fail, exit 1: + +``` +error Integrity check failed for "left-pad" (computed integrity doesn't match our records, got "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== sha1-W4o6d2Xf4AEmHd6RVYnngvjJTR4=") +``` + +(A raw byte-flip also fails, exit 1, but earlier and uglier — gzip error +`"invalid distance too far back". Mirror tarball appears to be corrupt.`) + +### y5-merged-alias/ (before -> after) +- `before/`: root deps `left-pad: ^1.3.0`, `alias: npm:left-pad@^1.3.0`, and a + folder dep `dep-a` (file:./dep-a) requiring `left-pad: ~1.3.0`, so yarn itself + generates a **merged** block `left-pad@^1.3.0, left-pad@~1.3.0:` plus a separate + alias block `"alias@npm:left-pad@^1.3.0":`. +- `after/`: both blocks' resolved+integrity rewritten to the vendored tarball + (then re-serialized by yarn, byte-identical). + +Replay: both `node_modules/left-pad` and `node_modules/alias` (an aliased copy of +left-pad@1.3.0) carry the marker; lock unchanged. + +Caveat: the claim's literal `left-pad@^1.3.2` range is unsatisfiable (1.3.0 is the +last left-pad ever published), so the merged block was generated with +`^1.3.0, ~1.3.0` instead. Merging behavior is the same: one block, N keys, one +resolved — a single rewrite patches every requester. + +### y8-berry-sniff/ +yarn 4.12.0 project with `nodeLinker: node-modules`. Its yarn.lock starts with a +generated-file comment + `__metadata:` (version 8, cacheKey 10c0) and contains +**no** `# yarn lockfile v1` header. Classic locks always carry +`# yarn lockfile v1`. Sniff rule: `__metadata:` => berry (different rewrite +strategy needed — berry verifies `checksum:` against its own cache format); +`# yarn lockfile v1` => classic (this recipe applies). + +## Behavioral claims verified without a dedicated fixture dir + +- **Y3 warm-cache poisoning**: cache primed with the registry tarball + (`v6/npm-left-pad-1.3.0-5b8a3a...-integrity`), then the vendored install run + against the same cache -> patched bytes installed. Cache entries are keyed + `npm----integrity`, so registry and vendored artifacts get + distinct slots; no poisoning either direction. +- **Y6 offline fresh checkout**: copying only package.json + yarn.lock + .socket + into an empty dir, empty cache, `--offline --frozen-lockfile` -> exit 0, patched. + Re-verified with dead HTTP(S)_PROXY: no network touched for the file: dep. +- **Y7 resolution base**: `corepack yarn --cwd install --frozen-lockfile` + run from an unrelated directory containing a decoy unpatched tarball at the same + relative path -> the decoy is ignored and the project's tarball is used (relative + `resolved` resolves against the project/lockfile dir, not process cwd). Running + from a nested subdir of the project (no --cwd) also resolves correctly. + +## Notes for the tool design + +- Write `resolved "file:./#"` + `integrity sha512-...`. Both hash + layers are enforced by yarn classic on every install, frozen or not, warm or + cold cache. +- `--frozen-lockfile` does not rewrite the lock; a plain `yarn install` keeps the + entry stable, and forced re-serialization preserves it byte-for-byte. +- The lock-only rewrite leaves package.json untouched (`left-pad@^1.3.0` range key + still matches), so no manifest churn. diff --git a/spikes/yarn-classic/y1-file-dep-ground-truth/package.json b/spikes/yarn-classic/y1-file-dep-ground-truth/package.json new file mode 100644 index 0000000..ffc393b --- /dev/null +++ b/spikes/yarn-classic/y1-file-dep-ground-truth/package.json @@ -0,0 +1 @@ +{"name":"t","version":"1.0.0","packageManager":"yarn@1.22.22","dependencies":{"lp":"file:./lp.tgz"}} diff --git a/spikes/yarn-classic/y1-file-dep-ground-truth/yarn.lock b/spikes/yarn-classic/y1-file-dep-ground-truth/yarn.lock new file mode 100644 index 0000000..fda8a6e --- /dev/null +++ b/spikes/yarn-classic/y1-file-dep-ground-truth/yarn.lock @@ -0,0 +1,7 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"lp@file:./lp.tgz": + version "1.3.0" + resolved "file:./lp.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" diff --git a/spikes/yarn-classic/y2-lock-rewrite/after/package.json b/spikes/yarn-classic/y2-lock-rewrite/after/package.json new file mode 100644 index 0000000..cd59d88 --- /dev/null +++ b/spikes/yarn-classic/y2-lock-rewrite/after/package.json @@ -0,0 +1,8 @@ +{ + "name": "t", + "version": "1.0.0", + "packageManager": "yarn@1.22.22", + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/spikes/yarn-classic/y2-lock-rewrite/after/yarn.lock b/spikes/yarn-classic/y2-lock-rewrite/after/yarn.lock new file mode 100644 index 0000000..36a6bd3 --- /dev/null +++ b/spikes/yarn-classic/y2-lock-rewrite/after/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +left-pad@^1.3.0: + version "1.3.0" + resolved "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" + integrity sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw== diff --git a/spikes/yarn-classic/y2-lock-rewrite/before/package.json b/spikes/yarn-classic/y2-lock-rewrite/before/package.json new file mode 100644 index 0000000..48e0783 --- /dev/null +++ b/spikes/yarn-classic/y2-lock-rewrite/before/package.json @@ -0,0 +1 @@ +{"name":"t","version":"1.0.0","packageManager":"yarn@1.22.22","dependencies":{"left-pad":"^1.3.0"}} diff --git a/spikes/yarn-classic/y2-lock-rewrite/before/yarn.lock b/spikes/yarn-classic/y2-lock-rewrite/before/yarn.lock new file mode 100644 index 0000000..9660884 --- /dev/null +++ b/spikes/yarn-classic/y2-lock-rewrite/before/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +left-pad@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== diff --git a/spikes/yarn-classic/y4-tamper/after/package.json b/spikes/yarn-classic/y4-tamper/after/package.json new file mode 100644 index 0000000..cd59d88 --- /dev/null +++ b/spikes/yarn-classic/y4-tamper/after/package.json @@ -0,0 +1,8 @@ +{ + "name": "t", + "version": "1.0.0", + "packageManager": "yarn@1.22.22", + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/spikes/yarn-classic/y4-tamper/after/yarn.lock b/spikes/yarn-classic/y4-tamper/after/yarn.lock new file mode 100644 index 0000000..36a6bd3 --- /dev/null +++ b/spikes/yarn-classic/y4-tamper/after/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +left-pad@^1.3.0: + version "1.3.0" + resolved "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" + integrity sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw== diff --git a/spikes/yarn-classic/y5-merged-alias/after/dep-a/package.json b/spikes/yarn-classic/y5-merged-alias/after/dep-a/package.json new file mode 100644 index 0000000..5cf9b11 --- /dev/null +++ b/spikes/yarn-classic/y5-merged-alias/after/dep-a/package.json @@ -0,0 +1 @@ +{"name":"dep-a","version":"1.0.0","dependencies":{"left-pad":"~1.3.0"}} diff --git a/spikes/yarn-classic/y5-merged-alias/after/package.json b/spikes/yarn-classic/y5-merged-alias/after/package.json new file mode 100644 index 0000000..5ef79af --- /dev/null +++ b/spikes/yarn-classic/y5-merged-alias/after/package.json @@ -0,0 +1,10 @@ +{ + "name": "t", + "version": "1.0.0", + "packageManager": "yarn@1.22.22", + "dependencies": { + "alias": "npm:left-pad@^1.3.0", + "dep-a": "file:./dep-a", + "left-pad": "^1.3.0" + } +} diff --git a/spikes/yarn-classic/y5-merged-alias/after/yarn.lock b/spikes/yarn-classic/y5-merged-alias/after/yarn.lock new file mode 100644 index 0000000..7867bbf --- /dev/null +++ b/spikes/yarn-classic/y5-merged-alias/after/yarn.lock @@ -0,0 +1,18 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"alias@npm:left-pad@^1.3.0": + version "1.3.0" + resolved "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" + integrity sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw== + +"dep-a@file:./dep-a": + version "1.0.0" + dependencies: + left-pad "~1.3.0" + +left-pad@^1.3.0, left-pad@~1.3.0: + version "1.3.0" + resolved "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" + integrity sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw== diff --git a/spikes/yarn-classic/y5-merged-alias/before/dep-a/package.json b/spikes/yarn-classic/y5-merged-alias/before/dep-a/package.json new file mode 100644 index 0000000..5cf9b11 --- /dev/null +++ b/spikes/yarn-classic/y5-merged-alias/before/dep-a/package.json @@ -0,0 +1 @@ +{"name":"dep-a","version":"1.0.0","dependencies":{"left-pad":"~1.3.0"}} diff --git a/spikes/yarn-classic/y5-merged-alias/before/package.json b/spikes/yarn-classic/y5-merged-alias/before/package.json new file mode 100644 index 0000000..e08815e --- /dev/null +++ b/spikes/yarn-classic/y5-merged-alias/before/package.json @@ -0,0 +1 @@ +{"name":"t","version":"1.0.0","packageManager":"yarn@1.22.22","dependencies":{"left-pad":"^1.3.0","alias":"npm:left-pad@^1.3.0","dep-a":"file:./dep-a"}} diff --git a/spikes/yarn-classic/y5-merged-alias/before/yarn.lock b/spikes/yarn-classic/y5-merged-alias/before/yarn.lock new file mode 100644 index 0000000..82dbc36 --- /dev/null +++ b/spikes/yarn-classic/y5-merged-alias/before/yarn.lock @@ -0,0 +1,18 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"alias@npm:left-pad@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== + +"dep-a@file:./dep-a": + version "1.0.0" + dependencies: + left-pad "~1.3.0" + +left-pad@^1.3.0, left-pad@~1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== diff --git a/spikes/yarn-classic/y8-berry-sniff/.yarnrc.yml b/spikes/yarn-classic/y8-berry-sniff/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/spikes/yarn-classic/y8-berry-sniff/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/spikes/yarn-classic/y8-berry-sniff/package.json b/spikes/yarn-classic/y8-berry-sniff/package.json new file mode 100644 index 0000000..88f1881 --- /dev/null +++ b/spikes/yarn-classic/y8-berry-sniff/package.json @@ -0,0 +1,8 @@ +{ + "name": "t8", + "version": "1.0.0", + "packageManager": "yarn@4.12.0", + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/spikes/yarn-classic/y8-berry-sniff/yarn.lock b/spikes/yarn-classic/y8-berry-sniff/yarn.lock new file mode 100644 index 0000000..73a20d1 --- /dev/null +++ b/spikes/yarn-classic/y8-berry-sniff/yarn.lock @@ -0,0 +1,21 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"left-pad@npm:^1.3.0": + version: 1.3.0 + resolution: "left-pad@npm:1.3.0" + checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955 + languageName: node + linkType: hard + +"t8@workspace:.": + version: 0.0.0-use.local + resolution: "t8@workspace:." + dependencies: + left-pad: "npm:^1.3.0" + languageName: unknown + linkType: soft diff --git a/tests/docker/Dockerfile.gem b/tests/docker/Dockerfile.gem index b588d47..4201bac 100644 --- a/tests/docker/Dockerfile.gem +++ b/tests/docker/Dockerfile.gem @@ -1,11 +1,36 @@ -# gem (Ruby) ecosystem test image: base + Ruby + bundler. -FROM socket-patch-test-base:latest +# gem (Ruby) ecosystem test image: Ruby 3.3 + bundler ~> 2.7 + socket-patch. +# +# Bundler is pinned to the 2.7 series — the version the gem vendor backend's +# lockfile grammar was spike-validated against (spikes/gem-checksums/). +# Bundler >= 2.7 requires Ruby >= 3.2 and Debian 12's apt ruby is 3.1, so this +# image follows the Dockerfile.nuget pattern: FROM the official ruby image, +# COPY socket-patch in from our base stage. +# +# NOTE for test scripts: the official ruby image sets +# `BUNDLE_APP_CONFIG=/usr/local/bundle`, which redirects `bundle config set +# --local` away from `./.bundle/config`. Scripts that need a project-local +# bundler config (e.g. the vendor capstone's committable `.bundle/config`) +# must `export BUNDLE_APP_CONFIG="$PWD/.bundle"` first. +FROM socket-patch-test-base:latest AS sptool +FROM ruby:3.3-slim-bookworm + +# Runtime utilities the test scripts and native-extension gem builds need +# (the slim ruby image drops git/curl/compilers; build-essential replaces the +# old image's ruby-dev + build-essential pair — the official ruby build ships +# its own headers). RUN apt-get update \ && apt-get install -y --no-install-recommends \ - ruby \ - ruby-dev \ build-essential \ + ca-certificates \ + curl \ + git \ && rm -rf /var/lib/apt/lists/* \ - && gem install bundler --no-document \ - && ruby --version && gem --version && bundle --version + && gem install bundler -v '~> 2.7' --no-document + +COPY --from=sptool /usr/local/bin/socket-patch /usr/local/bin/socket-patch +RUN ruby --version && gem --version && bundle --version \ + && bundle --version | grep -q 'Bundler version 2\.7\.' \ + && socket-patch --version + +WORKDIR /workspace diff --git a/tests/docker/Dockerfile.pypi b/tests/docker/Dockerfile.pypi index 8cab8df..ac962db 100644 --- a/tests/docker/Dockerfile.pypi +++ b/tests/docker/Dockerfile.pypi @@ -1,16 +1,17 @@ # pypi ecosystem test image: base + Python 3.11 + pip + venv + uv + -# poetry + pdm + hatch. +# poetry + pdm + hatch + pipenv. # # Debian 12 ships Python 3.11. We use a venv inside each test to keep # pip from needing `--break-system-packages` and to match real-world # user flow. # -# uv/poetry/pdm/hatch are installed from PyPI so the one image can drive -# every Python package manager the setup-matrix suite exercises -# (tests/setup_matrix/). The `--break-system-packages` flag is what -# Debian-packaged pip3 requires to install into the system site-packages; -# it's safe inside the disposable test container. Additive: the existing -# docker_e2e_pypi tests (pip + uv) are unaffected. +# uv/poetry/pdm/hatch/pipenv are installed from PyPI so the one image can +# drive every Python package manager the setup-matrix suite exercises +# (tests/setup_matrix/) plus the pypi package-manager vendor suite (pipenv). +# The `--break-system-packages` flag is what Debian-packaged pip3 requires +# to install into the system site-packages; it's safe inside the disposable +# test container. Additive: the existing docker_e2e_pypi tests (pip + uv) +# are unaffected. FROM socket-patch-test-base:latest RUN apt-get update \ @@ -19,10 +20,11 @@ RUN apt-get update \ python3-pip \ python3-venv \ && rm -rf /var/lib/apt/lists/* \ - && pip3 install --break-system-packages --no-cache-dir uv poetry pdm hatch \ + && pip3 install --break-system-packages --no-cache-dir uv poetry pdm hatch pipenv \ && python3 --version \ && pip3 --version \ && uv --version \ && poetry --version \ && pdm --version \ - && hatch --version + && hatch --version \ + && pipenv --version diff --git a/tests/docker/README.md b/tests/docker/README.md index c4b128b..248819c 100644 --- a/tests/docker/README.md +++ b/tests/docker/README.md @@ -13,6 +13,8 @@ against a wiremock-served patch fixture. | npm | `npm install minimist@1.2.2` | install + scan + apply + verify patched marker on disk | | pypi | `pip install pydantic-ai==0.0.36` (in venv) | install + scan discovery | | gem | `gem install activestorage -v 5.2.0` (vendor/bundle) | install + scan discovery | +| composer (vendor) | `composer update` (psr/log 3.0.x) | `docker_e2e_vendor_composer`: vendor → fresh-checkout `composer install --network none` → revert (see below) | +| gem (vendor) | `bundle install` (rack ~> 3.1, bundler ~> 2.7) | `docker_e2e_vendor_gem`: vendor → fresh-checkout frozen `bundle install --network none` → revert (see below) | | cargo | `cargo fetch` with `serde = "=1.0.200"` in Cargo.toml | install + scan discovery | | golang | `go mod download github.com/gin-gonic/gin@v1.9.1` | install + scan discovery | | maven | `mvn dependency:get -Dartifact=org.apache.commons:commons-lang3:3.12.0` | install + scan discovery | @@ -57,6 +59,35 @@ cargo test -p socket-patch-cli --features docker-e2e \ A default `cargo test` (no `--features docker-e2e`) skips this entire suite. Developers who aren't editing the test infra never need Docker. +## Vendor capstone suites (`docker_e2e_vendor_*`) + +`tests/docker_e2e_vendor_composer.rs` and `tests/docker_e2e_vendor_gem.rs` +prove the CLI_CONTRACT "Vendor command contract" rows against the real +package managers. Unlike the scan→apply suites they are MULTI-STAGE: a host +tempdir is bind-mounted at `/workspace` and shared across three `docker run`s +(networked fixture install + offline `socket-patch vendor`; then a +fresh-checkout install under `--network none` with cold caches; then +idempotent re-vendor / `--revert` / re-vendor). Shared helpers live in +`tests/docker_vendor_common/mod.rs`. They reuse the same images and run the +same way: + +```sh +docker build -f tests/docker/Dockerfile.base -t socket-patch-test-base:latest . +docker build -f tests/docker/Dockerfile.composer -t socket-patch-test-composer:latest . +docker build -f tests/docker/Dockerfile.gem -t socket-patch-test-gem:latest . +cargo test -p socket-patch-cli --features docker-e2e \ + --test docker_e2e_vendor_composer --test docker_e2e_vendor_gem +``` + +Because the vendor capstones exercise the binary BAKED into the base image, +rebuild `Dockerfile.base` after changing vendor code or the runs test a +stale binary. Note `Dockerfile.gem` is built on the official ruby image with +bundler pinned `~> 2.7` (the series the gem vendor lock grammar was +spike-validated against; bundler >= 2.7 needs ruby >= 3.2, newer than +Debian 12's apt ruby). The gem suite runs against the default no-CHECKSUMS +lock — the bundler >= 2.6 `lockfile_checksums` variant is a follow-up +(see the TODO in `docker_e2e_vendor_gem.rs`). + ## Host mode (no Docker) Set `SOCKET_PATCH_TEST_HOST=1` to run the tests against host-installed From 8d5b9cd77402c7cd5fd4386eff26ea4b4c3f0d7a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 10:02:13 -0400 Subject: [PATCH 21/31] chore(vendor): declare v2 backend module stubs Co-Authored-By: Claude Fable 5 --- crates/socket-patch-core/src/patch/vendor/berry_zip.rs | 1 + crates/socket-patch-core/src/patch/vendor/bun_lock.rs | 1 + crates/socket-patch-core/src/patch/vendor/mod.rs | 8 ++++++++ crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs | 1 + crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs | 1 + crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs | 1 + crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs | 1 + .../socket-patch-core/src/patch/vendor/yarn_berry_lock.rs | 1 + .../src/patch/vendor/yarn_classic_lock.rs | 1 + 9 files changed, 16 insertions(+) create mode 100644 crates/socket-patch-core/src/patch/vendor/berry_zip.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/bun_lock.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs create mode 100644 crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs diff --git a/crates/socket-patch-core/src/patch/vendor/berry_zip.rs b/crates/socket-patch-core/src/patch/vendor/berry_zip.rs new file mode 100644 index 0000000..6c90a08 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/berry_zip.rs @@ -0,0 +1 @@ +//! (stub — backend lands behind the npm_flavor / pypi router) diff --git a/crates/socket-patch-core/src/patch/vendor/bun_lock.rs b/crates/socket-patch-core/src/patch/vendor/bun_lock.rs new file mode 100644 index 0000000..6c90a08 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/bun_lock.rs @@ -0,0 +1 @@ +//! (stub — backend lands behind the npm_flavor / pypi router) diff --git a/crates/socket-patch-core/src/patch/vendor/mod.rs b/crates/socket-patch-core/src/patch/vendor/mod.rs index 256253c..8735b62 100644 --- a/crates/socket-patch-core/src/patch/vendor/mod.rs +++ b/crates/socket-patch-core/src/patch/vendor/mod.rs @@ -49,16 +49,24 @@ pub mod composer_lock; pub mod gem; #[cfg(feature = "golang")] pub mod golang; +mod berry_zip; +pub mod bun_lock; mod npm_common; pub mod npm_flavor; pub mod npm_lock; pub mod npm_pack; +pub mod pnpm_lock; pub mod pypi; +pub mod pypi_pdm; +pub mod pypi_pipenv; +pub mod pypi_poetry; pub mod pypi_requirements; pub mod pypi_uv; pub mod pypi_wheel; mod toml_surgery; pub mod verify; +pub mod yarn_berry_lock; +pub mod yarn_classic_lock; pub use path::{ecosystem_dir_for_purl, parse_vendor_path, VendorPathParts, VENDOR_DIR}; pub use state::{load_state, save_state, VendorEntry, VendorState, VENDOR_STATE_REL}; diff --git a/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs new file mode 100644 index 0000000..6c90a08 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs @@ -0,0 +1 @@ +//! (stub — backend lands behind the npm_flavor / pypi router) diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs b/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs new file mode 100644 index 0000000..6c90a08 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs @@ -0,0 +1 @@ +//! (stub — backend lands behind the npm_flavor / pypi router) diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs b/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs new file mode 100644 index 0000000..6c90a08 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs @@ -0,0 +1 @@ +//! (stub — backend lands behind the npm_flavor / pypi router) diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs b/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs new file mode 100644 index 0000000..6c90a08 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs @@ -0,0 +1 @@ +//! (stub — backend lands behind the npm_flavor / pypi router) diff --git a/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs new file mode 100644 index 0000000..6c90a08 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs @@ -0,0 +1 @@ +//! (stub — backend lands behind the npm_flavor / pypi router) diff --git a/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs new file mode 100644 index 0000000..6c90a08 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs @@ -0,0 +1 @@ +//! (stub — backend lands behind the npm_flavor / pypi router) From a8a2e94d5f54298596ed65bcf3cd3a521946994a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 11:16:02 -0400 Subject: [PATCH 22/31] feat(vendor): v2 backend implementations (yarn classic/berry, pnpm, bun, poetry/pdm/pipenv) All six spike-fixture-oracle backends, not yet wired into the routers: - yarn_classic_lock: lock-only block surgery (file:./...#sha1 + SRI double checksum; merged-key + npm-alias blocks; CRLF-preserving splice) - berry_zip + yarn_berry_lock: byte-exact tgz->cache-zip checksum port (10c0/, verified identical to yarn 4.12's own cache zip) + resolutions pair-edit; cacheKey/compressionLevel guards - pnpm_lock: pnpm.overrides pair edit + 4-part lock surgery (byte-identical to pnpm 9 & 10 emission) - bun_lock: lock-only 3-tuple rewrite, integrity recomputed - pypi_poetry/pypi_pdm/pypi_pipenv: lock-only [[package]]/JSON splices mirroring pypi_uv; pipenv emits vendor_integrity_unverified (no hash enforcement on file entries) Each: fixture-oracle byte-exact transforms, in-sync hot path, revert round-trips with per-flavor file allowlists, drift gates. Core lib 1371 green (2x at default parallelism). Co-Authored-By: Claude Fable 5 --- .../src/patch/vendor/berry_zip.rs | 477 +++- .../src/patch/vendor/bun_lock.rs | 1226 ++++++++- .../src/patch/vendor/pnpm_lock.rs | 2234 ++++++++++++++++- .../src/patch/vendor/pypi_pdm.rs | 1291 +++++++++- .../src/patch/vendor/pypi_pipenv.rs | 998 +++++++- .../src/patch/vendor/pypi_poetry.rs | 1226 ++++++++- .../src/patch/vendor/yarn_berry_lock.rs | 1579 +++++++++++- .../src/patch/vendor/yarn_classic_lock.rs | 1414 ++++++++++- 8 files changed, 10437 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-core/src/patch/vendor/berry_zip.rs b/crates/socket-patch-core/src/patch/vendor/berry_zip.rs index 6c90a08..dc56192 100644 --- a/crates/socket-patch-core/src/patch/vendor/berry_zip.rs +++ b/crates/socket-patch-core/src/patch/vendor/berry_zip.rs @@ -1 +1,476 @@ -//! (stub — backend lands behind the npm_flavor / pypi router) +//! Deterministic tgz → yarn-berry cache-zip rebuild (`checksum: 10c0/…`). +//! +//! yarn berry verifies every install against the sha512 of the *converted +//! cache zip*, not of the tarball — so a committed vendored lock entry needs +//! that checksum computed offline, with no yarn on the machine. This module +//! is a byte-exact Rust port of `spikes/yarn-berry-nm/rebuild_zip.py`, the +//! spike-proven recipe that reproduces yarn 4.x cache zips bit-for-bit +//! (verified against yarn 4.12.0 and 4.6.0 output, TZ-insensitive, mode-probe +//! tarball included — see spike B2 in `spikes/PHASE0-V2-FINDINGS.txt`). +//! +//! Every constant below is pinned by that spike; the zip writer is +//! hand-rolled because the recipe's exact field bytes (no extra fields, no +//! data descriptors, libzip's version-made-by, DOS timestamps rendered as +//! UTC) are the whole point and must never float with a zip-crate default. +//! +//! The recipe (everything that is in the zip, nothing else): +//! * **name mapping** — strip the first path component of each tar entry +//! (npm uses `package/`), prefix `node_modules//`; +//! * **entry order** — tar order, with parent directory entries emitted on +//! first need (mkdirp): `node_modules/` + `node_modules//` appear +//! before the first entry, deeper dirs at the tar position that first +//! references them; +//! * **compression** — stored (method 0) for every entry — the `c0` in +//! `10c0` (compressionLevel 0, the yarn 4 default; any other cacheKey is +//! the caller's cue to refuse); +//! * **timestamps** — every entry dosdate `0x08D6` dostime `0xAE40` +//! (= 1984-06-22 21:50:00, yarn's `SAFE_TIME` 456789000 rendered as UTC); +//! * **modes** — normalized by yarn, never copied from the tar: files +//! `0o100644`, or `0o100755` iff the tar mode carries any exec bit; dirs +//! always `0o40755`; `external_attr = mode << 16`, internal attrs 0; +//! * **headers** — version-needed 10 (files) / 20 (dirs), flags `0x0000` +//! (no data descriptor, no UTF-8 flag — entry names must be ASCII), +//! crc/sizes inline (0 for dirs), NO extra fields; +//! * **central dir** — version-made-by `0x033F` (UNIX, spec 6.3), no extra +//! fields, no comments, one CDH per LFH in the same order; +//! * **EOCD** — single disk, no zip64, no archive comment. + +use std::io::Read; + +use flate2::read::GzDecoder; +use sha2::{Digest, Sha512}; + +/// DOS time 21:50:00 — yarn `SAFE_TIME` 456789000 rendered as UTC. +const SAFE_DOS_TIME: u16 = 0xAE40; +/// DOS date 1984-06-22 — the other half of `SAFE_TIME`. +const SAFE_DOS_DATE: u16 = 0x08D6; +/// Central-dir version-made-by: UNIX (3) << 8 | zip spec 6.3 (63) — what +/// yarn's wasm libzip stamps. +const VERSION_MADE_BY: u16 = 0x033F; +/// Local/central version-needed-to-extract. +const VERSION_NEEDED_FILE: u16 = 10; +const VERSION_NEEDED_DIR: u16 = 20; +/// Normalized unix modes (yarn discards the tar's other permission bits). +const MODE_DIR: u32 = 0o40755; +const MODE_FILE: u32 = 0o100644; +const MODE_FILE_EXEC: u32 = 0o100755; + +/// The committed lock checksum for a vendored tarball under cacheKey `10c0`: +/// `"10c0/" + sha512-hex` of the deterministic cache zip rebuilt from +/// `tgz_bytes` for `node_modules//`. +/// +/// Fail-closed: any tar shape the spiked recipe did not cover (symlinks, +/// hardlinks, non-ASCII names, single-component paths) is an `Err` — a wrong +/// checksum would brick the user's `yarn install` with a YN0018, so we never +/// guess. +pub(super) fn berry_cache_checksum_10c0( + tgz_bytes: &[u8], + package_ident: &str, +) -> Result { + let zip = rebuild_cache_zip(tgz_bytes, package_ident)?; + Ok(format!("10c0/{}", hex::encode(Sha512::digest(&zip)))) +} + +/// One zip entry in emission order. +struct ZipEntry { + /// ASCII name; directories carry the trailing `/`. + name: String, + is_dir: bool, + /// Full unix mode (already normalized). + mode: u32, + data: Vec, +} + +/// Rebuild the cache zip bytes (the checksum input). Exposed at module level +/// so the tests can byte-compare against the spike-captured yarn zips. +fn rebuild_cache_zip(tgz_bytes: &[u8], package_ident: &str) -> Result, String> { + if package_ident.is_empty() || package_ident.starts_with('/') || package_ident.ends_with('/') { + return Err(format!("invalid package ident `{package_ident}`")); + } + let entries = collect_entries(tgz_bytes, package_ident)?; + write_zip(&entries) +} + +/// Walk the tarball in tar order, mapping names and emitting mkdirp parent +/// directory entries on first need — the spike-pinned ordering rule. +fn collect_entries(tgz_bytes: &[u8], package_ident: &str) -> Result, String> { + let prefix = format!("node_modules/{package_ident}"); + let mut entries: Vec = Vec::new(); + let mut seen_dirs: std::collections::HashSet = std::collections::HashSet::new(); + + // mkdirp: emit every missing ancestor of `dirpath` (no trailing slash), + // shallowest first, exactly once. + fn mkdirp(dirpath: &str, seen: &mut std::collections::HashSet, out: &mut Vec) { + let parts: Vec<&str> = dirpath.split('/').collect(); + for i in 1..=parts.len() { + let d = format!("{}/", parts[..i].join("/")); + if seen.insert(d.clone()) { + out.push(ZipEntry { name: d, is_dir: true, mode: MODE_DIR, data: Vec::new() }); + } + } + } + + let mut archive = tar::Archive::new(GzDecoder::new(tgz_bytes)); + let iter = archive + .entries() + .map_err(|e| format!("cannot read tarball: {e}"))?; + for entry in iter { + let mut entry = entry.map_err(|e| format!("cannot read tarball entry: {e}"))?; + let raw_name = String::from_utf8(entry.path_bytes().into_owned()) + .map_err(|_| "tar entry name is not UTF-8".to_string())?; + // Flags 0x0000 assume ASCII names (yarn would set the UTF-8 flag + // otherwise, changing the bytes) — refuse what we cannot reproduce. + if !raw_name.is_ascii() { + return Err(format!("tar entry name `{raw_name}` is not ASCII")); + } + // Strip the first path component (`package/` for npm packs). + let stripped = raw_name + .split('/') + .skip(1) + .collect::>() + .join("/"); + let stripped = stripped.trim_end_matches('/'); + + let entry_type = entry.header().entry_type(); + match entry_type { + tar::EntryType::Directory => { + let dir = if stripped.is_empty() { + prefix.clone() + } else { + format!("{prefix}/{stripped}") + }; + mkdirp(&dir, &mut seen_dirs, &mut entries); + } + tar::EntryType::Regular | tar::EntryType::Continuous => { + if stripped.is_empty() { + return Err(format!( + "tar file entry `{raw_name}` has no path under the package prefix" + )); + } + let target = format!("{prefix}/{stripped}"); + let parent = target.rsplit_once('/').map(|(p, _)| p).unwrap_or(""); + mkdirp(parent, &mut seen_dirs, &mut entries); + let mut data = Vec::new(); + entry + .read_to_end(&mut data) + .map_err(|e| format!("cannot read `{raw_name}` from the tarball: {e}"))?; + let tar_mode = entry + .header() + .mode() + .map_err(|e| format!("cannot read mode of `{raw_name}`: {e}"))?; + let mode = if tar_mode & 0o111 != 0 { MODE_FILE_EXEC } else { MODE_FILE }; + entries.push(ZipEntry { name: target, is_dir: false, mode, data }); + } + // Symlinks/hardlinks/devices never appear in `npm pack` output and + // yarn's conversion of them is unverified — fail closed rather + // than emit a checksum yarn would reject (see module docs). + other => { + return Err(format!( + "unsupported tar entry type {other:?} for `{raw_name}`; cannot rebuild the \ + berry cache zip deterministically" + )); + } + } + } + Ok(entries) +} + +fn w16(buf: &mut Vec, v: u16) { + buf.extend_from_slice(&v.to_le_bytes()); +} + +fn w32(buf: &mut Vec, v: u32) { + buf.extend_from_slice(&v.to_le_bytes()); +} + +fn as_u32(n: usize, what: &str) -> Result { + u32::try_from(n).map_err(|_| format!("{what} exceeds the zip32 limit (no zip64 in the recipe)")) +} + +/// Serialize the entries per the pinned recipe: LFHs+data, central dir, EOCD. +fn write_zip(entries: &[ZipEntry]) -> Result, String> { + let count = + u16::try_from(entries.len()).map_err(|_| "too many entries for a zip32 EOCD".to_string())?; + + let mut blob: Vec = Vec::new(); + let mut central: Vec = Vec::new(); + let mut offsets: Vec = Vec::with_capacity(entries.len()); + + for e in entries { + offsets.push(as_u32(blob.len(), "local header offset")?); + let crc = if e.is_dir { + 0 + } else { + let mut crc = flate2::Crc::new(); + crc.update(&e.data); + crc.sum() + }; + let size = as_u32(e.data.len(), "entry size")?; + let vneed = if e.is_dir { VERSION_NEEDED_DIR } else { VERSION_NEEDED_FILE }; + + blob.extend_from_slice(b"PK\x03\x04"); + w16(&mut blob, vneed); + w16(&mut blob, 0); // flags + w16(&mut blob, 0); // method: stored + w16(&mut blob, SAFE_DOS_TIME); + w16(&mut blob, SAFE_DOS_DATE); + w32(&mut blob, crc); + w32(&mut blob, size); // compressed == uncompressed (stored) + w32(&mut blob, size); + w16(&mut blob, u16::try_from(e.name.len()).map_err(|_| "entry name too long".to_string())?); + w16(&mut blob, 0); // extra len + blob.extend_from_slice(e.name.as_bytes()); + blob.extend_from_slice(&e.data); + + central.extend_from_slice(b"PK\x01\x02"); + w16(&mut central, VERSION_MADE_BY); + w16(&mut central, vneed); + w16(&mut central, 0); // flags + w16(&mut central, 0); // method + w16(&mut central, SAFE_DOS_TIME); + w16(&mut central, SAFE_DOS_DATE); + w32(&mut central, crc); + w32(&mut central, size); + w32(&mut central, size); + w16(&mut central, e.name.len() as u16); + w16(&mut central, 0); // extra len + w16(&mut central, 0); // comment len + w16(&mut central, 0); // disk number start + w16(&mut central, 0); // internal attrs + w32(&mut central, e.mode << 16); // external attrs + w32(&mut central, *offsets.last().expect("just pushed")); + central.extend_from_slice(e.name.as_bytes()); + } + + let cd_size = as_u32(central.len(), "central directory size")?; + let cd_offset = as_u32(blob.len(), "central directory offset")?; + let mut out = blob; + out.append(&mut central); + out.extend_from_slice(b"PK\x05\x06"); + w16(&mut out, 0); // disk number + w16(&mut out, 0); // central dir start disk + w16(&mut out, count); + w16(&mut out, count); + w32(&mut out, cd_size); + w32(&mut out, cd_offset); + w16(&mut out, 0); // comment len + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine as _; + + /// `spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/left-pad-1.3.0-patched.tgz` + /// (base64) — the spike's patched left-pad tarball, the input yarn 4.12.0 + /// converted into cache zip `left-pad-file-8dfd6a0c16-10c0.zip`. + const LEFT_PAD_PATCHED_TGZ_B64: &str = concat!( + "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", + ); + + /// `spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/modeprobe.tgz` + /// (base64) — the spike's odd-modes probe tarball (files 0755/0664/0600/ + /// 0444, dir 0700), pinning yarn's mode normalization. + const MODEPROBE_TGZ_B64: &str = concat!( + "H4sIAF9mKWoAA+2W3U6EMBCFud6nwHprSltom5j4MICjID8lFBRjfHeLC2aXGPRii4n0u5lACHPg5My0idMifoTAswghRHLu", + "f1ZxrIa5Hi8oZ5QxEdJQ+oRGkWSez22Kmul1F7dGSpUXqozLVw11ob557iUDKFfec/5RviW1F6eZ/G/7GuvMTo8f/WfsxH9h", + "/OeCCc8nduScs3P/r6+CJK8DnR0gzZSf5Ye/VuTYkjn/uk+s7YAx91M0fjn/OePUzf8tmP2fKn7Sqr50D/M/hIhW/I/kwn8h", + "Rejm/xa8oTquAN2iSt1D06oE0A16hlbnqjZ3KSaYoHe3FP4rX/Mf0hY6E38LPcb8r85/RhfnP8Ekd/nfAhP7vgQMQ6PaTt9R", + "l/R9cXr+e8Dd0FnoMW74aG3/E7bc/yyULv9bMLjAOxwOxy75AJ0RpNkAGAAA", + ); + + /// `spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-modeprobe-file-10c0.zip` + /// (base64) — yarn 4.12.0's OWN cache zip for modeprobe.tgz, byte-exact. + const MODEPROBE_YARN_ZIP_B64: &str = concat!( + "UEsDBBQAAAAAAECu1ggAAAAAAAAAAAAAAAANAAAAbm9kZV9tb2R1bGVzL1BLAwQUAAAAAABArtYIAAAAAAAAAAAAAAAAFwAA", + "AG5vZGVfbW9kdWxlcy9tb2RlcHJvYmUvUEsDBAoAAAAAAECu1ggvOtrpEgAAABIAAAAdAAAAbm9kZV9tb2R1bGVzL21vZGVw", + "cm9iZS9ydW4uc2gjIS9iaW4vc2gKZWNobyBoaQpQSwMEFAAAAAAAQK7WCAAAAAAAAAAAAAAAABsAAABub2RlX21vZHVsZXMv", + "bW9kZXByb2JlL3N1Yi9QSwMECgAAAAAAQK7WCEilu+UnAAAAJwAAACMAAABub2RlX21vZHVsZXMvbW9kZXByb2JlL3BhY2th", + "Z2UuanNvbnsibmFtZSI6Im1vZGVwcm9iZSIsInZlcnNpb24iOiIxLjAuMCJ9ClBLAwQKAAAAAABArtYIZbDR3REAAAARAAAA", + "IAAAAG5vZGVfbW9kdWxlcy9tb2RlcHJvYmUvc2VjcmV0LmpzbW9kdWxlLmV4cG9ydHM9MQpQSwMECgAAAAAAQK7WCB8I6kYC", + "AAAAAgAAACAAAABub2RlX21vZHVsZXMvbW9kZXByb2JlL3N1Yi9mLnR4dHgKUEsBAj8DFAAAAAAAQK7WCAAAAAAAAAAAAAAA", + "AA0AAAAAAAAAAAAAAO1BAAAAAG5vZGVfbW9kdWxlcy9QSwECPwMUAAAAAABArtYIAAAAAAAAAAAAAAAAFwAAAAAAAAAAAAAA", + "7UErAAAAbm9kZV9tb2R1bGVzL21vZGVwcm9iZS9QSwECPwMKAAAAAABArtYILzra6RIAAAASAAAAHQAAAAAAAAAAAAAA7YFg", + "AAAAbm9kZV9tb2R1bGVzL21vZGVwcm9iZS9ydW4uc2hQSwECPwMUAAAAAABArtYIAAAAAAAAAAAAAAAAGwAAAAAAAAAAAAAA", + "7UGtAAAAbm9kZV9tb2R1bGVzL21vZGVwcm9iZS9zdWIvUEsBAj8DCgAAAAAAQK7WCEilu+UnAAAAJwAAACMAAAAAAAAAAAAA", + "AKSB5gAAAG5vZGVfbW9kdWxlcy9tb2RlcHJvYmUvcGFja2FnZS5qc29uUEsBAj8DCgAAAAAAQK7WCGWw0d0RAAAAEQAAACAA", + "AAAAAAAAAAAAAKSBTgEAAG5vZGVfbW9kdWxlcy9tb2RlcHJvYmUvc2VjcmV0LmpzUEsBAj8DCgAAAAAAQK7WCB8I6kYCAAAA", + "AgAAACAAAAAAAAAAAAAAAKSBnQEAAG5vZGVfbW9kdWxlcy9tb2RlcHJvYmUvc3ViL2YudHh0UEsFBgAAAAAHAAcAAQIAAN0B", + "AAAAAA==", + ); + + /// Spike-captured lock checksum for the patched left-pad tarball: the + /// verbatim `checksum:` value yarn 4.12.0 wrote in + /// `spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/yarn.lock` + /// (== sha512 of `yarn-cache-left-pad-file-8dfd6a0c16-10c0.zip`). + const LEFT_PAD_SPIKE_CHECKSUM: &str = "10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3"; + + /// Spike-captured sha512 of `yarn-cache-modeprobe-file-10c0.zip` (yarn + /// 4.12.0's own cache zip for the odd-modes probe tarball). + const MODEPROBE_SPIKE_CHECKSUM: &str = "10c0/10507c38d64a0005a2aca03c1ee8c592fc17a53b97c1b87175374e61b95e1e214941c0f32dd476b69274e163dca4ae06d6d30f784eeb201006073694a35bba41"; + + fn b64(data: &str) -> Vec { + base64::engine::general_purpose::STANDARD + .decode(data) + .expect("embedded fixture base64 decodes") + } + + #[test] + fn left_pad_checksum_matches_the_spike_captured_lock_value() { + let tgz = b64(LEFT_PAD_PATCHED_TGZ_B64); + let got = berry_cache_checksum_10c0(&tgz, "left-pad").unwrap(); + // Oracle is the yarn-emitted lock value, never a self-computed one. + assert_eq!(got, LEFT_PAD_SPIKE_CHECKSUM); + } + + #[test] + fn modeprobe_checksum_matches_the_spike_captured_zip_hash() { + // Exercises every mode-normalization rule: 0755 keeps exec, 0664/ + // 0600/0444 all collapse to 0644, the 0700 dir becomes 0755. + let tgz = b64(MODEPROBE_TGZ_B64); + let got = berry_cache_checksum_10c0(&tgz, "modeprobe").unwrap(); + assert_eq!(got, MODEPROBE_SPIKE_CHECKSUM); + } + + #[test] + fn rebuilt_zip_is_byte_identical_to_yarns_own_cache_zip() { + // The strongest pin: every header field (timestamps, version-made-by, + // mkdirp ordering, external attrs, EOCD) byte-compared against the + // zip yarn 4.12.0 itself produced for the same tarball. + let tgz = b64(MODEPROBE_TGZ_B64); + let ours = rebuild_cache_zip(&tgz, "modeprobe").unwrap(); + assert_eq!(ours, b64(MODEPROBE_YARN_ZIP_B64)); + } + + /// Build a tgz with file entries ONLY (no directory entries) — the shape + /// `npm_pack::pack_deterministic` produces — and assert mkdirp still + /// emits the parent dirs first, in tar order, all stored. + #[test] + fn mkdirp_covers_tarballs_without_directory_entries() { + let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::new(6)); + let mut tar = tar::Builder::new(gz); + for (path, data) in [ + ("package/package.json", &b"{}"[..]), + ("package/lib/deep.js", &b"deep"[..]), + ] { + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Regular); + h.set_size(data.len() as u64); + h.set_mode(0o644); + h.set_cksum(); + tar.append_data(&mut h, path, data).unwrap(); + } + let tgz = tar.into_inner().unwrap().finish().unwrap(); + + let zip_bytes = rebuild_cache_zip(&tgz, "@scope/pkg").unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap(); + let names: Vec = + (0..zip.len()).map(|i| zip.by_index(i).unwrap().name().to_string()).collect(); + assert_eq!( + names, + vec![ + "node_modules/", + "node_modules/@scope/", + "node_modules/@scope/pkg/", + "node_modules/@scope/pkg/package.json", + "node_modules/@scope/pkg/lib/", + "node_modules/@scope/pkg/lib/deep.js", + ] + ); + for i in 0..zip.len() { + let entry = zip.by_index(i).unwrap(); + assert_eq!( + entry.compression(), + zip::CompressionMethod::Stored, + "{}: every entry stored (the c0)", + entry.name() + ); + } + } + + #[test] + fn unsupported_inputs_fail_closed() { + // Not a gzip stream at all. + assert!(berry_cache_checksum_10c0(b"not a tarball", "x").is_err()); + + // A symlink entry: yarn's conversion is unverified — must Err, never + // emit a checksum yarn might reject at install time. + let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::new(6)); + let mut tar = tar::Builder::new(gz); + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Symlink); + h.set_size(0); + tar.append_link(&mut h, "package/evil", "/etc/passwd").unwrap(); + let tgz = tar.into_inner().unwrap().finish().unwrap(); + let err = berry_cache_checksum_10c0(&tgz, "x").unwrap_err(); + assert!(err.contains("unsupported tar entry type"), "{err}"); + + // A non-ASCII name would need the UTF-8 flag (different bytes). + let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::new(6)); + let mut tar = tar::Builder::new(gz); + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Regular); + h.set_size(1); + h.set_mode(0o644); + h.set_cksum(); + tar.append_data(&mut h, "package/na\u{ef}ve.js", &b"x"[..]).unwrap(); + let tgz = tar.into_inner().unwrap().finish().unwrap(); + let err = berry_cache_checksum_10c0(&tgz, "x").unwrap_err(); + assert!(err.contains("not ASCII"), "{err}"); + + // Bad idents. + assert!(berry_cache_checksum_10c0(&[], "").is_err()); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/bun_lock.rs b/crates/socket-patch-core/src/patch/vendor/bun_lock.rs index 6c90a08..ff6a00d 100644 --- a/crates/socket-patch-core/src/patch/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/bun_lock.rs @@ -1 +1,1225 @@ -//! (stub — backend lands behind the npm_flavor / pypi router) +//! bun vendor backend: LOCK-ONLY `bun.lock` surgery. +//! +//! Spike BN3 (`spikes/PHASE0-V2-FINDINGS.txt`, fixtures in `spikes/bun/`) +//! proved the lock-only edit is sound on bun 1.3.x: rewriting just the +//! `packages` entry passes `bun install --frozen-lockfile` / `bun ci`, the +//! lock stays byte-stable under plain `bun install`, the entry's integrity +//! (sha512 of the raw tarball bytes) is enforced fail-closed even on plain +//! installs (BN5), warm caches never shadow the tarball (BN6), and a fresh +//! checkout installs fully offline (BN7). package.json is left UNTOUCHED — +//! and per-entry edits give exact per-instance targeting that bun's +//! name-only `overrides` cannot (BN4: a name-keyed override collapses EVERY +//! version; a version-scoped override key is a silent no-op). +//! +//! The rewrite (exact arity + spelling pinned by the BN1/BN3 fixtures): +//! every `packages` entry — top-level AND nested `"parent/child"` keys — +//! whose tuple resolves the exact `name@version` moves from the registry +//! 4-tuple `["name@version", "", {deps}, "sha512-..."]` to the +//! local-tarball 3-tuple `["name@", {deps}, "sha512-"]`, +//! where `` is the BARE project-relative path +//! (`.socket/vendor/npm//-.tgz` — no `file:`, no `./`; +//! that is the spelling bun itself emits and re-serializes byte-stably) and +//! the integrity is recomputed from the tarball we packed. The `{deps}` +//! object is carried over verbatim (its position shifts from index 2 to 1). +//! +//! `bun.lock` is JSONC (trailing commas), so the surgery is line-oriented — +//! bun emits each packages entry on a single line — under a conservative +//! grammar that fails CLOSED on anything unexpected; the file is never fed +//! to a JSON parser. + +use std::collections::HashMap; +use std::path::Path; + +use serde_json::Value; + +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{ApplyResult, PatchSources, VerifyResult, VerifyStatus}; +use crate::patch::copy_tree::remove_tree; +use crate::utils::fs::atomic_write_bytes; + +use super::npm_common::{done_failure, guard_coordinates, refused, stage_patch_pack, tgz_rel_leaf}; +use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; +use super::state::{ + write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, +}; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +const BUN_LOCK: &str = "bun.lock"; + +/// The only text-lockfile version the surgery has byte-exact fixtures for +/// (bun 1.3.x; spike pinned 1.3.14). +const SUPPORTED_LOCK_VERSION: u64 = 1; + +/// The `WiringRecord.kind` this backend owns: key = the `packages` map key, +/// original/new = the verbatim entry LINE. +const KIND_LOCK_PACKAGE: &str = "bun_lock_package"; + +/// SECURITY: revert writes are restricted to the one file vendor edits — a +/// poisoned state.json must not be able to point the rewrite at an arbitrary +/// project file. Records naming anything else are skipped with a warning +/// (fail-closed). +const REVERT_ALLOWLIST: [&str; 1] = [BUN_LOCK]; + +/// Vendor one installed npm package into a bun project (see the module doc). +/// Same contract as `npm_lock::vendor_npm`: refuse-early / wire-last, +/// `entry` present iff `result.success` and not a dry run, and an in-sync +/// re-run synthesizes AlreadyPatched with no entry. +#[allow(clippy::too_many_arguments)] +pub async fn vendor_bun( + purl: &str, + installed_dir: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, +) -> VendorOutcome { + let mut warnings: Vec = Vec::new(); + + // ── 1. Coordinates (shared fail-closed guard) ───────────────────────── + let coords = match guard_coordinates(purl, record) { + Ok(coords) => coords, + Err(outcome) => return *outcome, + }; + let (name, version) = (coords.name, coords.version); + // BN3 spelling: BARE project-relative path, no `file:`/`./` prefix. + let rel_tgz = format!("{}/{}", coords.uuid_dir_rel, tgz_rel_leaf(name, version)); + + // ── 2. Read + strictly parse the lock (refuse before any write) ────── + let lock_text = match tokio::fs::read_to_string(project_root.join(BUN_LOCK)).await { + Ok(text) => text, + Err(e) => { + return refused( + "vendor_lockfile_missing", + format!("cannot read {BUN_LOCK}: {e} — run `bun install` first"), + ); + } + }; + if let Err(detail) = check_lock_version(&lock_text) { + return refused("vendor_lockfile_version_unsupported", detail); + } + let mut lines: Vec = lock_text.split('\n').map(str::to_string).collect(); + let entries = match parse_packages_section(&lines) { + Ok(entries) => entries, + Err(detail) => { + // SECURITY/fail-closed: never line-splice a lock whose packages + // section does not match the pinned single-line grammar. + return refused( + "vendor_lockfile_version_unsupported", + format!("{BUN_LOCK} packages section is not in bun's emitted shape: {detail}"), + ); + } + }; + + // ── 3. Pre-flight: at least one rewritable instance ────────────────── + let target_spec = format!("{name}@{version}"); + let has_match = entries.iter().any(|e| { + classify(e, &target_spec, name).is_some() + }); + if !has_match { + return refused( + "vendor_lock_entry_not_found", + format!( + "{BUN_LOCK} has no packages entry resolving {name}@{version} — make sure \ + the package is installed and locked (`bun install`) before vendoring" + ), + ); + } + + // ── 4. Stage → patch → pack (shared flavor-agnostic pipeline) ──────── + let (staged, result) = match stage_patch_pack( + purl, + installed_dir, + project_root, + record, + sources, + dry_run, + force, + ) + .await + { + Ok(pair) => pair, + Err(outcome) => return *outcome, + }; + let Some(staged) = staged else { + // Failed patch or dry run: wiring never ran, project byte-untouched. + return VendorOutcome::Done { result, entry: None, warnings }; + }; + debug_assert_eq!(staged.rel_tgz, rel_tgz); + let packed = staged.packed; + if staged.staged_pkg_json.is_some() { + // The tuple's deps object mirrors the package's own manifest; the + // spike has no fixture for a manifest-rewriting patch, so it is + // preserved verbatim rather than recomputed (fail-safe + loud). + warnings.push(VendorWarning::new( + "vendor_dep_manifest_stale", + format!( + "the patch rewrites {name}@{version}'s package.json; its {BUN_LOCK} tuple's \ + dependency object was preserved verbatim — if the patch changed dependency \ + ranges, run `bun install` to re-resolve them" + ), + )); + } + + // ── 5. Rewrite every matching instance (in-memory) ──────────────────── + let mut wiring: Vec = Vec::new(); + let mut changed = false; + for entry in &entries { + let Some(shape) = classify(entry, &target_spec, name) else { continue }; + let (deps_verbatim, was_ours) = match shape { + TupleShape::Registry => (entry.elems[2].clone(), false), + TupleShape::Ours { path } => { + // Idempotency: an instance already carrying this exact path + // and integrity needs no edit and no wiring record. + if path == rel_tgz && entry.elems[2] == json_str(&packed.integrity) { + continue; + } + (entry.elems[1].clone(), true) + } + }; + let original_line = lines[entry.line_idx].clone(); + let new_line = format!( + "{indent}{key}: [\"{name}@{rel_tgz}\", {deps}, \"{integrity}\"]{comma}", + indent = entry.indent, + key = entry.key_raw, + deps = deps_verbatim, + integrity = packed.integrity, + comma = if entry.trailing_comma { "," } else { "" }, + ); + lines[entry.line_idx] = new_line.clone(); + wiring.push(WiringRecord { + file: BUN_LOCK.to_string(), + kind: KIND_LOCK_PACKAGE.to_string(), + action: WiringAction::Rewritten, + key: Some(entry.key.clone()), + // Never record one of our own (stale) edits as the "original" — + // revert must restore the pre-vendor registry tuple, not a + // dangling `.socket/vendor/` pointer from an earlier uuid. + original: if was_ours { None } else { Some(Value::String(original_line)) }, + new: Some(Value::String(new_line)), + }); + changed = true; + } + + if !changed { + // Every instance already points at this uuid with the packed + // integrity: in sync. The tarball re-pack above was byte-identical + // by determinism; synthesize AlreadyPatched and record nothing. + let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + return VendorOutcome::Done { + result: synthesized_result(purl, &project_root.join(&rel_tgz), verified, true, None), + entry: None, + warnings, + }; + } + + if let Err(e) = + atomic_write_bytes(&project_root.join(BUN_LOCK), lines.join("\n").as_bytes()).await + { + return done_failure(purl, format!("cannot write {BUN_LOCK}: {e}")); + } + + // ── 6. Marker + ledger entry ────────────────────────────────────────── + let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); + vulnerabilities.sort(); + let marker = VendorMarker { + schema_version: 1, + purl: coords.base_purl.clone(), + patch_uuid: record.uuid.clone(), + ecosystem: "npm".to_string(), + vulnerabilities, + vendored_at: vendored_at.to_string(), + }; + if let Err(e) = write_marker(&project_root.join(&coords.uuid_dir_rel), &marker).await { + warnings.push(VendorWarning::new( + "vendor_marker_write_failed", + format!("could not write the informational vendor marker: {e}"), + )); + } + + let entry = VendorEntry { + ecosystem: "npm".to_string(), + base_purl: coords.base_purl, + uuid: record.uuid.clone(), + artifact: VendorArtifact { + path: rel_tgz, + sha256: packed.sha256_hex, + size: Some(packed.size), + platform_locked: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + flavor: Some("bun".to_string()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + }; + VendorOutcome::Done { result, entry: Some(entry), warnings } +} + +/// Undo one bun-vendored package: restore the recorded entry lines and +/// remove the artifact dir. Reverse application order; per-record ownership +/// is re-checked against the live line (drift ⇒ warning, left alone). +pub async fn revert_bun(entry: &VendorEntry, project_root: &Path, dry_run: bool) -> RevertOutcome { + // SECURITY: `entry.uuid` comes from the committed, tamper-able + // state.json and names the directory tree we are about to DELETE. + // Validate through the same fail-closed grammar vendor used. + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &entry.uuid) else { + return RevertOutcome::failed(format!( + "refusing revert: `{}` is not a canonical patch uuid (tampered state.json?)", + entry.uuid + )); + }; + if dry_run { + return RevertOutcome::ok(); + } + let mut outcome = RevertOutcome::ok(); + + let mut touches_lock = false; + for rec in &entry.wiring { + if !REVERT_ALLOWLIST.contains(&rec.file.as_str()) { + outcome.warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("ignoring wiring record for non-allowlisted file `{}`", rec.file), + )); + continue; + } + touches_lock = true; + } + + let mut lines: Option> = None; + if touches_lock { + match tokio::fs::read_to_string(project_root.join(BUN_LOCK)).await { + Ok(text) => lines = Some(text.split('\n').map(str::to_string).collect()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + outcome.warnings.push(VendorWarning::new( + "vendor_lockfile_missing", + format!("{BUN_LOCK} is missing; lock entries cannot be restored"), + )); + } + Err(e) => return RevertOutcome::failed(format!("cannot read {BUN_LOCK}: {e}")), + } + } + + let mut dirty = false; + if let Some(lines) = lines.as_mut() { + for rec in entry.wiring.iter().rev().filter(|r| r.file == BUN_LOCK) { + revert_one_record(lines, rec, &entry.uuid, &mut dirty, &mut outcome.warnings); + } + if dirty { + if let Err(e) = + atomic_write_bytes(&project_root.join(BUN_LOCK), lines.join("\n").as_bytes()) + .await + { + return RevertOutcome::failed(format!("cannot write {BUN_LOCK}: {e}")); + } + } + } + + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + } + outcome +} + +fn revert_one_record( + lines: &mut [String], + rec: &WiringRecord, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let drifted = |detail: String| VendorWarning::new("vendor_lock_entry_drifted", detail); + if rec.kind != KIND_LOCK_PACKAGE { + warnings.push(drifted(format!("unknown wiring kind `{}`; left alone", rec.kind))); + return; + } + let Some(key) = rec.key.as_deref() else { + warnings.push(drifted("wiring record has no key; left alone".to_string())); + return; + }; + // Lenient location scan: unparseable foreign lines are ignored — ours + // must parse (we wrote it) or compare byte-equal to `rec.new`. + let Some((start, end)) = packages_bounds(lines) else { + warnings.push(drifted(format!( + "{BUN_LOCK} has no packages section; `{key}` not restored" + ))); + return; + }; + let located = lines[start + 1..end].iter().enumerate().find_map(|(off, line)| { + let parsed = parse_entry_line(line).ok()?; + (parsed.key == key).then_some((start + 1 + off, parsed)) + }); + if let Some((idx, parsed)) = located { + // Ours iff the line is exactly what we wrote, or its tuple still + // points into OUR uuid dir (a re-serialized but unmoved entry). + let exact = Some(lines[idx].as_str()) == rec.new.as_ref().and_then(Value::as_str); + let ours_uuid = parsed.elems.len() == 3 + && decode_json_string(&parsed.elems[0]) + .and_then(|spec| split_name_spec(&spec).map(|(_, p)| p.to_string())) + .and_then(|path| parse_vendor_path(&path)) + .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if !exact && !ours_uuid { + warnings.push(drifted(format!( + "lock entry `{key}` was re-resolved since vendoring; left alone" + ))); + return; + } + match rec.original.as_ref().and_then(Value::as_str) { + Some(original) => { + lines[idx] = original.to_string(); + *dirty = true; + } + None => { + // The record rewrote one of our own earlier edits, so there + // is no pre-vendor tuple to restore (by design). Surface it + // instead of guessing a registry tuple. + warnings.push(drifted(format!( + "lock entry `{key}` has no recorded pre-vendor original; left as-is \ + (run `bun install` to re-resolve it from the registry)" + ))); + } + } + return; + } + warnings.push(drifted(format!("lock entry `{key}` no longer exists; nothing to restore"))); +} + +// ───────────────────────── conservative line grammar ────────────────────── + +/// One parsed single-line packages entry. +struct BunEntry { + line_idx: usize, + /// Leading whitespace, re-emitted verbatim. + indent: String, + /// Decoded map key (`left-pad`, `haspad/left-pad`). + key: String, + /// The key token exactly as spelled (incl. quotes), re-emitted verbatim. + key_raw: String, + /// Verbatim top-level tuple elements (trimmed). + elems: Vec, + trailing_comma: bool, +} + +/// What a matching entry's tuple looks like. +enum TupleShape { + /// Registry 4-tuple `["name@version", "", {deps}, "sha512-…"]`. + Registry, + /// Our local 3-tuple (any uuid; the caller decides current vs stale). + Ours { path: String }, +} + +/// Classify an entry against the target: `Some(Registry)` for the exact +/// `name@version` registry tuple, `Some(Ours{..})` for one of our own +/// `.socket/vendor/npm/` tuples for the same package, `None` otherwise. +fn classify(entry: &BunEntry, target_spec: &str, name: &str) -> Option { + let spec = decode_json_string(entry.elems.first()?)?; + match entry.elems.len() { + 4 if spec == target_spec + && decode_json_string(&entry.elems[1]).is_some() + && entry.elems[2].starts_with('{') + && decode_json_string(&entry.elems[3]).is_some() => + { + Some(TupleShape::Registry) + } + 3 => { + let (entry_name, path) = split_name_spec(&spec)?; + if entry_name != name || !entry.elems[1].starts_with('{') { + return None; + } + let parts = parse_vendor_path(path)?; + (parts.eco == "npm").then(|| TupleShape::Ours { path: path.to_string() }) + } + _ => None, + } +} + +/// `name@spec` split at the LAST `@` (scoped names keep their leading `@`). +fn split_name_spec(s: &str) -> Option<(&str, &str)> { + let at = s.rfind('@').filter(|&i| i > 0)?; + Some((&s[..at], &s[at + 1..])) +} + +/// `"lockfileVersion": ` head check — only the fixture-pinned text +/// lockfile version is spliced (fail-closed on anything newer/older). +fn check_lock_version(text: &str) -> Result<(), String> { + let version = text.lines().take(5).find_map(|line| { + line.trim() + .strip_prefix("\"lockfileVersion\":") + .map(|rest| rest.trim().trim_end_matches(',').to_string()) + }); + match version.as_deref().map(str::parse::) { + Some(Ok(v)) if v == SUPPORTED_LOCK_VERSION => Ok(()), + Some(Ok(v)) => Err(format!( + "{BUN_LOCK} has lockfileVersion {v}; only {SUPPORTED_LOCK_VERSION} is supported — \ + re-lock with bun >= 1.3" + )), + _ => Err(format!( + "{BUN_LOCK} has no integer lockfileVersion in its head; only \ + {SUPPORTED_LOCK_VERSION} is supported — re-lock with bun >= 1.3" + )), + } +} + +/// `(header_idx, close_idx)` of the `"packages": {` section. +fn packages_bounds(lines: &[String]) -> Option<(usize, usize)> { + let start = lines.iter().position(|l| l.trim_end() == " \"packages\": {")?; + let end = lines + .iter() + .enumerate() + .skip(start + 1) + .find(|(_, l)| matches!(l.trim_end(), " }" | " },")) + .map(|(i, _)| i)?; + Some((start, end)) +} + +/// Strictly parse every entry line of the packages section. Any line that +/// is neither blank nor a single-line `"key": [tuple]` entry fails CLOSED. +fn parse_packages_section(lines: &[String]) -> Result, String> { + let Some((start, end)) = packages_bounds(lines) else { + // No (or unterminated) packages section: an empty lock simply has + // no entries; an unterminated one is malformed. + return if lines.iter().any(|l| l.trim_end() == " \"packages\": {") { + Err("unterminated \"packages\" section".to_string()) + } else { + Ok(Vec::new()) + }; + }; + let mut entries = Vec::new(); + for (idx, line) in lines.iter().enumerate().take(end).skip(start + 1) { + if line.trim().is_empty() { + continue; + } + let mut entry = parse_entry_line(line).map_err(|e| format!("line {}: {e}", idx + 1))?; + entry.line_idx = idx; + entries.push(entry); + } + Ok(entries) +} + +/// Parse one ` "key": ["…", …],` line (the only shape bun emits for +/// packages entries). Returns `Err` on anything that deviates. +fn parse_entry_line(line: &str) -> Result { + let indent_len = line.len() - line.trim_start().len(); + let (indent, s) = line.split_at(indent_len); + // Key token: a JSON string. + let key_end = scan_json_string(s)?; + let key_raw = &s[..key_end]; + let key = decode_json_string(key_raw).ok_or("invalid JSON string key")?; + // `: [` separator. + let after = s[key_end..] + .strip_prefix(':') + .ok_or("expected `:` after the entry key")? + .trim_start(); + if !after.starts_with('[') { + return Err("entry value is not a single-line array".to_string()); + } + // The tuple, with depth/string tracking up to its matching `]`. + let close = scan_balanced_array(after)?; + let interior = &after[1..close - 1]; + let tail = after[close..].trim(); + let trailing_comma = match tail { + "" => false, + "," => true, + other => return Err(format!("unexpected trailing content `{other}`")), + }; + let elems = split_top_level(interior)?; + if elems.is_empty() { + return Err("empty tuple".to_string()); + } + Ok(BunEntry { + line_idx: 0, // set by the caller + indent: indent.to_string(), + key, + key_raw: key_raw.to_string(), + elems, + trailing_comma, + }) +} + +/// Byte index one past the closing quote of the JSON string at the start of +/// `s` (escape-aware). +fn scan_json_string(s: &str) -> Result { + let bytes = s.as_bytes(); + if bytes.first() != Some(&b'"') { + return Err("expected a quoted key".to_string()); + } + let mut i = 1; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 2, + b'"' => return Ok(i + 1), + _ => i += 1, + } + } + Err("unterminated string".to_string()) +} + +/// Byte index one past the `]` matching the `[` at the start of `s` +/// (string- and nesting-aware). +fn scan_balanced_array(s: &str) -> Result { + let bytes = s.as_bytes(); + let mut depth = 0usize; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'"' => i += scan_json_string(&s[i..]).map_err(|e| e.to_string())? - 1, + b'[' | b'{' => depth += 1, + b']' | b'}' => { + depth -= 1; + if depth == 0 { + return Ok(i + 1); + } + } + _ => {} + } + i += 1; + } + Err("unterminated array".to_string()) +} + +/// Split the tuple interior at top-level commas into verbatim trimmed +/// element substrings. +fn split_top_level(interior: &str) -> Result, String> { + let bytes = interior.as_bytes(); + let mut elems = Vec::new(); + let mut depth = 0usize; + let mut elem_start = 0usize; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'"' => i += scan_json_string(&interior[i..])? - 1, + b'[' | b'{' => depth += 1, + b']' | b'}' => { + depth = depth.checked_sub(1).ok_or("unbalanced brackets")?; + } + b',' if depth == 0 => { + elems.push(interior[elem_start..i].trim().to_string()); + elem_start = i + 1; + } + _ => {} + } + i += 1; + } + let last = interior[elem_start..].trim(); + if !last.is_empty() { + elems.push(last.to_string()); + } + if elems.iter().any(String::is_empty) { + return Err("empty tuple element".to_string()); + } + Ok(elems) +} + +/// Decode a verbatim JSON string token; `None` if it is not one. +fn decode_json_string(token: &str) -> Option { + if !token.starts_with('"') { + return None; + } + serde_json::from_str::(token).ok() +} + +/// Encode for verbatim comparison against a tuple element. +fn json_str(s: &str) -> String { + format!("\"{s}\"") +} + +// ───────────────────────── small shared helpers ─────────────────────────── +// (same shapes as npm_lock's; duplicated because that module's helpers are +// private and this file is the only allowed edit surface) + +fn synthesized_result( + package_key: &str, + path: &Path, + files_verified: Vec, + success: bool, + error: Option, +) -> ApplyResult { + ApplyResult { + package_key: package_key.to_string(), + package_path: path.display().to_string(), + success, + files_verified, + files_patched: Vec::new(), + applied_via: HashMap::new(), + error, + sidecar: None, + } +} + +fn already_patched_verify(file: &str) -> VerifyResult { + VerifyResult { + file: file.to_string(), + status: VerifyStatus::AlreadyPatched, + message: None, + current_hash: None, + expected_hash: None, + target_hash: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; + use base64::Engine as _; + use sha2::{Digest, Sha512}; + use std::path::PathBuf; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; + const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + + /// The spike tarball's integrity, as committed in the after-fixtures. + /// Our pack produces a DIFFERENT (deterministic) tarball, so fixture + /// comparisons substitute the actual integrity for this token — + /// everything else must be byte-identical. + const SPIKE_INTEGRITY: &str = + "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="; + + // ── tool-generated byte-exact oracles ───────────────────────────────── + // Provenance: spikes/bun/bn3-lock-only/{before,after}/bun.lock — the + // decisive lock-only pair, bun 1.3.14 (frozen install passes, plain + // install + `bun ci` keep the after-lock byte-identical). + const BN3_BEFORE_LOCK: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn3-lockonly", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + } +} +"#; + const BN3_AFTER_LOCK: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn3-lockonly", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], + } +} +"#; + const BN3_PKG: &str = r#"{ + "name": "bn3-lockonly", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0" + } +} +"#; + + // Provenance: spikes/bun/bn4c-targeted-nested/{before,after}/bun.lock — + // per-instance targeting: ONLY the nested "haspad/left-pad" (1.3.0) + // moves; the root "left-pad" (1.2.0) stays the registry tuple. + const BN4C_BEFORE_LOCK: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn4c-targeted", + "dependencies": { + "haspad": "file:./haspad-1.0.0.tgz", + "left-pad": "1.2.0", + }, + }, + }, + "packages": { + "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], + + "left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], + + "haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + } +} +"#; + const BN4C_AFTER_LOCK: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bn4c-targeted", + "dependencies": { + "haspad": "file:./haspad-1.0.0.tgz", + "left-pad": "1.2.0", + }, + }, + }, + "packages": { + "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], + + "left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], + + "haspad/left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], + } +} +"#; + + struct Fixture { + tmp: tempfile::TempDir, + record: PatchRecord, + /// Where the patched instance is installed (nested for bn4c). + installed: PathBuf, + } + + impl Fixture { + fn root(&self) -> &Path { + self.tmp.path() + } + + fn rel_tgz(&self) -> String { + format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") + } + + async fn read_lock(&self) -> String { + tokio::fs::read_to_string(self.root().join(BUN_LOCK)).await.unwrap() + } + + /// The actual SRI of the tarball our pack produced. + async fn actual_integrity(&self) -> String { + let tgz = tokio::fs::read(self.root().join(self.rel_tgz())).await.unwrap(); + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&tgz)) + ) + } + + async fn vendor(&self, dry_run: bool) -> VendorOutcome { + let blobs = self.root().join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + vendor_bun( + "pkg:npm/left-pad@1.3.0", + &self.installed, + self.root(), + &self.record, + &sources, + "2026-06-09T00:00:00Z", + dry_run, + false, + ) + .await + } + } + + async fn fixture_with(lock: &str, installed_rel: &str) -> Fixture { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + let installed = root.join(installed_rel); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write( + installed.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .await + .unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX).await.unwrap(); + + let blobs = root.join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX).await.unwrap(); + + tokio::fs::write(root.join("package.json"), BN3_PKG).await.unwrap(); + tokio::fs::write(root.join(BUN_LOCK), lock).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(ORIG_INDEX), + after_hash, + }, + ); + let record = PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-06-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: "test patch".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }; + Fixture { tmp, record, installed } + } + + fn expect_done( + outcome: VendorOutcome, + ) -> (ApplyResult, Option, Vec) { + match outcome { + VendorOutcome::Done { result, entry, warnings } => (result, entry, warnings), + VendorOutcome::Refused { code, detail } => { + panic!("expected Done, got Refused {code}: {detail}") + } + } + } + + fn expect_refused(outcome: VendorOutcome, want_code: &str) -> String { + match outcome { + VendorOutcome::Refused { code, detail } => { + assert_eq!(code, want_code, "wrong refusal code ({detail})"); + detail + } + VendorOutcome::Done { result, .. } => { + panic!("expected Refused {want_code}, got Done (success={})", result.success) + } + } + } + + #[tokio::test] + async fn bn3_fixture_oracle_transform_is_byte_identical_and_pkg_json_untouched() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("success carries a ledger entry"); + + let actual = fx.actual_integrity().await; + assert_ne!(actual, SPIKE_INTEGRITY, "different tarballs, different hashes"); + assert_eq!( + fx.read_lock().await, + BN3_AFTER_LOCK.replace(SPIKE_INTEGRITY, &actual), + "the BN3 transform, byte-for-byte (3-tuple arity, bare rel path, no file:/./)" + ); + // LOCK-ONLY: package.json byte-untouched. + assert_eq!( + tokio::fs::read_to_string(fx.root().join("package.json")).await.unwrap(), + BN3_PKG + ); + + // Ledger facts. + assert_eq!(entry.flavor.as_deref(), Some("bun")); + assert!(entry.pnpm.is_none()); + assert_eq!(entry.artifact.path, fx.rel_tgz()); + assert_eq!(entry.wiring.len(), 1); + let rec = &entry.wiring[0]; + assert_eq!(rec.file, BUN_LOCK); + assert_eq!(rec.kind, KIND_LOCK_PACKAGE); + assert_eq!(rec.action, WiringAction::Rewritten); + assert_eq!(rec.key.as_deref(), Some("left-pad")); + assert_eq!( + rec.original.as_ref().and_then(Value::as_str).unwrap(), + " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==\"],", + "original = the verbatim pre-vendor entry line" + ); + } + + #[tokio::test] + async fn bn4c_nested_key_is_rewritten_and_the_other_version_stays_registry() { + let fx = + fixture_with(BN4C_BEFORE_LOCK, "node_modules/haspad/node_modules/left-pad").await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + + let actual = fx.actual_integrity().await; + assert_eq!( + fx.read_lock().await, + BN4C_AFTER_LOCK.replace(SPIKE_INTEGRITY, &actual), + "only the nested haspad/left-pad instance moves (scoping)" + ); + assert_eq!(entry.wiring.len(), 1); + assert_eq!(entry.wiring[0].key.as_deref(), Some("haspad/left-pad")); + } + + #[tokio::test] + async fn integrity_is_recomputed_from_the_packed_tarball() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + let tgz = tokio::fs::read(fx.root().join(fx.rel_tgz())).await.unwrap(); + let expected = format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&tgz)) + ); + let live = fx.read_lock().await; + assert!( + live.contains(&format!("\"{expected}\"")), + "lock must carry the recomputed tarball hash, never an inherited one: {live}" + ); + assert!(!live.contains("sha512-XI5MPzVN"), "registry integrity gone"); + assert_eq!(entry.artifact.sha256, hex::encode(sha2::Sha256::digest(&tgz))); + assert_eq!(entry.artifact.size, Some(tgz.len() as u64)); + } + + #[tokio::test] + async fn deps_object_is_preserved_verbatim_with_a_note_when_manifest_rewritten() { + // The target's registry tuple carries a deps object; it must move + // from index 2 (4-tuple) to index 1 (3-tuple) VERBATIM. + let lock = BN3_BEFORE_LOCK.replace( + r#""left-pad": ["left-pad@1.3.0", "", {}, "#, + r#""left-pad": ["left-pad@1.3.0", "", { "dependencies": { "wow": "^1.0.0" } }, "#, + ); + let mut fx = fixture_with(&lock, "node_modules/left-pad").await; + + // The patch ALSO rewrites the package's own package.json. + let before = br#"{"name":"left-pad","version":"1.3.0"}"#; + let after: &[u8] = br#"{"name":"left-pad","version":"1.3.0","dependencies":{"wow":"^2.0.0"}}"#; + let after_hash = compute_git_sha256_from_bytes(after); + tokio::fs::write(fx.root().join(".socket/blobs").join(&after_hash), after) + .await + .unwrap(); + fx.record.files.insert( + "package/package.json".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(before), + after_hash, + }, + ); + + let (result, _, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let live = fx.read_lock().await; + assert!( + live.contains(&format!( + "\"left-pad\": [\"left-pad@{}\", {{ \"dependencies\": {{ \"wow\": \"^1.0.0\" }} }}, \"sha512-", + fx.rel_tgz() + )), + "deps object carried verbatim into the 3-tuple: {live}" + ); + assert!( + warnings.iter().any(|w| w.code == "vendor_dep_manifest_stale" + && w.detail.contains("bun install")), + "loud note that the deps mirror was NOT recomputed: {warnings:?}" + ); + } + + #[tokio::test] + async fn no_matching_entry_is_refused() { + // The lock only knows left-pad@1.2.0; the exact 1.3.0 tuple is + // absent (only the exact version is ever rewritten). + let lock = BN3_BEFORE_LOCK.replace("left-pad@1.3.0", "left-pad@1.2.0"); + let fx = fixture_with(&lock, "node_modules/left-pad").await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); + assert!(detail.contains("bun install"), "actionable detail: {detail}"); + assert_eq!(fx.read_lock().await, lock, "refusal writes nothing"); + assert!(!fx.root().join(".socket/vendor").exists()); + } + + #[tokio::test] + async fn unparseable_entry_line_fails_closed_before_any_write() { + for bad in [ + " \"left-pad\": [\"left-pad@1.3.0\", \"\", {},", // unterminated + " \"left-pad\": {\"not\": \"a tuple\"},", // not an array + " bare-key: [\"x@1\", \"\", {}, \"sha\"],", // unquoted key + ] { + let lock = BN3_BEFORE_LOCK.replace( + " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==\"],", + bad, + ); + assert_ne!(lock, BN3_BEFORE_LOCK, "replacement must hit"); + let fx = fixture_with(&lock, "node_modules/left-pad").await; + let detail = + expect_refused(fx.vendor(false).await, "vendor_lockfile_version_unsupported"); + assert!(detail.contains("packages section"), "{detail}"); + assert_eq!(fx.read_lock().await, lock, "fail-closed: lock untouched"); + assert!(!fx.root().join(".socket/vendor").exists(), "nothing staged/packed"); + } + } + + #[tokio::test] + async fn missing_lock_and_unsupported_version_are_refused() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + tokio::fs::remove_file(fx.root().join(BUN_LOCK)).await.unwrap(); + let detail = expect_refused(fx.vendor(false).await, "vendor_lockfile_missing"); + assert!(detail.contains("bun install"), "{detail}"); + + let lock = BN3_BEFORE_LOCK.replace("\"lockfileVersion\": 1,", "\"lockfileVersion\": 2,"); + let fx = fixture_with(&lock, "node_modules/left-pad").await; + let detail = + expect_refused(fx.vendor(false).await, "vendor_lockfile_version_unsupported"); + assert!(detail.contains('2'), "{detail}"); + } + + #[tokio::test] + async fn rerun_is_in_sync_and_byte_stable() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + assert!(entry.is_some()); + let lock_first = fx.read_lock().await; + let tgz_first = tokio::fs::read(fx.root().join(fx.rel_tgz())).await.unwrap(); + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success); + assert!(entry.is_none(), "in-sync re-run records nothing"); + assert!( + result.files_verified.iter().all(|v| v.status == VerifyStatus::AlreadyPatched), + "{:?}", + result.files_verified + ); + assert_eq!(fx.read_lock().await, lock_first, "lock byte-stable"); + assert_eq!( + tokio::fs::read(fx.root().join(fx.rel_tgz())).await.unwrap(), + tgz_first, + "tarball byte-identical across re-runs" + ); + } + + #[tokio::test] + async fn dry_run_writes_nothing() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (result, entry, _) = expect_done(fx.vendor(true).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none()); + assert!(result.files_patched.is_empty()); + + assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK); + assert!(!fx.root().join(".socket/vendor").exists()); + assert_eq!( + tokio::fs::read(fx.installed.join("index.js")).await.unwrap(), + ORIG_INDEX, + "vendor never patches in place" + ); + } + + #[tokio::test] + async fn revert_round_trips_the_lock_and_removes_the_artifact() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let tgz_path = fx.root().join(fx.rel_tgz()); + assert!(tgz_path.exists()); + + // Dry-run revert touches nothing. + let outcome = revert_bun(&entry, fx.root(), true).await; + assert!(outcome.success); + assert!(tgz_path.exists()); + assert_ne!(fx.read_lock().await, BN3_BEFORE_LOCK); + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK, "lock byte-restored"); + assert!(!tgz_path.exists()); + assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + } + + #[tokio::test] + async fn revert_allowlist_is_fail_closed() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + // A poisoned ledger names a file outside the allowlist. + tokio::fs::write(fx.root().join("package.json.bak"), b"precious").await.unwrap(); + entry.wiring.push(WiringRecord { + file: "package.json.bak".to_string(), + kind: KIND_LOCK_PACKAGE.to_string(), + action: WiringAction::Rewritten, + key: Some("left-pad".to_string()), + original: Some(Value::String("overwritten!".to_string())), + new: Some(Value::String("x".to_string())), + }); + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted" + && w.detail.contains("package.json.bak")), + "{:?}", + outcome.warnings + ); + assert_eq!( + tokio::fs::read(fx.root().join("package.json.bak")).await.unwrap(), + b"precious", + "non-allowlisted file never touched" + ); + assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK, "real record still restored"); + } + + #[tokio::test] + async fn revert_leaves_drifted_entries_alone_with_warning() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + // The user re-resolved the entry behind our back (`bun update`). + let drifted_line = " \"left-pad\": [\"left-pad@1.3.1\", \"\", {}, \"sha512-other==\"],"; + let live = fx.read_lock().await; + let new_line = entry.wiring[0].new.as_ref().and_then(Value::as_str).unwrap(); + let drifted_lock = live.replace(new_line, drifted_line); + assert_ne!(drifted_lock, live, "test setup must actually drift the entry"); + tokio::fs::write(fx.root().join(BUN_LOCK), &drifted_lock).await.unwrap(); + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted" + && w.detail.contains("left-pad")), + "{:?}", + outcome.warnings + ); + assert!( + fx.read_lock().await.contains(drifted_line), + "drifted entry left alone" + ); + assert!( + !fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists(), + "artifact still removed" + ); + } + + #[tokio::test] + async fn revert_refuses_tampered_uuid_fail_closed() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.uuid = "../../x".to_string(); + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(!outcome.success, "tampered uuid must fail closed"); + } + + #[test] + fn line_grammar_parses_the_fixture_shapes() { + // Registry 4-tuple with deps and trailing comma. + let e = parse_entry_line( + r#" "haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI=="],"#, + ) + .unwrap(); + assert_eq!(e.key, "haspad/left-pad"); + assert_eq!(e.key_raw, "\"haspad/left-pad\""); + assert_eq!(e.indent, " "); + assert!(e.trailing_comma); + assert_eq!( + e.elems, + vec!["\"left-pad@1.3.0\"", "\"\"", "{}", "\"sha512-XI==\""] + ); + + // Local 3-tuple with a deps object containing commas + brackets. + let e = parse_entry_line( + r#" "haspad": ["haspad@./h.tgz", { "dependencies": { "a": "^1", "b": "[2]" } }, "sha512-C=="]"#, + ) + .unwrap(); + assert_eq!(e.elems.len(), 3); + assert_eq!(e.elems[1], r#"{ "dependencies": { "a": "^1", "b": "[2]" } }"#); + assert!(!e.trailing_comma); + + // split at the LAST @ (scoped names). + assert_eq!(split_name_spec("@scope/pkg@1.0.0"), Some(("@scope/pkg", "1.0.0"))); + assert_eq!(split_name_spec("left-pad@.socket/x.tgz"), Some(("left-pad", ".socket/x.tgz"))); + assert_eq!(split_name_spec("@scope/pkg"), None, "a scope @ alone is not a version sep"); + + // Fail-closed grammar. + assert!(parse_entry_line(" \"k\": [\"a\", ").is_err(), "unterminated"); + assert!(parse_entry_line(" k: [\"a\"]").is_err(), "unquoted key"); + assert!(parse_entry_line(" \"k\": \"not an array\"").is_err()); + assert!(parse_entry_line(" \"k\": [\"a\"], junk").is_err(), "trailing junk"); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs index 6c90a08..1164209 100644 --- a/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs @@ -1 +1,2233 @@ -//! (stub — backend lands behind the npm_flavor / pypi router) +//! pnpm vendor backend: paired `package.json` + `pnpm-lock.yaml` surgery. +//! +//! pnpm resolves overrides from the ROOT package.json (`pnpm.overrides`) and +//! cross-checks them against the lockfile's own `overrides:` section, so a +//! lock-only edit is unsound: `--frozen-lockfile` fails with +//! `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` and a plain `pnpm install` silently +//! strips the section and reinstalls the unpatched registry bytes (spike P3, +//! `spikes/PHASE0-V2-FINDINGS.txt`). Vendoring therefore writes the PAIR: a +//! versioned `pnpm.overrides` selector (`@` — only that exact +//! version moves, spike P6) pointing at the vendored tarball, plus the four +//! lock fragments pnpm itself would emit. The surgery is a faithful port of +//! `spikes/pnpm/edit_lock.py`, whose output was verified byte-identical to +//! pnpm's own lock on BOTH supported majors (9.15.9 / 10.34.1 — they emit +//! byte-identical `lockfileVersion: '9.0'` locks; fixtures in `spikes/pnpm/`): +//! +//! 1. `overrides:` section — inserted before `importers:` or extended; +//! 2. every importer's dep entry — `specifier:` AND `version:` rewritten to +//! the `file:` spec, the specifier re-relativized PER IMPORTER +//! (`file:../../.socket/...` for `packages/app`; spike P7) while +//! `version:` and the packages/snapshots keys stay lockfile-root-relative; +//! 3. the `packages:` entry — rekeyed `name@version` → `name@file:` +//! with `resolution: {integrity: sha512-, tarball: file:}` +//! (the recomputed tarball hash — pnpm enforces it even offline, spike +//! P5), a new `version: X.Y.Z` line, and any `deprecated:` line dropped; +//! 4. `snapshots:` — the entry rekeyed the same way and every other +//! snapshot's dep reference rewritten to the bare `name: file:` +//! form (no `name@` prefix). +//! +//! The lock is machine-emitted YAML, edited by LINE-BLOCK SPLICES (never a +//! YAML library): untouched lines stay byte-identical, which is what makes +//! the lock byte-stable under pnpm's own re-serialization (spike P2). +//! package.json is written FIRST and the lock second; a lock write failure +//! unwinds package.json to its original bytes so the P3 desync pair is +//! never left behind. + +use std::collections::HashMap; +use std::path::Path; + +use serde_json::Value; + +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{ApplyResult, PatchSources, VerifyResult, VerifyStatus}; +use crate::patch::copy_tree::remove_tree; +use crate::utils::fs::atomic_write_bytes; + +use super::npm_common::{done_failure, guard_coordinates, refused, stage_patch_pack, tgz_rel_leaf}; +use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; +use super::state::{ + write_marker, PnpmMeta, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, +}; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +const PACKAGE_JSON: &str = "package.json"; +const PNPM_LOCK: &str = "pnpm-lock.yaml"; + +/// The only lockfileVersion the surgery has byte-exact fixtures for (both +/// pnpm 9 and 10 emit it). +const SUPPORTED_LOCK_VERSION: &str = "9.0"; + +/// Wiring kinds (the `WiringRecord.kind` discriminators this backend owns). +const KIND_PKG_OVERRIDE: &str = "pnpm_pkg_override"; +const KIND_LOCK_OVERRIDES: &str = "pnpm_lock_overrides"; +const KIND_LOCK_IMPORTER_DEP: &str = "pnpm_lock_importer_dep"; +const KIND_LOCK_PACKAGE: &str = "pnpm_lock_package"; +const KIND_LOCK_SNAPSHOT: &str = "pnpm_lock_snapshot"; +const KIND_LOCK_SNAPSHOT_REF: &str = "pnpm_lock_snapshot_ref"; + +/// SECURITY: revert writes are restricted to exactly the pair vendor edits — +/// a poisoned state.json must not be able to point the rewrite at an +/// arbitrary project file. Records naming anything else are skipped with a +/// warning (fail-closed). +const REVERT_ALLOWLIST: [&str; 2] = [PNPM_LOCK, PACKAGE_JSON]; + +/// Vendor one installed npm package into a pnpm project (see the module doc +/// for the wiring shape). Same contract as `npm_lock::vendor_npm`: +/// refuse-early / wire-last, `entry` present iff `result.success` and not a +/// dry run, and an in-sync re-run synthesizes AlreadyPatched with no entry. +#[allow(clippy::too_many_arguments)] +pub async fn vendor_pnpm( + purl: &str, + installed_dir: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, +) -> VendorOutcome { + let mut warnings: Vec = Vec::new(); + + // ── 1. Coordinates (shared fail-closed guard) ───────────────────────── + let coords = match guard_coordinates(purl, record) { + Ok(coords) => coords, + Err(outcome) => return *outcome, + }; + let (name, version) = (coords.name, coords.version); + let rel_tgz = format!("{}/{}", coords.uuid_dir_rel, tgz_rel_leaf(name, version)); + // pnpm spells the override target `file:` with NO + // `./` (spike P1 fixtures, verbatim). + let spec = format!("file:{rel_tgz}"); + let override_key = format!("{name}@{version}"); + + // ── 2. Read the pair (refuse before any write) ─────────────────────── + let pkg_bytes = match tokio::fs::read(project_root.join(PACKAGE_JSON)).await { + Ok(bytes) => bytes, + Err(e) => { + return refused( + "vendor_lockfile_missing", + format!( + "cannot read {PACKAGE_JSON}: {e} — the pnpm wiring edits the \ + package.json + pnpm-lock.yaml PAIR (a lock-only edit silently \ + unpatches on the next plain `pnpm install`)" + ), + ); + } + }; + let mut pkg: Value = match serde_json::from_slice(&pkg_bytes) { + Ok(Value::Object(map)) => Value::Object(map), + Ok(_) | Err(_) => { + return refused( + "vendor_pkg_json_unsupported", + format!("{PACKAGE_JSON} is not a JSON object; cannot add pnpm.overrides"), + ); + } + }; + let lock_text = match tokio::fs::read_to_string(project_root.join(PNPM_LOCK)).await { + Ok(text) => text, + Err(e) => { + return refused( + "vendor_lockfile_missing", + format!("cannot read {PNPM_LOCK}: {e} — run `pnpm install` first"), + ); + } + }; + if let Err(detail) = check_lock_version(&lock_text) { + return refused("vendor_lockfile_version_unsupported", detail); + } + let mut lines = split_lines(&lock_text); + + // ── 3. Pre-flight refusals (override conflicts, entry present) ─────── + if let Err(detail) = check_pkg_override_conflict(&pkg, name, &override_key) { + return refused("vendor_override_conflict", detail); + } + if let Err(detail) = check_lock_override_conflict(&lines, name, &override_key) { + return refused("vendor_override_conflict", detail); + } + if !lock_has_target_package(&lines, name, version) { + return refused( + "vendor_lock_entry_not_found", + format!( + "{PNPM_LOCK} has no packages entry for {name}@{version} — make sure the \ + package is installed and locked (`pnpm install`) before vendoring" + ), + ); + } + + // ── 4. Stage → patch → pack (shared flavor-agnostic pipeline) ──────── + let (staged, result) = match stage_patch_pack( + purl, + installed_dir, + project_root, + record, + sources, + dry_run, + force, + ) + .await + { + Ok(pair) => pair, + Err(outcome) => return *outcome, + }; + let Some(staged) = staged else { + // Failed patch or dry run: wiring never ran, project byte-untouched. + return VendorOutcome::Done { result, entry: None, warnings }; + }; + debug_assert_eq!(staged.rel_tgz, rel_tgz); + let packed = staged.packed; + if staged.staged_pkg_json.is_some() { + // pnpm snapshots mirror the package's own dependency maps; the spike + // has no fixture for a manifest-rewriting patch, so the mirrors are + // preserved verbatim and the user is told to re-resolve. + warnings.push(VendorWarning::new( + "vendor_dep_manifest_stale", + format!( + "the patch rewrites {name}@{version}'s package.json; pnpm-lock.yaml's \ + dependency mirrors were preserved verbatim — if the patch changed \ + dependency ranges, run `pnpm install` to re-resolve them" + ), + )); + } + + // ── 5. Compute both edits in memory (nothing written yet) ──────────── + let ctx = EditCtx { + name, + version, + rel_tgz: &rel_tgz, + spec: &spec, + integrity: &packed.integrity, + }; + let mut wiring: Vec = Vec::new(); + + let (pkg_changed, created_pnpm_table, created_overrides_table) = + match apply_pkg_override(&mut pkg, &override_key, &spec, &mut wiring) { + Ok(out) => out, + Err(e) => return done_failure(purl, e), + }; + let mut lock_changed = false; + for edit in [ + edit_overrides, + edit_importers, + edit_packages, + edit_snapshot_rekey, + edit_snapshot_refs, + ] { + match edit(&mut lines, &ctx, &mut wiring) { + Ok(changed) => lock_changed |= changed, + Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + } + } + + if !pkg_changed && !lock_changed { + // Everything already carries this uuid + the packed integrity: the + // project is in sync. The tarball re-pack above was byte-identical + // by determinism; synthesize AlreadyPatched and record nothing (the + // existing ledger entry stays authoritative). + let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + return VendorOutcome::Done { + result: synthesized_result(purl, &project_root.join(&rel_tgz), verified, true, None), + entry: None, + warnings, + }; + } + + // ── 6. Commit: package.json FIRST, lock second, unwind on failure ──── + let pkg_indent = detect_indent(&String::from_utf8_lossy(&pkg_bytes)); + let new_pkg_bytes = match serialize_json(&pkg, &pkg_indent) { + Ok(bytes) => bytes, + Err(e) => return done_failure(purl, format!("cannot serialize {PACKAGE_JSON}: {e}")), + }; + let lock_out = join_lines(&lines); + if let Err(e) = commit_pair( + project_root, + pkg_changed.then_some(new_pkg_bytes.as_slice()), + &pkg_bytes, + lock_changed.then_some(lock_out.as_bytes()), + ) + .await + { + return done_failure(purl, e); + } + + // ── 7. Marker + ledger entry ───────────────────────────────────────── + let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); + vulnerabilities.sort(); + let marker = VendorMarker { + schema_version: 1, + purl: coords.base_purl.clone(), + patch_uuid: record.uuid.clone(), + ecosystem: "npm".to_string(), + vulnerabilities, + vendored_at: vendored_at.to_string(), + }; + if let Err(e) = write_marker(&project_root.join(&coords.uuid_dir_rel), &marker).await { + warnings.push(VendorWarning::new( + "vendor_marker_write_failed", + format!("could not write the informational vendor marker: {e}"), + )); + } + + let entry = VendorEntry { + ecosystem: "npm".to_string(), + base_purl: coords.base_purl, + uuid: record.uuid.clone(), + artifact: VendorArtifact { + path: rel_tgz, + sha256: packed.sha256_hex, + size: Some(packed.size), + platform_locked: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + flavor: Some("pnpm".to_string()), + uv: None, + pnpm: Some(PnpmMeta { created_overrides_table, created_pnpm_table }), + poetry: None, + pdm: None, + pipenv: None, + }; + VendorOutcome::Done { result, entry: Some(entry), warnings } +} + +/// Undo one pnpm-vendored package: restore the recorded pair fragments and +/// remove the artifact dir. Reverse application order; per-record ownership +/// is re-checked against the live fragment (drift ⇒ warning, left alone). +pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool) -> RevertOutcome { + // SECURITY: `entry.uuid` comes from the committed, tamper-able + // state.json and names the directory tree we are about to DELETE. + // Validate through the same fail-closed grammar vendor used. + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &entry.uuid) else { + return RevertOutcome::failed(format!( + "refusing revert: `{}` is not a canonical patch uuid (tampered state.json?)", + entry.uuid + )); + }; + if dry_run { + return RevertOutcome::ok(); + } + let mut outcome = RevertOutcome::ok(); + + // Partition by file through the allowlist (fail-closed skip+warning on + // anything else — see REVERT_ALLOWLIST's security note). + let mut touches_pkg = false; + let mut touches_lock = false; + for rec in &entry.wiring { + if !REVERT_ALLOWLIST.contains(&rec.file.as_str()) { + outcome.warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("ignoring wiring record for non-allowlisted file `{}`", rec.file), + )); + continue; + } + if rec.file == PACKAGE_JSON { + touches_pkg = true; + } else { + touches_lock = true; + } + } + + // Load both surfaces up front (fail-closed on unparseable; a missing + // file degrades to a warning and the artifact removal still proceeds). + let mut lock_lines: Option> = None; + if touches_lock { + match tokio::fs::read_to_string(project_root.join(PNPM_LOCK)).await { + Ok(text) => lock_lines = Some(split_lines(&text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + outcome.warnings.push(VendorWarning::new( + "vendor_lockfile_missing", + format!("{PNPM_LOCK} is missing; lock fragments cannot be restored"), + )); + } + Err(e) => return RevertOutcome::failed(format!("cannot read {PNPM_LOCK}: {e}")), + } + } + let mut pkg_state: Option<(Value, String)> = None; // (doc, indent) + if touches_pkg { + match tokio::fs::read(project_root.join(PACKAGE_JSON)).await { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(doc) if doc.is_object() => { + let indent = detect_indent(&String::from_utf8_lossy(&bytes)); + pkg_state = Some((doc, indent)); + } + // Fail-closed: editing a manifest we cannot parse risks + // destroying it; the user must repair it first. + _ => { + return RevertOutcome::failed(format!( + "{PACKAGE_JSON} is not a JSON object; fix it and re-run revert" + )) + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + outcome.warnings.push(VendorWarning::new( + "vendor_lockfile_missing", + format!("{PACKAGE_JSON} is missing; the pnpm override cannot be removed"), + )); + } + Err(e) => return RevertOutcome::failed(format!("cannot read {PACKAGE_JSON}: {e}")), + } + } + + let mut lock_dirty = false; + let mut pkg_dirty = false; + for rec in entry.wiring.iter().rev() { + match rec.file.as_str() { + PNPM_LOCK => { + if let Some(lines) = lock_lines.as_mut() { + revert_lock_record(lines, rec, &entry.uuid, &mut lock_dirty, &mut outcome.warnings); + } + } + PACKAGE_JSON => { + if let Some((doc, _)) = pkg_state.as_mut() { + revert_pkg_record(doc, rec, &entry.uuid, &mut pkg_dirty, &mut outcome.warnings); + } + } + _ => {} // warned above + } + } + + // Remove the now-empty tables iff vendor created them (third-party keys + // added since keep the table alive). + if let Some((doc, _)) = pkg_state.as_mut() { + let (created_overrides, created_pnpm) = match &entry.pnpm { + Some(meta) => (meta.created_overrides_table, meta.created_pnpm_table), + None => (false, false), + }; + if let Some(obj) = doc.as_object_mut() { + if let Some(pnpm_tbl) = obj.get_mut("pnpm").and_then(Value::as_object_mut) { + if created_overrides + && pnpm_tbl.get("overrides").and_then(Value::as_object).is_some_and( + serde_json::Map::is_empty, + ) + { + pnpm_tbl.shift_remove("overrides"); + pkg_dirty = true; + } + } + if created_pnpm + && obj.get("pnpm").and_then(Value::as_object).is_some_and(serde_json::Map::is_empty) + { + obj.shift_remove("pnpm"); + pkg_dirty = true; + } + } + } + + // Reverse write order: lock first, package.json second. + if lock_dirty { + if let Some(lines) = &lock_lines { + if let Err(e) = + atomic_write_bytes(&project_root.join(PNPM_LOCK), join_lines(lines).as_bytes()) + .await + { + return RevertOutcome::failed(format!("cannot write {PNPM_LOCK}: {e}")); + } + } + } + if pkg_dirty { + if let Some((doc, indent)) = &pkg_state { + let bytes = match serialize_json(doc, indent) { + Ok(b) => b, + Err(e) => { + return RevertOutcome::failed(format!("cannot serialize {PACKAGE_JSON}: {e}")) + } + }; + if let Err(e) = atomic_write_bytes(&project_root.join(PACKAGE_JSON), &bytes).await { + return RevertOutcome::failed(format!("cannot write {PACKAGE_JSON}: {e}")); + } + } + } + + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + } + outcome +} + +// ───────────────────────────── edit context ────────────────────────────── + +struct EditCtx<'a> { + name: &'a str, + version: &'a str, + /// `.socket/vendor/npm//` (forward slashes, root-relative). + rel_tgz: &'a str, + /// `file:` — the exact override/lock value spelling (no `./`). + spec: &'a str, + /// `sha512-` of the packed tarball. + integrity: &'a str, +} + +impl EditCtx<'_> { + /// Registry-shaped key (`name@version`). + fn reg_key(&self) -> String { + format!("{}@{}", self.name, self.version) + } + + /// Our rekeyed packages/snapshots key (`name@file:`). + fn new_key(&self) -> String { + format!("{}@{}", self.name, self.spec) + } + + /// Does `value` point into `.socket/vendor/npm/` (ours — any uuid; a + /// stale uuid is rewritten to the current one with `original: None`)? + fn is_ours(&self, value: &str) -> bool { + parse_vendor_path(value).is_some_and(|p| p.eco == "npm") + } + + /// The per-importer `specifier:` spelling: re-relativized for nested + /// importers, root-relative for `.` (spike P7). + fn spec_for_importer(&self, importer: &str) -> String { + if importer == "." { + self.spec.to_string() + } else { + format!("file:{}{}", "../".repeat(importer.split('/').count()), self.rel_tgz) + } + } +} + +// ─────────────────────────── pre-flight checks ─────────────────────────── + +/// `lockfileVersion: '9.0'` head check (accept pnpm's single quotes plus +/// double-quoted/bare spellings, mirroring the flavor router's sniff). +fn check_lock_version(text: &str) -> Result<(), String> { + let version = text + .lines() + .take(5) + .find_map(|line| line.strip_prefix("lockfileVersion:")) + .map(|rest| rest.trim().trim_matches(['\'', '"']).to_string()); + match version { + Some(v) if v == SUPPORTED_LOCK_VERSION => Ok(()), + Some(v) => Err(format!( + "{PNPM_LOCK} has lockfileVersion {v}; only {SUPPORTED_LOCK_VERSION} is \ + supported — re-lock with pnpm >= 9" + )), + None => Err(format!( + "{PNPM_LOCK} has no lockfileVersion in its head; only \ + {SUPPORTED_LOCK_VERSION} is supported — re-lock with pnpm >= 9" + )), + } +} + +/// The package-name component of a pnpm override key +/// (`[@scope/]name[@range]`, possibly behind a `parent>child` selector +/// chain — the override targets the LAST segment). +fn override_key_name(key: &str) -> &str { + let last = key.rsplit('>').next().unwrap_or(key).trim(); + if let Some(rest) = last.strip_prefix('@') { + match rest.find('@') { + Some(i) => &last[..i + 1], + None => last, + } + } else { + match last.find('@') { + Some(i) => &last[..i], + None => last, + } + } +} + +/// Is this (key, value) override pair OURS for the target package — the +/// exact versioned selector pointing into `.socket/vendor/npm/`? +fn override_is_ours(key: &str, value: &str, our_key: &str) -> bool { + key == our_key && parse_vendor_path(value).is_some_and(|p| p.eco == "npm") +} + +/// A user-authored override already steering this package would be +/// silently fought over by ours; refuse instead (fail-closed). +fn check_pkg_override_conflict(pkg: &Value, name: &str, our_key: &str) -> Result<(), String> { + let Some(overrides) = pkg.get("pnpm").and_then(|p| p.get("overrides")) else { + return Ok(()); + }; + let Some(map) = overrides.as_object() else { + return Err("package.json pnpm.overrides is not an object".to_string()); + }; + for (key, value) in map { + if override_key_name(key) != name { + continue; + } + let value_str = value.as_str().unwrap_or(""); + if override_is_ours(key, value_str, our_key) { + continue; // ours (possibly a stale uuid) — the edit handles it + } + return Err(format!( + "package.json already carries a pnpm override for `{key}` ({value}); vendoring \ + would fight it — remove the override (or vendor --revert) first" + )); + } + Ok(()) +} + +/// Same conflict check against the lock's own `overrides:` section (a +/// desynced lock-side override would be silently clobbered otherwise). +fn check_lock_override_conflict( + lines: &[String], + name: &str, + our_key: &str, +) -> Result<(), String> { + let Some((start, end)) = section_bounds(lines, "overrides") else { + return Ok(()); + }; + for line in &lines[start + 1..end] { + if let Some((key, _repr, rest)) = parse_key_line(line, 2) { + if override_key_name(&key) == name && !override_is_ours(&key, &rest, our_key) { + return Err(format!( + "{PNPM_LOCK} already carries an override for `{key}` ({rest}); vendoring \ + would fight it — remove the override (or vendor --revert) first" + )); + } + } + } + Ok(()) +} + +/// Pre-flight: does the lock have a packages entry vendoring can target — +/// the registry `name@version` key, or our own rekeyed `name@file:` key +/// (the in-sync / stale-uuid re-run)? +fn lock_has_target_package(lines: &[String], name: &str, version: &str) -> bool { + let Some((start, end)) = section_bounds(lines, "packages") else { return false }; + let reg_key = format!("{name}@{version}"); + let ours_prefix = format!("{name}@file:"); + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + if block.key == reg_key { + return true; + } + if let Some(rest) = block.key.strip_prefix(&ours_prefix) { + if parse_vendor_path(rest).is_some_and(|p| p.eco == "npm") { + return true; + } + } + i = block.end; + } + false +} + +// ───────────────────────── package.json override ───────────────────────── + +/// Add/refresh `pnpm.overrides[@] = file:` on the +/// parsed (preserve_order) document. Returns +/// `(changed, created_pnpm_table, created_overrides_table)`. +fn apply_pkg_override( + pkg: &mut Value, + our_key: &str, + spec: &str, + wiring: &mut Vec, +) -> Result<(bool, bool, bool), String> { + let obj = pkg.as_object_mut().ok_or("package.json root is not an object")?; + let created_pnpm_table = !obj.contains_key("pnpm"); + let pnpm_tbl = obj + .entry("pnpm") + .or_insert_with(|| Value::Object(serde_json::Map::new())) + .as_object_mut() + .ok_or("package.json `pnpm` is not an object")?; + let created_overrides_table = !pnpm_tbl.contains_key("overrides"); + let overrides = pnpm_tbl + .entry("overrides") + .or_insert_with(|| Value::Object(serde_json::Map::new())) + .as_object_mut() + .ok_or("package.json `pnpm.overrides` is not an object")?; + + let existing = overrides.get(our_key).and_then(Value::as_str); + if existing == Some(spec) { + return Ok((false, false, false)); // in sync, no record + } + // The conflict pre-flight guarantees any existing value here is OURS + // (a stale uuid): never record our own edit as the "original". + let was_ours = existing.is_some(); + overrides.insert(our_key.to_string(), Value::String(spec.to_string())); + wiring.push(WiringRecord { + file: PACKAGE_JSON.to_string(), + kind: KIND_PKG_OVERRIDE.to_string(), + action: if was_ours { WiringAction::Rewritten } else { WiringAction::Added }, + key: Some(our_key.to_string()), + original: None, // Added has none; Rewritten-over-ours records none by design + new: Some(Value::String(spec.to_string())), + }); + Ok((true, created_pnpm_table, created_overrides_table)) +} + +// ───────────────────────────── lock edits ───────────────────────────────── + +/// Edit 1: the `overrides:` section — insert it before `importers:` when +/// absent (pnpm emits it between `settings:` and `importers:`), or splice +/// our entry into the existing one. +fn edit_overrides( + lines: &mut Vec, + ctx: &EditCtx<'_>, + wiring: &mut Vec, +) -> Result { + let our_key = ctx.reg_key(); + let entry_line = format!(" {}: {}", yaml_key(&our_key), ctx.spec); + if let Some((start, end)) = section_bounds(lines, "overrides") { + // Immutable scan first: our line's position (if present) + the last + // entry line (the append anchor). + let mut ours = None; + let mut last_entry = start; + for (i, line) in lines.iter().enumerate().take(end).skip(start + 1) { + if let Some((key, _repr, rest)) = parse_key_line(line, 2) { + last_entry = i; + if key == our_key { + ours = Some((i, rest)); + break; + } + } + } + if let Some((i, rest)) = ours { + if rest == ctx.spec { + return Ok(false); // in sync + } + // Ours with a stale uuid (conflict pre-flight proved it). + lines[i] = entry_line; + wiring.push(overrides_record(&our_key, ctx.spec, WiringAction::Rewritten)); + return Ok(true); + } + lines.insert(last_entry + 1, entry_line); + wiring.push(overrides_record(&our_key, ctx.spec, WiringAction::Added)); + return Ok(true); + } + // No overrides section: insert one right before `importers:` (with the + // blank separator pnpm emits — byte-identical to the P1/P4 fixtures). + let (importers, _) = + section_bounds(lines, "importers").ok_or("no importers: section to anchor on")?; + lines.splice( + importers..importers, + ["overrides:".to_string(), entry_line, String::new()], + ); + wiring.push(overrides_record(&our_key, ctx.spec, WiringAction::Added)); + Ok(true) +} + +fn overrides_record(key: &str, spec: &str, action: WiringAction) -> WiringRecord { + WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_OVERRIDES.to_string(), + action, + key: Some(key.to_string()), + original: None, // Added, or rewritten-over-ours (never an original) + new: Some(Value::String(spec.to_string())), + } +} + +/// Edit 2: every importer's dep entry for the exact `name@version` — +/// `specifier:` (re-relativized per importer) AND `version:` move to the +/// `file:` spec. +// &mut Vec keeps all five edit functions' signatures unifiable into the one +// fn array `vendor_pnpm` iterates (the section-splicing edits need the Vec). +#[allow(clippy::ptr_arg)] +fn edit_importers( + lines: &mut Vec, + ctx: &EditCtx<'_>, + wiring: &mut Vec, +) -> Result { + let Some((start, end)) = section_bounds(lines, "importers") else { + return Ok(false); + }; + let mut changed = false; + let mut i = start + 1; + while let Some(importer) = next_block(lines, i, end) { + let importer_key = importer.key.clone(); + // Dep entries sit at 6-space indent under the 4-space dep-type + // headers; their fields at 8. + let mut k = importer.header + 1; + while k < importer.end { + let Some((dep, _repr, rest)) = parse_key_line(&lines[k], 6) else { + k += 1; + continue; + }; + if dep != ctx.name || !rest.is_empty() { + k += 1; + continue; + } + // Locate this dep's specifier/version field lines. + let mut spec_idx = None; + let mut ver_idx = None; + let mut f = k + 1; + while f < importer.end { + let Some((field, _frepr, fval)) = parse_key_line(&lines[f], 8) else { break }; + match field.as_str() { + "specifier" => spec_idx = Some((f, fval)), + "version" => ver_idx = Some((f, fval)), + _ => {} + } + f += 1; + } + if let (Some((si, old_spec)), Some((vi, old_ver))) = (spec_idx, ver_idx) { + let target = old_ver == ctx.version + || (old_ver != ctx.spec && ctx.is_ours(&old_ver)); + if target { + let was_ours = ctx.is_ours(&old_ver); + let importer_spec = ctx.spec_for_importer(&importer_key); + lines[si] = format!(" specifier: {importer_spec}"); + lines[vi] = format!(" version: {}", ctx.spec); + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_IMPORTER_DEP.to_string(), + action: WiringAction::Rewritten, + key: Some(format!("{importer_key}|{dep}")), + original: if was_ours { + None + } else { + Some(serde_json::json!({ + "specifier": old_spec, + "version": old_ver, + })) + }, + new: Some(serde_json::json!({ + "specifier": importer_spec, + "version": ctx.spec, + })), + }); + changed = true; + } + } + k = f; + } + i = importer.end; + } + Ok(changed) +} + +/// Edit 3: rekey the `packages:` entry and rewrite its body — +/// `resolution: {integrity: , tarball: }`, a `version:` line +/// inserted after it, `deprecated:` dropped, everything else verbatim. +fn edit_packages( + lines: &mut Vec, + ctx: &EditCtx<'_>, + wiring: &mut Vec, +) -> Result { + let (start, end) = section_bounds(lines, "packages").ok_or("no packages: section")?; + let reg_key = ctx.reg_key(); + let new_key = ctx.new_key(); + let ours_prefix = format!("{}@file:", ctx.name); + + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + let is_registry = block.key == reg_key; + let is_ours_key = block + .key + .strip_prefix(&ours_prefix) + .is_some_and(|rest| parse_vendor_path(rest).is_some_and(|p| p.eco == "npm")); + if !is_registry && !is_ours_key { + i = block.end; + continue; + } + let original_lines: Vec = lines[block.header..block.end].to_vec(); + let expected_resolution = + format!(" resolution: {{integrity: {}, tarball: {}}}", ctx.integrity, ctx.spec); + if block.key == new_key + && original_lines.iter().any(|l| l == &expected_resolution) + { + return Ok(false); // in sync (only the exact version moves: done) + } + // Rebuild the block (registry → ours, or stale-ours → current). + let mut new_lines = Vec::with_capacity(original_lines.len() + 1); + new_lines.push(format!(" {}:{}", yaml_key_like(&new_key, &block.repr), block.rest_suffix())); + let mut replaced_resolution = false; + for line in &original_lines[1..] { + if line.trim_start().starts_with("resolution:") { + new_lines.push(expected_resolution.clone()); + new_lines.push(format!(" version: {}", ctx.version)); + replaced_resolution = true; + } else if line.trim_start().starts_with("deprecated:") + || line.trim_start().starts_with("version:") + { + // deprecated: dropped (pnpm drops it for file: entries); + // version: re-inserted canonically after resolution. + } else { + new_lines.push(line.clone()); + } + } + if !replaced_resolution { + return Err(format!("packages entry `{}` has no resolution line", block.key)); + } + let header = block.header; + let block_end = block.end; + lines.splice(header..block_end, new_lines.clone()); + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_PACKAGE.to_string(), + action: WiringAction::Rewritten, + key: Some(block.key.clone()), + original: if is_ours_key { None } else { Some(lines_value(&original_lines)) }, + new: Some(lines_value(&new_lines)), + }); + return Ok(true); + } + // Pre-flight proved an entry exists; reaching here means it vanished + // mid-run (impossible in-process) — fail loudly rather than wire half. + Err(format!("packages entry for {reg_key} vanished mid-rewrite")) +} + +/// Edit 4a: rekey the `snapshots:` entry (`name@version` → +/// `name@file:`), body verbatim. +fn edit_snapshot_rekey( + lines: &mut Vec, + ctx: &EditCtx<'_>, + wiring: &mut Vec, +) -> Result { + let Some((start, end)) = section_bounds(lines, "snapshots") else { + return Ok(false); // a lock without snapshots has nothing to rekey + }; + let reg_key = ctx.reg_key(); + let new_key = ctx.new_key(); + let ours_prefix = format!("{}@file:", ctx.name); + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + let is_registry = block.key == reg_key; + let is_ours_key = block + .key + .strip_prefix(&ours_prefix) + .is_some_and(|rest| parse_vendor_path(rest).is_some_and(|p| p.eco == "npm")); + if !is_registry && !is_ours_key { + i = block.end; + continue; + } + if block.key == new_key { + return Ok(false); // in sync + } + let original_lines: Vec = lines[block.header..block.end].to_vec(); + let mut new_lines = original_lines.clone(); + new_lines[0] = format!(" {}:{}", yaml_key_like(&new_key, &block.repr), block.rest_suffix()); + let header = block.header; + let block_end = block.end; + lines.splice(header..block_end, new_lines.clone()); + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_SNAPSHOT.to_string(), + action: WiringAction::Rewritten, + key: Some(block.key.clone()), + original: if is_ours_key { None } else { Some(lines_value(&original_lines)) }, + new: Some(lines_value(&new_lines)), + }); + return Ok(true); + } + Ok(false) +} + +/// Edit 4b: every OTHER snapshot's dep reference to the exact version — +/// `name: ` → bare `name: file:` (spike P1: dependents +/// reference the override with no `name@` prefix). +// &mut Vec keeps all five edit functions' signatures unifiable into the one +// fn array `vendor_pnpm` iterates (the section-splicing edits need the Vec). +#[allow(clippy::ptr_arg)] +fn edit_snapshot_refs( + lines: &mut Vec, + ctx: &EditCtx<'_>, + wiring: &mut Vec, +) -> Result { + let Some((start, end)) = section_bounds(lines, "snapshots") else { + return Ok(false); + }; + let mut changed = false; + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + for line in lines[block.header + 1..block.end].iter_mut() { + let Some((dep, _repr, rest)) = parse_key_line(line, 6) else { continue }; + if dep != ctx.name { + continue; + } + let target = + rest == ctx.version || (rest != ctx.spec && ctx.is_ours(&rest)); + if !target { + continue; + } + let was_ours = ctx.is_ours(&rest); + *line = format!(" {}: {}", yaml_key(&dep), ctx.spec); + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_SNAPSHOT_REF.to_string(), + action: WiringAction::Rewritten, + key: Some(format!("{}|{dep}", block.key)), + original: if was_ours { None } else { Some(Value::String(rest.clone())) }, + new: Some(Value::String(ctx.spec.to_string())), + }); + changed = true; + } + i = block.end; + } + Ok(changed) +} + +// ───────────────────────────── revert helpers ───────────────────────────── + +fn revert_pkg_record( + doc: &mut Value, + rec: &WiringRecord, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + if rec.kind != KIND_PKG_OVERRIDE { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("unknown wiring kind `{}` for {PACKAGE_JSON}; left alone", rec.kind), + )); + return; + } + let Some(key) = rec.key.as_deref() else { + warnings.push(drifted("package.json override record has no key; left alone")); + return; + }; + let overrides = doc + .get_mut("pnpm") + .and_then(|p| p.get_mut("overrides")) + .and_then(Value::as_object_mut); + let Some(overrides) = overrides else { + warnings.push(drifted(format!("pnpm.overrides is gone; `{key}` not removed"))); + return; + }; + let live = overrides.get(key).and_then(Value::as_str); + let ours = live.is_some_and(|v| { + Some(v) == rec.new.as_ref().and_then(Value::as_str) + || parse_vendor_path(v).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid) + }); + if !ours { + warnings.push(drifted(format!( + "pnpm.overrides[`{key}`] was changed since vendoring ({live:?}); left alone" + ))); + return; + } + overrides.shift_remove(key); + *dirty = true; +} + +fn revert_lock_record( + lines: &mut Vec, + rec: &WiringRecord, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some(key) = rec.key.as_deref() else { + warnings.push(drifted(format!("wiring record in {PNPM_LOCK} has no key; left alone"))); + return; + }; + match rec.kind.as_str() { + KIND_LOCK_OVERRIDES => revert_overrides_line(lines, rec, key, entry_uuid, dirty, warnings), + KIND_LOCK_IMPORTER_DEP => { + revert_importer_dep(lines, rec, key, entry_uuid, dirty, warnings) + } + KIND_LOCK_PACKAGE => { + revert_block(lines, rec, key, "packages", entry_uuid, dirty, warnings) + } + KIND_LOCK_SNAPSHOT => { + revert_block(lines, rec, key, "snapshots", entry_uuid, dirty, warnings) + } + KIND_LOCK_SNAPSHOT_REF => { + revert_snapshot_ref(lines, rec, key, entry_uuid, dirty, warnings) + } + other => warnings.push(drifted(format!( + "unknown wiring kind `{other}` for `{key}`; left alone" + ))), + } +} + +fn revert_overrides_line( + lines: &mut Vec, + rec: &WiringRecord, + key: &str, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some((start, end)) = section_bounds(lines, "overrides") else { + warnings.push(drifted(format!("overrides section is gone; `{key}` not removed"))); + return; + }; + // First pass: locate our line + count the other entries (the section is + // pruned only when ours was the last one). + let mut ours_at = None; + let mut others = 0usize; + for (i, line) in lines.iter().enumerate().take(end).skip(start + 1) { + if let Some((k, _repr, rest)) = parse_key_line(line, 2) { + if k == key && ours_at.is_none() { + ours_at = Some((i, rest)); + } else { + others += 1; + } + } + } + let Some((idx, rest)) = ours_at else { + warnings.push(drifted(format!("overrides entry `{key}` no longer exists"))); + return; + }; + let ours = Some(rest.as_str()) == rec.new.as_ref().and_then(Value::as_str) + || parse_vendor_path(&rest).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if !ours { + warnings.push(drifted(format!( + "overrides entry `{key}` was changed since vendoring ({rest}); left alone" + ))); + return; + } + lines.remove(idx); + *dirty = true; + if others == 0 { + // Ours was the last entry: drop the section header (and its blank + // separator) too — pnpm never emits an empty overrides section. + lines.remove(start); + if start < lines.len() && lines[start].is_empty() { + lines.remove(start); + } + } +} + +fn revert_importer_dep( + lines: &mut [String], + rec: &WiringRecord, + key: &str, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some((importer_key, dep)) = key.rsplit_once('|') else { + warnings.push(drifted(format!("malformed importer-dep key `{key}`; left alone"))); + return; + }; + let Some((start, end)) = section_bounds(lines, "importers") else { + warnings.push(drifted("importers section is gone; nothing to restore".to_string())); + return; + }; + let mut i = start + 1; + while let Some(importer) = next_block(lines, i, end) { + if importer.key != importer_key { + i = importer.end; + continue; + } + let mut k = importer.header + 1; + while k < importer.end { + let Some((d, _repr, rest)) = parse_key_line(&lines[k], 6) else { + k += 1; + continue; + }; + if d != dep || !rest.is_empty() { + k += 1; + continue; + } + let mut spec_idx = None; + let mut ver_idx = None; + let mut f = k + 1; + while f < importer.end { + let Some((field, _fr, fval)) = parse_key_line(&lines[f], 8) else { break }; + match field.as_str() { + "specifier" => spec_idx = Some(f), + "version" => ver_idx = Some((f, fval)), + _ => {} + } + f += 1; + } + let (Some(si), Some((vi, live_ver))) = (spec_idx, ver_idx) else { break }; + let new_ver = rec + .new + .as_ref() + .and_then(|n| n.get("version")) + .and_then(Value::as_str); + let ours = Some(live_ver.as_str()) == new_ver + || parse_vendor_path(&live_ver) + .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if !ours { + warnings.push(drifted(format!( + "importer dep `{key}` was re-resolved since vendoring ({live_ver}); left alone" + ))); + return; + } + let Some(original) = rec.original.as_ref() else { + warnings.push(drifted(format!( + "importer dep `{key}` has no recorded pre-vendor original; left as-is \ + (re-run `pnpm install` to re-resolve it)" + ))); + return; + }; + let (Some(orig_spec), Some(orig_ver)) = ( + original.get("specifier").and_then(Value::as_str), + original.get("version").and_then(Value::as_str), + ) else { + warnings.push(drifted(format!("importer dep `{key}` original is malformed"))); + return; + }; + lines[si] = format!(" specifier: {orig_spec}"); + lines[vi] = format!(" version: {orig_ver}"); + *dirty = true; + return; + } + break; + } + warnings.push(drifted(format!("importer dep `{key}` no longer exists; nothing to restore"))); +} + +/// Restore a rekeyed packages/snapshots block: locate the block by the NEW +/// key (from `rec.new`'s first line), verify ownership, splice the original +/// lines back. +fn revert_block( + lines: &mut Vec, + rec: &WiringRecord, + key: &str, + section: &str, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let new_lines = rec.new.as_ref().and_then(value_lines); + let Some(new_lines) = new_lines else { + warnings.push(drifted(format!("record for `{key}` has no `new` fragment; left alone"))); + return; + }; + let Some((new_key, _repr, _rest)) = + new_lines.first().and_then(|l| parse_key_line(l, 2)) + else { + warnings.push(drifted(format!("record for `{key}` has a malformed fragment"))); + return; + }; + let Some((start, end)) = section_bounds(lines, section) else { + warnings.push(drifted(format!("{section} section is gone; `{key}` not restored"))); + return; + }; + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + if block.key != new_key { + i = block.end; + continue; + } + // Ours iff the live block is exactly what we wrote, or its key still + // points into OUR uuid dir (a re-serialized but unmoved entry). + let live: Vec = lines[block.header..block.end].to_vec(); + let key_is_ours = new_key + .rsplit_once("@file:") + .is_some_and(|(_, p)| { + parse_vendor_path(p).is_some_and(|v| v.eco == "npm" && v.uuid == entry_uuid) + }); + if live != new_lines && !key_is_ours { + warnings.push(drifted(format!( + "{section} entry `{new_key}` was changed since vendoring; left alone" + ))); + return; + } + let Some(original) = rec.original.as_ref().and_then(value_lines) else { + warnings.push(drifted(format!( + "{section} entry `{key}` has no recorded pre-vendor original; left as-is \ + (re-run `pnpm install` to re-resolve it)" + ))); + return; + }; + let header = block.header; + let block_end = block.end; + lines.splice(header..block_end, original); + *dirty = true; + return; + } + warnings.push(drifted(format!( + "{section} entry `{new_key}` no longer exists; nothing to restore" + ))); +} + +fn revert_snapshot_ref( + lines: &mut [String], + rec: &WiringRecord, + key: &str, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some((snapshot_key, dep)) = key.rsplit_once('|') else { + warnings.push(drifted(format!("malformed snapshot-ref key `{key}`; left alone"))); + return; + }; + let Some((start, end)) = section_bounds(lines, "snapshots") else { + warnings.push(drifted("snapshots section is gone; nothing to restore".to_string())); + return; + }; + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + if block.key != snapshot_key { + i = block.end; + continue; + } + for line in lines[block.header + 1..block.end].iter_mut() { + let Some((d, _repr, rest)) = parse_key_line(line, 6) else { continue }; + if d != dep { + continue; + } + let ours = Some(rest.as_str()) == rec.new.as_ref().and_then(Value::as_str) + || parse_vendor_path(&rest) + .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if !ours { + warnings.push(drifted(format!( + "snapshot ref `{key}` was re-resolved since vendoring ({rest}); left alone" + ))); + return; + } + let Some(original) = rec.original.as_ref().and_then(Value::as_str) else { + warnings.push(drifted(format!( + "snapshot ref `{key}` has no recorded pre-vendor original; left as-is" + ))); + return; + }; + *line = format!(" {}: {original}", yaml_key(dep)); + *dirty = true; + return; + } + break; + } + warnings.push(drifted(format!("snapshot ref `{key}` no longer exists; nothing to restore"))); +} + +fn drifted(detail: impl Into) -> VendorWarning { + VendorWarning::new("vendor_lock_entry_drifted", detail.into()) +} + +// ─────────────────────────── pair commit + unwind ───────────────────────── + +/// Write the pair: package.json FIRST, lock second; a lock failure restores +/// the original package.json bytes so the P3 desync (override without lock +/// entry or vice versa) is never left on disk. +async fn commit_pair( + project_root: &Path, + new_pkg: Option<&[u8]>, + original_pkg: &[u8], + new_lock: Option<&[u8]>, +) -> Result<(), String> { + if let Some(bytes) = new_pkg { + atomic_write_bytes(&project_root.join(PACKAGE_JSON), bytes) + .await + .map_err(|e| format!("cannot write {PACKAGE_JSON}: {e}"))?; + } + if let Some(bytes) = new_lock { + if let Err(e) = atomic_write_bytes(&project_root.join(PNPM_LOCK), bytes).await { + if new_pkg.is_some() { + // Unwind (best effort): a failure here leaves the desync pair + // anyway, but the lock write failing usually means the + // restore fails identically loudly. + let _ = + atomic_write_bytes(&project_root.join(PACKAGE_JSON), original_pkg).await; + } + return Err(format!( + "cannot write {PNPM_LOCK}: {e} ({PACKAGE_JSON} restored to its original bytes)" + )); + } + } + Ok(()) +} + +// ─────────────────────── yaml-ish line-block helpers ────────────────────── +// pnpm-lock.yaml is machine-emitted with a fixed 2/4/6/8-space shape; these +// helpers splice line blocks and never interpret YAML generically. + +fn split_lines(text: &str) -> Vec { + text.split('\n').map(str::to_string).collect() +} + +fn join_lines(lines: &[String]) -> String { + lines.join("\n") +} + +/// `(header_idx, end_idx)` of a top-level `name:` section; `end` is the +/// first following column-0 line (exclusive), so trailing blank separator +/// lines belong to the section. +fn section_bounds(lines: &[String], name: &str) -> Option<(usize, usize)> { + let header = format!("{name}:"); + let start = lines.iter().position(|l| l == &header)?; + let end = lines + .iter() + .enumerate() + .skip(start + 1) + .find(|(_, l)| !l.is_empty() && !l.starts_with(' ')) + .map(|(i, _)| i) + .unwrap_or(lines.len()); + Some((start, end)) +} + +/// One 2-space-keyed block inside a section (`[header, end)`; `end` stops at +/// the blank separator / next block header, so the captured fragment is the +/// verbatim entry without surrounding blanks). +struct YamlBlock { + header: usize, + end: usize, + key: String, + /// The key exactly as spelled in the file (incl. quotes) — rekeys + /// preserve the file's quoting style. + repr: String, + /// Inline value after `:` (e.g. `{}` for empty snapshots), `""` if none. + rest: String, +} + +impl YamlBlock { + /// The inline-rest suffix to re-emit after the (re)written key. + fn rest_suffix(&self) -> String { + if self.rest.is_empty() { String::new() } else { format!(" {}", self.rest) } + } +} + +/// The next block at or after line `i` (within `[i, end)`). +fn next_block(lines: &[String], mut i: usize, end: usize) -> Option { + while i < end { + if let Some((key, repr, rest)) = parse_key_line(&lines[i], 2) { + let mut j = i + 1; + while j < end && !lines[j].is_empty() && indent_of(&lines[j]) >= 4 { + j += 1; + } + return Some(YamlBlock { header: i, end: j, key, repr, rest }); + } + i += 1; + } + None +} + +fn indent_of(line: &str) -> usize { + line.len() - line.trim_start_matches(' ').len() +} + +/// Parse a mapping line at exactly `indent` spaces into +/// `(key, verbatim_key_repr, value_after_colon)`. Accepts pnpm's bare keys +/// and both quote styles (single quotes are what pnpm emits for `@`-leading +/// keys); the value separator is the first `:` followed by a space or EOL +/// (keys themselves contain `:` in `file:` specs). +fn parse_key_line(line: &str, indent: usize) -> Option<(String, String, String)> { + if line.len() <= indent || !line.as_bytes()[..indent].iter().all(|&b| b == b' ') { + return None; + } + let s = &line[indent..]; + let c0 = s.as_bytes()[0]; + if c0 == b' ' { + return None; + } + if c0 == b'\'' || c0 == b'"' { + let quote = c0 as char; + let close = s[1..].find(quote)? + 1; + let after = &s[close + 1..]; + let rest = after.strip_prefix(':')?; + let rest = rest.strip_prefix(' ').unwrap_or(rest); + return Some(( + s[1..close].to_string(), + s[..close + 1].to_string(), + rest.to_string(), + )); + } + let bytes = s.as_bytes(); + for i in 0..bytes.len() { + if bytes[i] == b':' && (i + 1 == bytes.len() || bytes[i + 1] == b' ') { + if i == 0 { + return None; + } + let rest = if i + 1 < bytes.len() { &s[i + 2..] } else { "" }; + return Some((s[..i].to_string(), s[..i].to_string(), rest.to_string())); + } + } + None +} + +/// pnpm quotes `@`-leading keys with single quotes; everything we write is +/// otherwise bare. +fn yaml_key(key: &str) -> String { + if key.starts_with('@') { format!("'{key}'") } else { key.to_string() } +} + +/// Re-spell `key` in the same quoting style as the original `repr`. +fn yaml_key_like(key: &str, original_repr: &str) -> String { + match original_repr.as_bytes().first() { + Some(b'\'') => format!("'{key}'"), + Some(b'"') => format!("\"{key}\""), + _ => yaml_key(key), + } +} + +fn lines_value(lines: &[String]) -> Value { + Value::Array(lines.iter().map(|l| Value::String(l.clone())).collect()) +} + +fn value_lines(v: &Value) -> Option> { + v.as_array() + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) +} + +// ───────────────────────── small shared helpers ─────────────────────────── +// (same shapes as npm_lock's; duplicated because that module's helpers are +// private and this file is the only allowed edit surface) + +/// The file's indent unit: the leading whitespace of the first indented +/// line. Defaults to 2 spaces (npm/pnpm's own emission). +fn detect_indent(text: &str) -> String { + for line in text.lines() { + let trimmed = line.trim_start_matches([' ', '\t']); + if !trimmed.is_empty() && trimmed.len() < line.len() { + return line[..line.len() - trimmed.len()].to_string(); + } + } + " ".to_string() +} + +/// Pretty-print JSON with the detected indent + trailing newline. +fn serialize_json(doc: &Value, indent: &str) -> std::io::Result> { + use serde::Serialize; + let mut out = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes()); + let mut ser = serde_json::Serializer::with_formatter(&mut out, formatter); + doc.serialize(&mut ser).map_err(std::io::Error::other)?; + out.push(b'\n'); + Ok(out) +} + +fn synthesized_result( + package_key: &str, + path: &Path, + files_verified: Vec, + success: bool, + error: Option, +) -> ApplyResult { + ApplyResult { + package_key: package_key.to_string(), + package_path: path.display().to_string(), + success, + files_verified, + files_patched: Vec::new(), + applied_via: HashMap::new(), + error, + sidecar: None, + } +} + +fn already_patched_verify(file: &str) -> VerifyResult { + VerifyResult { + file: file.to_string(), + status: VerifyStatus::AlreadyPatched, + message: None, + current_hash: None, + expected_hash: None, + target_hash: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; + use base64::Engine as _; + use sha2::{Digest, Sha512}; + use std::path::PathBuf; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; + const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + + /// The spike tarball's integrity, as committed in the after-fixtures. + /// Our pack pipeline produces a DIFFERENT (deterministic) tarball, so + /// fixture comparisons substitute the actual integrity for this token — + /// everything else must be byte-identical. + const SPIKE_INTEGRITY: &str = + "sha512-VR8nCbFxvOcFX5Rxku2psjaj0+xzKdzFkcuqZJSHf597bMVomG100t6+cJkMBFRLhyVdSVwufbCwVzlCzZkUwg=="; + + // ── tool-generated byte-exact oracles ───────────────────────────────── + // Provenance: spikes/pnpm/p1-multi-dep/{before,after}/ — generated by + // pnpm 9.15.9 AND 10.34.1 (byte-identical on both majors), spike P1/P2. + const P1_BEFORE_PKG: &str = r#"{ + "name": "vendor-spike", + "version": "1.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer", + "left-pad": "1.3.0", + "left-pad-old": "npm:left-pad@1.2.0" + } +} +"#; + const P1_AFTER_PKG: &str = r#"{ + "name": "vendor-spike", + "version": "1.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer", + "left-pad": "1.3.0", + "left-pad-old": "npm:left-pad@1.2.0" + }, + "pnpm": { + "overrides": { + "left-pad@1.3.0": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } + } +} +"#; + const P1_BEFORE_LOCK: &str = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + consumer: + specifier: file:./consumer + version: file:consumer + left-pad: + specifier: 1.3.0 + version: 1.3.0 + left-pad-old: + specifier: npm:left-pad@1.2.0 + version: left-pad@1.2.0 + +packages: + + consumer@file:consumer: + resolution: {directory: consumer, type: directory} + + left-pad@1.2.0: + resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} + deprecated: use String.prototype.padStart() + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + +snapshots: + + consumer@file:consumer: + dependencies: + left-pad: 1.3.0 + + left-pad@1.2.0: {} + + left-pad@1.3.0: {} +"; + const P1_AFTER_LOCK: &str = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + +importers: + + .: + dependencies: + consumer: + specifier: file:./consumer + version: file:consumer + left-pad: + specifier: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + version: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + left-pad-old: + specifier: npm:left-pad@1.2.0 + version: left-pad@1.2.0 + +packages: + + consumer@file:consumer: + resolution: {directory: consumer, type: directory} + + left-pad@1.2.0: + resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} + deprecated: use String.prototype.padStart() + + left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-VR8nCbFxvOcFX5Rxku2psjaj0+xzKdzFkcuqZJSHf597bMVomG100t6+cJkMBFRLhyVdSVwufbCwVzlCzZkUwg==, tarball: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz} + version: 1.3.0 + +snapshots: + + consumer@file:consumer: + dependencies: + left-pad: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + + left-pad@1.2.0: {} + + left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: {} +"; + + // Provenance: spikes/pnpm/p7-workspace/{before,after}/ (spike P7) — the + // per-importer re-relativized specifier vs root-relative version. + const P7_BEFORE_PKG: &str = r#"{ + "name": "ws-root", + "version": "1.0.0", + "private": true +} +"#; + const P7_AFTER_PKG: &str = r#"{ + "name": "ws-root", + "version": "1.0.0", + "private": true, + "pnpm": { + "overrides": { + "left-pad@1.3.0": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } + } +} +"#; + const P7_BEFORE_LOCK: &str = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + packages/app: + dependencies: + left-pad: + specifier: ^1.3.0 + version: 1.3.0 + +packages: + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + +snapshots: + + left-pad@1.3.0: {} +"; + const P7_AFTER_LOCK: &str = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + +importers: + + .: {} + + packages/app: + dependencies: + left-pad: + specifier: file:../../.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + version: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz + +packages: + + left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-VR8nCbFxvOcFX5Rxku2psjaj0+xzKdzFkcuqZJSHf597bMVomG100t6+cJkMBFRLhyVdSVwufbCwVzlCzZkUwg==, tarball: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz} + version: 1.3.0 + +snapshots: + + left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: {} +"; + + struct Fixture { + tmp: tempfile::TempDir, + record: PatchRecord, + } + + impl Fixture { + fn root(&self) -> &Path { + self.tmp.path() + } + + fn installed(&self) -> PathBuf { + self.root().join("node_modules/left-pad") + } + + fn rel_tgz(&self) -> String { + format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") + } + + async fn read(&self, name: &str) -> String { + tokio::fs::read_to_string(self.root().join(name)).await.unwrap() + } + + /// The actual SRI of the tarball our pack produced. + async fn actual_integrity(&self) -> String { + let tgz = tokio::fs::read(self.root().join(self.rel_tgz())).await.unwrap(); + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&tgz)) + ) + } + + async fn vendor(&self, dry_run: bool) -> VendorOutcome { + let blobs = self.root().join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + vendor_pnpm( + "pkg:npm/left-pad@1.3.0", + &self.installed(), + self.root(), + &self.record, + &sources, + "2026-06-09T00:00:00Z", + dry_run, + false, + ) + .await + } + } + + async fn fixture_with(pkg_json: &str, lock: &str) -> Fixture { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + let installed = root.join("node_modules/left-pad"); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write( + installed.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .await + .unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX).await.unwrap(); + + let blobs = root.join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX).await.unwrap(); + + tokio::fs::write(root.join(PACKAGE_JSON), pkg_json).await.unwrap(); + tokio::fs::write(root.join(PNPM_LOCK), lock).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(ORIG_INDEX), + after_hash, + }, + ); + let record = PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-06-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: "test patch".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }; + Fixture { tmp, record } + } + + fn expect_done( + outcome: VendorOutcome, + ) -> (ApplyResult, Option, Vec) { + match outcome { + VendorOutcome::Done { result, entry, warnings } => (result, entry, warnings), + VendorOutcome::Refused { code, detail } => { + panic!("expected Done, got Refused {code}: {detail}") + } + } + } + + fn expect_refused(outcome: VendorOutcome, want_code: &str) -> String { + match outcome { + VendorOutcome::Refused { code, detail } => { + assert_eq!(code, want_code, "wrong refusal code ({detail})"); + detail + } + VendorOutcome::Done { result, .. } => { + panic!("expected Refused {want_code}, got Done (success={})", result.success) + } + } + } + + #[tokio::test] + async fn p1_fixture_oracle_transform_is_byte_identical_for_both_files() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("success carries a ledger entry"); + + // package.json: byte-identical to the pnpm-blessed after fixture. + assert_eq!(fx.read(PACKAGE_JSON).await, P1_AFTER_PKG); + + // Lock: byte-identical modulo the integrity (ours is recomputed from + // the deterministic tarball we packed — never the spike's bytes). + let actual = fx.actual_integrity().await; + assert_ne!(actual, SPIKE_INTEGRITY, "different tarballs, different hashes"); + let expected_lock = P1_AFTER_LOCK.replace(SPIKE_INTEGRITY, &actual); + assert_eq!(fx.read(PNPM_LOCK).await, expected_lock); + + // Ledger facts: flavor + meta + wiring kinds. + assert_eq!(entry.flavor.as_deref(), Some("pnpm")); + assert_eq!( + entry.pnpm, + Some(PnpmMeta { created_overrides_table: true, created_pnpm_table: true }) + ); + assert_eq!(entry.artifact.path, fx.rel_tgz()); + let kinds: Vec<&str> = entry.wiring.iter().map(|r| r.kind.as_str()).collect(); + assert_eq!( + kinds, + vec![ + KIND_PKG_OVERRIDE, + KIND_LOCK_OVERRIDES, + KIND_LOCK_IMPORTER_DEP, + KIND_LOCK_PACKAGE, + KIND_LOCK_SNAPSHOT, + KIND_LOCK_SNAPSHOT_REF, + ], + "{:?}", + entry.wiring + ); + // The transitive consumer snapshot-ref is keyed snapshot|dep. + let snap_ref = entry.wiring.iter().find(|r| r.kind == KIND_LOCK_SNAPSHOT_REF).unwrap(); + assert_eq!(snap_ref.key.as_deref(), Some("consumer@file:consumer|left-pad")); + assert_eq!(snap_ref.original, Some(Value::String("1.3.0".into()))); + + // Scoping: the 1.2.0 sibling stayed registry (asserted by the byte + // oracle above, re-asserted explicitly here). + let lock = fx.read(PNPM_LOCK).await; + assert!(lock.contains(" left-pad@1.2.0:\n resolution: {integrity: sha512-OQadpCyF")); + assert!(lock.contains(" version: left-pad@1.2.0\n"), "aliased 1.2.0 importer untouched"); + } + + #[tokio::test] + async fn p7_workspace_fixture_re_relativizes_the_sub_importer_specifier() { + let fx = fixture_with(P7_BEFORE_PKG, P7_BEFORE_LOCK).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + + assert_eq!(fx.read(PACKAGE_JSON).await, P7_AFTER_PKG); + let expected_lock = + P7_AFTER_LOCK.replace(SPIKE_INTEGRITY, &fx.actual_integrity().await); + assert_eq!(fx.read(PNPM_LOCK).await, expected_lock); + + let dep = entry.wiring.iter().find(|r| r.kind == KIND_LOCK_IMPORTER_DEP).unwrap(); + assert_eq!(dep.key.as_deref(), Some("packages/app|left-pad")); + assert_eq!( + dep.new.as_ref().unwrap()["specifier"], + Value::String(format!("file:../../{}", fx.rel_tgz())), + "specifier is re-relativized per importer" + ); + assert_eq!( + dep.new.as_ref().unwrap()["version"], + Value::String(format!("file:{}", fx.rel_tgz())), + "version stays lockfile-root-relative" + ); + } + + #[tokio::test] + async fn existing_user_override_for_the_name_is_refused() { + // Name-keyed, range-keyed, and exact-key-but-foreign-value overrides + // all conflict; an override for a DIFFERENT package does not. + for key in ["left-pad", "left-pad@^1", "left-pad@1.3.0"] { + let pkg = format!( + "{{\n \"name\": \"x\",\n \"pnpm\": {{\n \"overrides\": {{\n \"{key}\": \"1.2.0\"\n }}\n }}\n}}\n" + ); + let fx = fixture_with(&pkg, P1_BEFORE_LOCK).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_override_conflict"); + assert!(detail.contains(key), "{detail}"); + assert!(!fx.root().join(".socket/vendor").exists(), "refusal writes nothing"); + assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK, "lock untouched"); + } + + // Lock-side desynced override conflicts too. + let lock = P1_BEFORE_LOCK.replace( + "importers:", + "overrides:\n left-pad: 1.2.0\n\nimporters:", + ); + let fx = fixture_with(P1_BEFORE_PKG, &lock).await; + expect_refused(fx.vendor(false).await, "vendor_override_conflict"); + + // Unrelated override: fine. + let pkg = r#"{ + "name": "x", + "dependencies": { "left-pad": "1.3.0" }, + "pnpm": { + "overrides": { + "other-pkg": "2.0.0" + } + } +} +"#; + let lock = P1_BEFORE_LOCK.replace( + "importers:", + "overrides:\n other-pkg: 2.0.0\n\nimporters:", + ); + let fx = fixture_with(pkg, &lock).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + assert_eq!( + entry.pnpm, + Some(PnpmMeta { created_overrides_table: false, created_pnpm_table: false }) + ); + // Our entry extends the existing overrides section, theirs intact. + let live = fx.read(PNPM_LOCK).await; + assert!(live.contains("overrides:\n other-pkg: 2.0.0\n left-pad@1.3.0: file:")); + + // Revert removes only ours, keeping the user's table + section. + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + let live: Value = + serde_json::from_str(&fx.read(PACKAGE_JSON).await).unwrap(); + assert_eq!(live["pnpm"]["overrides"]["other-pkg"], Value::String("2.0.0".into())); + assert!(live["pnpm"]["overrides"].get("left-pad@1.3.0").is_none()); + let live_lock = fx.read(PNPM_LOCK).await; + assert!(live_lock.contains("overrides:\n other-pkg: 2.0.0\n\nimporters:")); + } + + #[tokio::test] + async fn created_tables_bookkeeping_and_revert_prunes_them() { + // pnpm table exists (other keys), overrides created by us: revert + // must remove the emptied overrides table but KEEP the pnpm table. + let pkg = r#"{ + "name": "x", + "dependencies": { "left-pad": "1.3.0" }, + "pnpm": { + "onlyBuiltDependencies": [] + } +} +"#; + let fx = fixture_with(pkg, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + assert_eq!( + entry.pnpm, + Some(PnpmMeta { created_overrides_table: true, created_pnpm_table: false }) + ); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + let live: Value = serde_json::from_str(&fx.read(PACKAGE_JSON).await).unwrap(); + assert!(live["pnpm"].get("overrides").is_none(), "created overrides table pruned"); + assert!( + live["pnpm"].get("onlyBuiltDependencies").is_some(), + "pre-existing pnpm table kept: {live}" + ); + + // Both created (P1): revert prunes pnpm entirely → byte round-trip. + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let outcome = revert_pnpm(&entry.unwrap(), fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + } + + #[tokio::test] + async fn commit_pair_unwinds_package_json_on_lock_write_failure() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::write(root.join(PACKAGE_JSON), P1_BEFORE_PKG).await.unwrap(); + // A directory where the lock should be makes the atomic rename fail + // AFTER package.json was already written. + tokio::fs::create_dir(root.join(PNPM_LOCK)).await.unwrap(); + + let err = commit_pair( + root, + Some(P1_AFTER_PKG.as_bytes()), + P1_BEFORE_PKG.as_bytes(), + Some(b"lock bytes"), + ) + .await + .unwrap_err(); + assert!(err.contains(PNPM_LOCK), "{err}"); + assert_eq!( + tokio::fs::read_to_string(root.join(PACKAGE_JSON)).await.unwrap(), + P1_BEFORE_PKG, + "package.json restored byte-for-byte after the lock failure" + ); + } + + #[tokio::test] + async fn rerun_is_in_sync_and_byte_stable() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + assert!(entry.is_some()); + let pkg_first = fx.read(PACKAGE_JSON).await; + let lock_first = fx.read(PNPM_LOCK).await; + let tgz_first = tokio::fs::read(fx.root().join(fx.rel_tgz())).await.unwrap(); + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success); + assert!(entry.is_none(), "in-sync re-run records nothing"); + assert!( + result.files_verified.iter().all(|v| v.status == VerifyStatus::AlreadyPatched), + "{:?}", + result.files_verified + ); + assert_eq!(fx.read(PACKAGE_JSON).await, pkg_first); + assert_eq!(fx.read(PNPM_LOCK).await, lock_first); + assert_eq!( + tokio::fs::read(fx.root().join(fx.rel_tgz())).await.unwrap(), + tgz_first, + "tarball byte-identical across re-runs" + ); + } + + #[tokio::test] + async fn dry_run_writes_nothing() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (result, entry, _) = expect_done(fx.vendor(true).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none()); + assert!(result.files_patched.is_empty()); + + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK); + assert!(!fx.root().join(".socket/vendor").exists()); + assert_eq!( + tokio::fs::read(fx.installed().join("index.js")).await.unwrap(), + ORIG_INDEX, + "vendor never patches in place" + ); + } + + #[tokio::test] + async fn revert_round_trips_both_files_and_removes_the_artifact() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let tgz_path = fx.root().join(fx.rel_tgz()); + assert!(tgz_path.exists()); + + // Dry-run revert touches nothing. + let outcome = revert_pnpm(&entry, fx.root(), true).await; + assert!(outcome.success); + assert!(tgz_path.exists()); + assert_ne!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG, "package.json byte-restored"); + assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK, "lock byte-restored"); + assert!(!tgz_path.exists()); + assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + } + + #[tokio::test] + async fn revert_allowlist_is_fail_closed() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + // A poisoned ledger names a file outside the pair. + tokio::fs::write(fx.root().join("Cargo.toml"), b"[package]\n").await.unwrap(); + entry.wiring.push(WiringRecord { + file: "Cargo.toml".to_string(), + kind: KIND_LOCK_OVERRIDES.to_string(), + action: WiringAction::Added, + key: Some("left-pad@1.3.0".to_string()), + original: None, + new: Some(Value::String("evil".to_string())), + }); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted" && w.detail.contains("Cargo.toml")), + "{:?}", + outcome.warnings + ); + assert_eq!( + tokio::fs::read(fx.root().join("Cargo.toml")).await.unwrap(), + b"[package]\n", + "non-allowlisted file never touched" + ); + // And the real pair still round-tripped. + assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK); + } + + #[tokio::test] + async fn revert_leaves_drifted_fragments_alone_with_warnings() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + // The user re-resolved the importer dep behind our back. + let live = fx.read(PNPM_LOCK).await; + let drifted_lock = live.replace( + &format!( + " left-pad:\n specifier: file:{rel}\n version: file:{rel}\n", + rel = fx.rel_tgz() + ), + " left-pad:\n specifier: 1.3.1\n version: 1.3.1\n", + ); + assert_ne!(drifted_lock, live, "test setup must actually drift the entry"); + tokio::fs::write(fx.root().join(PNPM_LOCK), &drifted_lock).await.unwrap(); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted" + && w.detail.contains(".|left-pad")), + "{:?}", + outcome.warnings + ); + let after = fx.read(PNPM_LOCK).await; + assert!( + after.contains(" specifier: 1.3.1\n version: 1.3.1\n"), + "drifted importer dep left alone: {after}" + ); + // Non-drifted fragments still restored. + assert!(after.contains(" left-pad@1.3.0:\n resolution: {integrity: sha512-XI5MPzVN")); + assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + } + + #[tokio::test] + async fn preflight_refusals_fire_before_any_write() { + // Missing lock. + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + tokio::fs::remove_file(fx.root().join(PNPM_LOCK)).await.unwrap(); + let detail = expect_refused(fx.vendor(false).await, "vendor_lockfile_missing"); + assert!(detail.contains("pnpm install"), "{detail}"); + + // Unsupported lockfileVersion. + let fx = fixture_with(P1_BEFORE_PKG, &P1_BEFORE_LOCK.replace("'9.0'", "'6.0'")).await; + let detail = + expect_refused(fx.vendor(false).await, "vendor_lockfile_version_unsupported"); + assert!(detail.contains("6.0"), "{detail}"); + + // Missing package.json (the PAIR requirement). + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + tokio::fs::remove_file(fx.root().join(PACKAGE_JSON)).await.unwrap(); + expect_refused(fx.vendor(false).await, "vendor_lockfile_missing"); + + // Lock knows only another version of the package. + let lock = P1_BEFORE_LOCK.replace("1.3.0", "1.4.0"); + let fx = fixture_with(P1_BEFORE_PKG, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); + assert!(detail.contains("left-pad@1.3.0"), "{detail}"); + assert!(!fx.root().join(".socket/vendor").exists(), "refusals write nothing"); + } + + #[test] + fn override_key_name_grammar() { + assert_eq!(override_key_name("left-pad"), "left-pad"); + assert_eq!(override_key_name("left-pad@1.3.0"), "left-pad"); + assert_eq!(override_key_name("left-pad@^1"), "left-pad"); + assert_eq!(override_key_name("@scope/pkg"), "@scope/pkg"); + assert_eq!(override_key_name("@scope/pkg@2"), "@scope/pkg"); + assert_eq!(override_key_name("parent@1>left-pad@2"), "left-pad"); + } + + #[test] + fn key_line_parser_handles_both_quote_styles_and_file_specs() { + assert_eq!( + parse_key_line(" left-pad@1.3.0:", 2), + Some(("left-pad@1.3.0".into(), "left-pad@1.3.0".into(), String::new())) + ); + assert_eq!( + parse_key_line(" left-pad@1.3.0: {}", 2), + Some(("left-pad@1.3.0".into(), "left-pad@1.3.0".into(), "{}".into())) + ); + // Keys containing `:` (file: specs) split at the colon+space/EOL. + assert_eq!( + parse_key_line(" left-pad@file:x/y.tgz:", 2), + Some(("left-pad@file:x/y.tgz".into(), "left-pad@file:x/y.tgz".into(), String::new())) + ); + // pnpm's quoted @-keys (both majors single-quote them). + assert_eq!( + parse_key_line(" '@scope/a@1.0.0':", 2), + Some(("@scope/a@1.0.0".into(), "'@scope/a@1.0.0'".into(), String::new())) + ); + assert_eq!( + parse_key_line(" \"@scope/a@1.0.0\": {}", 2), + Some(("@scope/a@1.0.0".into(), "\"@scope/a@1.0.0\"".into(), "{}".into())) + ); + // Wrong indent / deeper lines are not keys at this level. + assert_eq!(parse_key_line(" resolution: {}", 2), None); + assert_eq!(parse_key_line(" - left-pad", 6), None, "list items are not keys"); + + assert_eq!(yaml_key("@scope/a@file:x"), "'@scope/a@file:x'"); + assert_eq!(yaml_key("left-pad@1.3.0"), "left-pad@1.3.0"); + assert_eq!(yaml_key_like("k", "'orig'"), "'k'"); + assert_eq!(yaml_key_like("k", "orig"), "k"); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs b/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs index 6c90a08..a728422 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs @@ -1 +1,1290 @@ -//! (stub — backend lands behind the npm_flavor / pypi router) +//! pdm-project wiring: a lock-ONLY `[[package]]` splice (pdm.lock +//! lock_version 4.5.x). +//! +//! pdm's `content_hash` covers the pyproject requirements only — identical +//! between strategy variants of the same pyproject — so a per-package lock +//! splice can never trip `pdm install --check` / `pdm lock --check` +//! freshness. The spike-captured D1 shape is a RELATIVE `path = "./…"` key +//! (inserted between `requires_python` and `summary`, exactly where pdm's own +//! serializer puts it) plus `files = []` reduced to the single patched-wheel +//! hash; `pdm sync` / `--check` / `--frozen-lockfile` all pass byte-stably +//! and unearth hash-verifies the local wheel fail-closed (D4). See +//! `spikes/pdm/` and the pdm section of `spikes/PHASE0-V2-FINDINGS.txt`. +//! +//! Drift caveat (spike D5): `pdm lock` and `pdm update ` silently revert +//! the splice with exit 0 (only plain `pdm install` preserves it); the lock's +//! files[] hash is the drift oracle. `pyproject.toml` and `content_hash` are +//! NEVER written by this backend. +//! +//! Spike caveat (D6, partial): only the `inherit_metadata` and `static_urls` +//! strategy shapes were captured, so any other `[metadata] strategy` flag +//! refuses; pdm 2.27 can no longer produce hash-less locks, so a files entry +//! without a sha256 refuses too (both fail-closed, not warnings). + +use std::path::Path; + +use toml_edit::{DocumentMut, Item, Value}; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::fs::atomic_write_bytes; + +use super::path::parse_vendor_path; +use super::state::{PdmMeta, VendorEntry, WiringAction, WiringRecord}; +use super::toml_surgery::find_unit_span; +use super::{RevertOutcome, VendorWarning}; + +/// The only file this backend ever writes (and the revert allowlist). +const LOCK_FILE: &str = "pdm.lock"; + +/// The `WiringRecord.kind` discriminator this backend owns. +const KIND_LOCK_PACKAGE: &str = "pdm_lock_package"; + +/// The `[metadata] strategy` flags whose lock shapes the spike captured +/// (D1 default + D6 static_urls). Any other flag refuses fail-closed. +const SUPPORTED_STRATEGIES: [&str; 2] = ["inherit_metadata", "static_urls"]; + +/// A loaded-and-guard-checked pdm project. +#[derive(Debug)] +pub struct PdmProject { + /// Verbatim pdm.lock text (the surgery substrate). + pub lock_text: String, + /// Parsed lock (guard checks only — every edit is text surgery). + pub lock: DocumentMut, + /// pyproject.toml content when present. NEVER written; read only to + /// classify the dependency for [`PdmMeta::dep_class`] diagnostics. + pub pyproject_text: Option, + /// pdm.lock `[metadata] lock_version` (recorded into [`PdmMeta`]). + pub lock_version: String, + /// pdm.lock `[metadata] strategy` (recorded into [`PdmMeta`]). + pub strategy: Vec, + /// Non-fatal advisories raised during load (untested lock version). + pub warnings: Vec, +} + +/// What the target `[[package]]` unit already looks like. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PdmTarget { + /// Registry-shaped: proceed to build the wheel and wire. + Fresh, + /// Already wired to THIS patch uuid — the caller synthesizes an + /// AlreadyPatched success, builds nothing, and records nothing (the + /// first run's ledger entry holds the only copy of the original). + InSync, +} + +/// Read + parse pdm.lock and run every project-level guard (lock version +/// series, strategy set). Refuses before ANY write — the orchestrator runs +/// this (and the target guards) before the wheel is built, so a refusal +/// leaves the tree byte-untouched. +pub async fn load_pdm_project(root: &Path) -> Result { + let lock_text = tokio::fs::read_to_string(root.join(LOCK_FILE)) + .await + .map_err(|e| { + ( + "pypi_pdm_lock_parse_failed", + format!("cannot read {LOCK_FILE}: {e}"), + ) + })?; + let lock: DocumentMut = lock_text.parse().map_err(|e| { + ( + "pypi_pdm_lock_parse_failed", + format!("{LOCK_FILE} does not parse: {e}"), + ) + })?; + + let metadata = lock.get("metadata"); + let lock_version = metadata + .and_then(|m| item_get(m, "lock_version")) + .and_then(Item::as_str) + .map(str::to_string) + .ok_or_else(|| { + ( + "pypi_pdm_lock_version_unsupported", + format!("{LOCK_FILE} has no [metadata] lock_version; re-lock with pdm >= 2.17"), + ) + })?; + let mut warnings = Vec::new(); + match lock_version_series(&lock_version) { + // The fixture series (pdm 2.27 writes 4.5.0). + LockVersionSeries::Supported => {} + // A newer 4.x minor keeps the shapes we rewrite (additive schema); + // warn instead of refusing — `pdm lock --check` is the backstop. + LockVersionSeries::NewerMinor => warnings.push(VendorWarning::new( + "pypi_pdm_lock_version_untested", + format!( + "pdm.lock lock_version {lock_version} is newer than the fixture-tested 4.5.x; \ + verify with `pdm install --check` after vendoring" + ), + )), + LockVersionSeries::Unsupported => { + return Err(( + "pypi_pdm_lock_version_unsupported", + format!( + "pdm.lock lock_version {lock_version:?} is outside the supported 4.5+ \ + series; re-lock with a current pdm" + ), + )) + } + } + + // SECURITY/correctness: strategies change the files[]/unit shapes; only + // the fixture-captured set is splice-proven (spike D6 was partial) — + // anything else refuses fail-closed rather than guessing an emitter shape. + let strategy: Vec = metadata + .and_then(|m| item_get(m, "strategy")) + .and_then(Item::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + if let Some(unknown) = strategy + .iter() + .find(|s| !SUPPORTED_STRATEGIES.contains(&s.as_str())) + { + return Err(( + "pypi_pdm_lock_strategy_unsupported", + format!( + "pdm.lock [metadata] strategy contains {unknown:?}; only \ + inherit_metadata/static_urls locks are fixture-tested" + ), + )); + } + + let pyproject_text = tokio::fs::read_to_string(root.join("pyproject.toml")).await.ok(); + Ok(PdmProject { + lock_text, + lock, + pyproject_text, + lock_version, + strategy, + warnings, + }) +} + +/// `"direct"` iff the package is declared in the pyproject — PEP 621 +/// `[project] dependencies` / `optional-dependencies`, +/// `[tool.pdm.dev-dependencies]` groups, or PEP 735 `[dependency-groups]` — +/// else `"transitive"`. Diagnostics ONLY ([`PdmMeta::dep_class`]): the splice +/// is identical either way, so a missing/unparseable pyproject degrades to +/// `"transitive"` instead of refusing. +pub fn classify_dependency(p: &PdmProject, canon_name: &str) -> &'static str { + let Some(text) = p.pyproject_text.as_deref() else { + return "transitive"; + }; + let Ok(doc) = text.parse::() else { + return "transitive"; + }; + let mut declared: Vec = Vec::new(); + if let Some(project) = doc.get("project") { + if let Some(deps) = item_get(project, "dependencies").and_then(Item::as_array) { + declared.extend( + deps.iter() + .filter_map(Value::as_str) + .map(|s| pep508_name(s).to_string()), + ); + } + if let Some(optional) = + item_get(project, "optional-dependencies").and_then(Item::as_table_like) + { + for (_, item) in optional.iter() { + if let Some(arr) = item.as_array() { + declared.extend( + arr.iter() + .filter_map(Value::as_str) + .map(|s| pep508_name(s).to_string()), + ); + } + } + } + } + for groups in [ + doc.get("tool") + .and_then(|t| item_get(t, "pdm")) + .and_then(|p| item_get(p, "dev-dependencies")), + doc.get("dependency-groups"), + ] + .into_iter() + .flatten() + { + if let Some(table) = groups.as_table_like() { + for (_, item) in table.iter() { + if let Some(arr) = item.as_array() { + declared.extend( + arr.iter() + .filter_map(Value::as_str) + .map(|s| pep508_name(s).to_string()), + ); + } + } + } + } + if declared + .iter() + .any(|n| canonicalize_pypi_name(n) == canon_name) + { + "direct" + } else { + "transitive" + } +} + +/// Target-specific guards (also re-run by [`wire_pdm`] right before +/// writing). The orchestrator runs them pre-flight so a refusal happens +/// before the wheel artifact is built. Lock names match by PEP 503 canonical +/// form (pdm records canonical names, mirroring poetry's P8 finding). +pub(super) fn check_target_guards( + p: &PdmProject, + canon_name: &str, + version: &str, + record_uuid: &str, +) -> Result { + let units: Vec<&toml_edit::Table> = p + .lock + .get("package") + .and_then(Item::as_array_of_tables) + .map(|pkgs| { + pkgs.iter() + .filter(|t| { + t.get("name") + .and_then(Item::as_str) + .map(canonicalize_pypi_name) + .as_deref() + == Some(canon_name) + }) + .collect() + }) + .unwrap_or_default(); + if units.is_empty() { + return Err(( + "pypi_pdm_lock_package_missing", + format!("{LOCK_FILE} has no [[package]] entry for {canon_name}; run `pdm lock` first"), + )); + } + // Cross-platform/marker forks list the same name at multiple versions; + // one surgical rewrite would mispin the other forks — refuse (mirrors uv). + if units.len() > 1 { + return Err(( + "pypi_pdm_lock_forked_package", + format!( + "{LOCK_FILE} resolves {canon_name} at multiple versions/markers (a forked \ + resolution); vendoring would mispin the other forks" + ), + )); + } + let unit = units[0]; + + if let Some(path) = unit.get("path").and_then(Item::as_str) { + return match parse_vendor_path(path) { + // Ours, same patch generation: the in-sync hot path. + Some(parts) if parts.eco == "pypi" && parts.uuid == record_uuid => Ok(PdmTarget::InSync), + // Ours, but a STALE patch generation: wiring over it would lose + // the only recorded registry original — refuse with the repair + // path (mirrors gem's stale-checksum refusal). + Some(parts) if parts.eco == "pypi" => Err(( + "pypi_pdm_source_already_exists", + format!( + "{LOCK_FILE} already routes {canon_name} through \ + .socket/vendor/pypi/{} (an earlier socket-patch vendor); run \ + `socket-patch vendor --revert` for it and re-vendor", + parts.uuid + ), + )), + // A user-authored local path dependency. + _ => Err(( + "pypi_pdm_source_already_exists", + format!( + "{LOCK_FILE} already declares a local path for {canon_name}; refusing to \ + overwrite a user-authored source" + ), + )), + }; + } + // Direct URL / VCS units carry unit-level url/git keys — also user-owned. + if unit.get("url").is_some() || unit.get("git").is_some() { + return Err(( + "pypi_pdm_source_already_exists", + format!( + "{LOCK_FILE} resolves {canon_name} from a user-declared url/vcs source; \ + refusing to overwrite it" + ), + )); + } + + // Splicing a hashed entry into a hash-less lock is untested (spike D6: + // `--no-hashes` no longer exists in pdm 2.27, so this only arises from + // older tools) — refuse rather than mix verification regimes. + let hashed_entries = unit + .get("files") + .and_then(Item::as_array) + .map(|arr| { + !arr.is_empty() + && arr.iter().all(|v| { + v.as_inline_table().is_some_and(|t| t.contains_key("hash")) + }) + }) + .unwrap_or(false); + if !hashed_entries { + return Err(( + "pypi_pdm_lock_no_hashes", + format!( + "the {canon_name} entry in {LOCK_FILE} has no sha256-hashed files entries (a \ + hash-less lock); re-lock with a current pdm so hashes are recorded" + ), + )); + } + + // The splice keeps the unit's version line verbatim, so the lock must + // already resolve the version being patched (lock/venv drift otherwise). + let locked_version = unit.get("version").and_then(Item::as_str).unwrap_or(""); + if locked_version != version { + return Err(( + "pypi_pdm_lock_package_missing", + format!( + "{LOCK_FILE} resolves {canon_name} at {locked_version:?}, not the patched \ + {version}; re-lock so the lock matches the installed version" + ), + )); + } + Ok(PdmTarget::Fresh) +} + +/// Wire pdm.lock for the vendored wheel: rewrite ONLY the target +/// `[[package]]` unit (the new text is fully computed before any write, then +/// committed atomically). `rel_wheel` is the project-relative wheel path +/// (`.socket/vendor/pypi//`, no `./` prefix — the `./` idiom of +/// pdm's own `path` serialization is applied here, fixture-pinned). +#[allow(clippy::too_many_arguments)] +pub async fn wire_pdm( + p: &PdmProject, + root: &Path, + canon_name: &str, + version: &str, + rel_wheel: &str, + wheel_file_name: &str, + wheel_sha256_hex: &str, + record_uuid: &str, +) -> Result<(Vec, PdmMeta), (&'static str, String)> { + match check_target_guards(p, canon_name, version, record_uuid)? { + // Defensive: the orchestrator short-circuits in-sync pre-flight and + // never calls wire on it (we must never re-record our own edit as an + // "original"). + PdmTarget::InSync => { + return Err(( + "pypi_pdm_source_already_exists", + format!( + "{LOCK_FILE} already wires {canon_name} to this patch's vendored wheel; \ + nothing to wire" + ), + )) + } + PdmTarget::Fresh => {} + } + + let (old_unit, new_unit) = rewrite_target_package_unit( + &p.lock_text, + canon_name, + rel_wheel, + wheel_file_name, + wheel_sha256_hex, + )?; + let new_lock = p.lock_text.replacen(&old_unit, &new_unit, 1); + atomic_write_bytes(&root.join(LOCK_FILE), new_lock.as_bytes()) + .await + .map_err(|e| { + ( + "pypi_pdm_write_failed", + format!("cannot write {LOCK_FILE}: {e}"), + ) + })?; + + let wiring = vec![record( + KIND_LOCK_PACKAGE, + WiringAction::Rewritten, + canon_name, + Some(old_unit), + new_unit, + )]; + let meta = PdmMeta { + dep_class: classify_dependency(p, canon_name).to_string(), + lock_version: p.lock_version.clone(), + strategy: p.strategy.clone(), + }; + Ok((wiring, meta)) +} + +/// Reverse the wiring: restore the verbatim original `[[package]]` unit. A +/// fragment that no longer matches what we wrote is left alone with a +/// `vendor_lock_entry_drifted` warning — revert never clobbers third-party +/// edits. +pub async fn revert_pdm(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { + let lock_path = root.join(LOCK_FILE); + let mut lock_text = match tokio::fs::read_to_string(&lock_path).await { + Ok(t) => t, + Err(e) => return RevertOutcome::failed(format!("cannot read {LOCK_FILE}: {e}")), + }; + let mut warnings: Vec = Vec::new(); + + for rec in entry.wiring.iter().rev() { + // SECURITY: `rec.file` comes verbatim from the committed, tamper-able + // state.json. This backend only ever wrote pdm.lock (the per-flavor + // file allowlist); any other recorded path is skipped fail-closed with + // a warning and is NEVER resolved against the filesystem. + if rec.file != LOCK_FILE { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "ignoring wiring record for unexpected file `{}` (only {LOCK_FILE} is \ + pdm-owned)", + rec.file + ), + )); + continue; + } + let drifted = || { + VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "{LOCK_FILE} fragment for {:?} changed since vendoring; left untouched", + rec.key + ), + ) + }; + match rec.kind.as_str() { + KIND_LOCK_PACKAGE => { + let new_text = rec.new.as_ref().and_then(serde_json::Value::as_str); + let original_text = rec.original.as_ref().and_then(serde_json::Value::as_str); + let (Some(new), Some(orig)) = (new_text, original_text) else { + warnings.push(drifted()); + continue; + }; + if lock_text.contains(new) { + lock_text = lock_text.replacen(new, orig, 1); + } else { + warnings.push(drifted()); + } + } + // Forward compatibility: a newer ledger's unknown kind degrades + // to a warning (never guess at a fragment shape). + other => warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("unknown pdm wiring kind {other:?}; skipped"), + )), + } + } + + if !dry_run { + if let Err(e) = atomic_write_bytes(&lock_path, lock_text.as_bytes()).await { + return RevertOutcome { + success: false, + warnings, + error: Some(format!("cannot write {LOCK_FILE}: {e}")), + }; + } + } + RevertOutcome { + success: true, + warnings, + error: None, + } +} + +// ── helpers ────────────────────────────────────────────────────────────── + +enum LockVersionSeries { + Supported, + NewerMinor, + Unsupported, +} + +/// `4.5.x` is the fixture series; a newer `4.` warns; everything else +/// (older minors, other majors, unparseable) refuses. +fn lock_version_series(v: &str) -> LockVersionSeries { + let mut it = v.split('.'); + let major = it.next().and_then(|s| s.parse::().ok()); + let minor = it.next().and_then(|s| s.parse::().ok()); + match (major, minor) { + (Some(4), Some(5)) => LockVersionSeries::Supported, + (Some(4), Some(m)) if m > 5 => LockVersionSeries::NewerMinor, + _ => LockVersionSeries::Unsupported, + } +} + +fn record( + kind: &str, + action: WiringAction, + key: &str, + original: Option, + new: String, +) -> WiringRecord { + WiringRecord { + file: LOCK_FILE.to_string(), + kind: kind.to_string(), + action, + key: Some(key.to_string()), + original: original.map(serde_json::Value::String), + new: Some(serde_json::Value::String(new)), + } +} + +fn item_get<'a>(item: &'a Item, key: &str) -> Option<&'a Item> { + item.as_table_like().and_then(|t| t.get(key)) +} + +/// Leading PEP 508 distribution name of a dependency spec. +fn pep508_name(spec: &str) -> &str { + let s = spec.trim_start(); + let end = s + .char_indices() + .find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) + .map(|(i, _)| i) + .unwrap_or(s.len()); + &s[..end] +} + +fn unit_has_canon_name(lines: &[&str], canon: &str) -> bool { + lines + .iter() + .find_map(|l| l.strip_prefix("name = ")) + .map(|r| canonicalize_pypi_name(r.trim().trim_matches('"'))) + .as_deref() + == Some(canon) +} + +/// Rewrite the target `[[package]]` unit to the D1-captured local-file +/// shape: insert `path = "./"` right after `requires_python` +/// (falling back to `version`/`name` — pdm's own key order) and reduce +/// `files = [...]` to the single `{file = "", hash = "sha256:"}` +/// element. Every other line is preserved verbatim. Returns +/// `(old_unit, new_unit)` for the wiring record. +fn rewrite_target_package_unit( + lock_text: &str, + canon: &str, + rel_wheel: &str, + wheel_file_name: &str, + wheel_sha256_hex: &str, +) -> Result<(String, String), (&'static str, String)> { + let span = find_unit_span(lock_text, |lines| unit_has_canon_name(lines, canon)).ok_or_else( + || { + ( + "pypi_pdm_lock_package_missing", + format!("{LOCK_FILE} has no [[package]] entry for {canon}"), + ) + }, + )?; + // `find_unit_span` ends a unit at the NEXT `[[package]]` or EOF; truncate + // defensively at any foreign top-level header so the splice never + // swallows a trailing section (pdm's [metadata] leads the file today, but + // the truncation keeps the cut correct if a section ever trails). + let mut unit: Vec<&str> = lock_text[span].lines().collect(); + if let Some(stop) = unit + .iter() + .enumerate() + .skip(1) + .find_map(|(i, l)| (l.starts_with('[') && !l.starts_with("[package.")).then_some(i)) + { + unit.truncate(stop); + while unit.last().is_some_and(|l| l.trim().is_empty()) { + unit.pop(); + } + } + let old_unit = unit.join("\n"); + let files_lines = [ + "files = [".to_string(), + format!(" {{file = \"{wheel_file_name}\", hash = \"sha256:{wheel_sha256_hex}\"}},"), + "]".to_string(), + ]; + + let mut out: Vec = Vec::new(); + let mut files_done = false; + let mut i = 0; + while i < unit.len() { + let line = unit[i]; + if line.starts_with("files = [") { + out.extend(files_lines.iter().cloned()); + files_done = true; + if !line.trim_end().ends_with(']') { + // skip the original multi-line array body + closing bracket + while i + 1 < unit.len() && unit[i + 1].trim() != "]" { + i += 1; + } + i += 1; + } + } else { + out.push(line.to_string()); + } + i += 1; + } + if !files_done { + // The hash guard already requires hashed files entries; reaching here + // means the parsed and textual views disagree — fail closed. + return Err(( + "pypi_pdm_lock_parse_failed", + format!("the {canon} [[package]] entry has no files array to rewrite"), + )); + } + + let anchor = out + .iter() + .position(|l| l.starts_with("requires_python = ")) + .or_else(|| out.iter().position(|l| l.starts_with("version = "))) + .or_else(|| out.iter().position(|l| l.starts_with("name = "))) + .ok_or_else(|| { + ( + "pypi_pdm_lock_parse_failed", + format!("the {canon} [[package]] entry has no key to anchor the path after"), + ) + })?; + out.insert(anchor + 1, format!("path = \"./{rel_wheel}\"")); + Ok((old_unit, out.join("\n"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::patch::vendor::state::VendorArtifact; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const REL_WHEEL: &str = + ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"; + const WHEEL_NAME: &str = "six-1.16.0-py2.py3-none-any.whl"; + /// sha256 of the spike's patched wheel (spikes/pdm fixtures, D1). + const WHEEL_SHA: &str = "7015f5a42a0f83fd1b7d3ca0ba10d8777a207c19b6ffebb39e2e1c03af6a281b"; + + // ── fixture constants ────────────────────────────────────────────── + // Byte-exact copies of the spikes/pdm/ fixtures (pdm 2.27.0, lock_version + // 4.5.0; spike date 2026-06-10). The registry locks are tool-generated + // (`pdm lock`); the vendored expectations carry the D1 path-unit verbatim + // from the tool-generated `after/` locks with the BEFORE lock's + // content_hash — the lock-only splice leaves content_hash untouched + // (spike D2). If these drift from the committed fixtures, the spike dirs + // are the source of truth. + + /// spikes/pdm/direct-path-wheel/before/pdm.lock (verbatim — identical to + /// direct-registry/after/pdm.lock). + const LOCK_DIRECT_REGISTRY: &str = r#"# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata"] +lock_version = "4.5.0" +content_hash = "sha256:d49d286986c5de41ec9879b6d710389b0be11cd096d883c069123b489ac6e6ea" + +[[metadata.targets]] +requires_python = "==3.14.*" + +[[package]] +name = "six" +version = "1.16.0" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +summary = "Python 2 and 3 compatibility utilities" +groups = ["default"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +"#; + + /// Expected splice output: the six [[package]] unit verbatim from + /// spikes/pdm/direct-path-wheel/after/pdm.lock (the D1 shape), with the + /// before lock's [metadata]/content_hash (untouched by the splice, D2). + const LOCK_DIRECT_VENDORED: &str = r#"# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata"] +lock_version = "4.5.0" +content_hash = "sha256:d49d286986c5de41ec9879b6d710389b0be11cd096d883c069123b489ac6e6ea" + +[[metadata.targets]] +requires_python = "==3.14.*" + +[[package]] +name = "six" +version = "1.16.0" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +path = "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" +summary = "Python 2 and 3 compatibility utilities" +groups = ["default"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:7015f5a42a0f83fd1b7d3ca0ba10d8777a207c19b6ffebb39e2e1c03af6a281b"}, +] +"#; + + /// The transitive "before": [metadata] + python-dateutil unit verbatim + /// from spikes/pdm/transitive-path/before/pdm.lock, with the six unit + /// verbatim from direct-registry — the registry resolution pdm produced + /// when 1.16.0 was current (the production case: the lock resolves the + /// version being patched; today's resolver picks 1.17.0, spike D3). + const LOCK_TRANSITIVE_REGISTRY: &str = r#"# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata"] +lock_version = "4.5.0" +content_hash = "sha256:b35b8b182ba39eb4b0e832cc853dd574342a4a4cb9ed441209d23928a52ae106" + +[[metadata.targets]] +requires_python = "==3.14.*" + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +summary = "Extensions to the standard Python datetime module" +groups = ["default"] +dependencies = [ + "six>=1.5", +] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[[package]] +name = "six" +version = "1.16.0" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +summary = "Python 2 and 3 compatibility utilities" +groups = ["default"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +"#; + + /// Expected transitive splice output: the six unit verbatim from + /// spikes/pdm/transitive-path/after/pdm.lock (identical D1 shape), with + /// the before lock's content_hash. + const LOCK_TRANSITIVE_VENDORED: &str = r#"# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata"] +lock_version = "4.5.0" +content_hash = "sha256:b35b8b182ba39eb4b0e832cc853dd574342a4a4cb9ed441209d23928a52ae106" + +[[metadata.targets]] +requires_python = "==3.14.*" + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +summary = "Extensions to the standard Python datetime module" +groups = ["default"] +dependencies = [ + "six>=1.5", +] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[[package]] +name = "six" +version = "1.16.0" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +path = "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" +summary = "Python 2 and 3 compatibility utilities" +groups = ["default"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:7015f5a42a0f83fd1b7d3ca0ba10d8777a207c19b6ffebb39e2e1c03af6a281b"}, +] +"#; + + /// The D6-captured static_urls shape: strategy gains "static_urls" and + /// files entries become `{url = ..., hash = ...}` (content_hash is + /// IDENTICAL to the default-strategy lock — D6). Assembled from the D6 + /// findings text; the splice into it was verified green by the spike. + const LOCK_STATIC_URLS_REGISTRY: &str = r#"# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata", "static_urls"] +lock_version = "4.5.0" +content_hash = "sha256:d49d286986c5de41ec9879b6d710389b0be11cd096d883c069123b489ac6e6ea" + +[[metadata.targets]] +requires_python = "==3.14.*" + +[[package]] +name = "six" +version = "1.16.0" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +summary = "Python 2 and 3 compatibility utilities" +groups = ["default"] +files = [ + {url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +"#; + + const PYPROJECT_DIRECT: &str = r#"[project] +name = "direct-registry" +version = "0.1.0" +dependencies = ["six==1.16.0"] +requires-python = "==3.14.*" + +[tool.pdm] +distribution = false +"#; + + const PYPROJECT_TRANSITIVE: &str = r#"[project] +name = "transitive-registry" +version = "0.1.0" +dependencies = ["python-dateutil==2.9.0.post0"] +requires-python = "==3.14.*" + +[tool.pdm] +distribution = false +"#; + + async fn write_project(lock: &str, pyproject: &str) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("pdm.lock"), lock).await.unwrap(); + tokio::fs::write(tmp.path().join("pyproject.toml"), pyproject) + .await + .unwrap(); + tmp + } + + async fn read_lock(root: &Path) -> String { + tokio::fs::read_to_string(root.join("pdm.lock")).await.unwrap() + } + + fn entry_for(wiring: Vec, meta: PdmMeta) -> VendorEntry { + VendorEntry { + ecosystem: "pypi".into(), + base_purl: "pkg:pypi/six@1.16.0".into(), + uuid: UUID.into(), + artifact: VendorArtifact { + path: REL_WHEEL.into(), + sha256: WHEEL_SHA.into(), + size: Some(11053), + platform_locked: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + flavor: Some("pdm".into()), + uv: None, + pnpm: None, + poetry: None, + pdm: Some(meta), + pipenv: None, + } + } + + async fn wire_default(p: &PdmProject, root: &Path) -> (Vec, PdmMeta) { + wire_pdm(p, root, "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UUID) + .await + .unwrap() + } + + /// The load-bearing oracle: wiring the registry lock must produce the + /// D1-captured local-file unit BYTE-IDENTICALLY (direct and transitive), + /// leaving pyproject and content_hash untouched. + #[tokio::test] + async fn wiring_matches_fixtures_byte_identically() { + let cases = [ + (LOCK_DIRECT_REGISTRY, LOCK_DIRECT_VENDORED, PYPROJECT_DIRECT, "direct"), + ( + LOCK_TRANSITIVE_REGISTRY, + LOCK_TRANSITIVE_VENDORED, + PYPROJECT_TRANSITIVE, + "transitive", + ), + ]; + for (before, after, pyproject, dep_class) in cases { + let tmp = write_project(before, pyproject).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + assert_eq!(p.lock_version, "4.5.0"); + assert_eq!(p.strategy, vec!["inherit_metadata".to_string()]); + assert_eq!(classify_dependency(&p, "six"), dep_class); + assert_eq!( + check_target_guards(&p, "six", "1.16.0", UUID).unwrap(), + PdmTarget::Fresh + ); + + let (wiring, meta) = wire_default(&p, tmp.path()).await; + assert_eq!( + read_lock(tmp.path()).await, + after, + "{dep_class}: pdm.lock must byte-match the D1 splice" + ); + // pyproject + content_hash are NEVER touched (lock-only splice). + assert_eq!( + tokio::fs::read_to_string(tmp.path().join("pyproject.toml")).await.unwrap(), + pyproject + ); + + assert_eq!(wiring.len(), 1); + assert_eq!(wiring[0].kind, KIND_LOCK_PACKAGE); + assert_eq!(wiring[0].action, WiringAction::Rewritten); + assert_eq!(wiring[0].file, "pdm.lock"); + assert_eq!(wiring[0].key.as_deref(), Some("six")); + assert_eq!(meta.dep_class, dep_class); + assert_eq!(meta.lock_version, "4.5.0"); + assert_eq!(meta.strategy, vec!["inherit_metadata".to_string()]); + } + } + + /// D6: a `{file = ..., hash = ...}` entry is accepted inside a + /// static_urls lock — the same D1 splice applies and the strategy is + /// recorded into the meta. + #[tokio::test] + async fn static_urls_strategy_lock_splices_with_the_same_shape() { + let tmp = write_project(LOCK_STATIC_URLS_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + assert_eq!( + p.strategy, + vec!["inherit_metadata".to_string(), "static_urls".to_string()] + ); + let (_, meta) = wire_default(&p, tmp.path()).await; + assert_eq!(meta.strategy, p.strategy); + + // Same expected text as the direct splice, modulo the strategy line. + let expected = LOCK_DIRECT_VENDORED.replace( + "strategy = [\"inherit_metadata\"]", + "strategy = [\"inherit_metadata\", \"static_urls\"]", + ); + assert_eq!(read_lock(tmp.path()).await, expected); + } + + /// D6 (partial leg): strategy sets outside the fixtures refuse — their + /// unit shapes were never captured. + #[tokio::test] + async fn unsupported_strategy_refuses() { + for flag in ["cross_platform", "direct_minimal_versions", "no_hashes"] { + let lock = LOCK_DIRECT_REGISTRY.replace( + "strategy = [\"inherit_metadata\"]", + &format!("strategy = [\"inherit_metadata\", \"{flag}\"]"), + ); + let tmp = write_project(&lock, PYPROJECT_DIRECT).await; + let err = load_pdm_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_pdm_lock_strategy_unsupported", "{flag}"); + assert!(err.1.contains(flag), "{}", err.1); + } + } + + /// D6 (partial leg): hash-less files entries refuse — splicing a hashed + /// entry into a hash-less lock is untested. + #[tokio::test] + async fn hashless_lock_refuses() { + // An entry without a hash key. + let lock = LOCK_DIRECT_REGISTRY.replace( + " {file = \"six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254\"},\n {file = \"six-1.16.0.tar.gz\", hash = \"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926\"},", + " {file = \"six-1.16.0-py2.py3-none-any.whl\"},", + ); + let tmp = write_project(&lock, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "six", "1.16.0", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_pdm_lock_no_hashes"); + + // No files array at all. + let lock = format!( + "{}\n[[package]]\nname = \"hashless\"\nversion = \"1.0.0\"\nsummary = \"x\"\ngroups = [\"default\"]\n", + LOCK_DIRECT_REGISTRY.trim_end() + ); + let tmp = write_project(&lock, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "hashless", "1.0.0", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_pdm_lock_no_hashes"); + } + + #[tokio::test] + async fn guards_refuse_parse_version_missing_forked_and_sources() { + // unreadable / unparseable lock + let tmp = tempfile::tempdir().unwrap(); + let err = load_pdm_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_pdm_lock_parse_failed"); + let tmp = write_project("[[package]\nbroken", PYPROJECT_DIRECT).await; + let err = load_pdm_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_pdm_lock_parse_failed"); + + // lock_version absent / outside the series + let tmp = write_project("[[package]]\nname = \"six\"\n", PYPROJECT_DIRECT).await; + let err = load_pdm_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_pdm_lock_version_unsupported"); + for bad in ["4.4.1", "3.0", "5.0.0", "garbage"] { + let lock = LOCK_DIRECT_REGISTRY + .replace("lock_version = \"4.5.0\"", &format!("lock_version = \"{bad}\"")); + let tmp = write_project(&lock, PYPROJECT_DIRECT).await; + let err = load_pdm_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_pdm_lock_version_unsupported", "{bad}"); + } + + // target absent from the lock + let tmp = write_project(LOCK_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "absent-pkg", "1.0.0", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_pdm_lock_package_missing"); + + // forked: the same name at two versions + let fork = format!( + "{LOCK_DIRECT_REGISTRY}\n[[package]]\nname = \"six\"\nversion = \"1.17.0\"\nsummary = \"x\"\ngroups = [\"default\"]\nfiles = [\n {{file = \"six-1.17.0-py2.py3-none-any.whl\", hash = \"sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274\"}},\n]\n" + ); + let tmp = write_project(&fork, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "six", "1.16.0", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_pdm_lock_forked_package"); + + // single unit at a DIFFERENT version than the patch target + let tmp = write_project(LOCK_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "six", "1.17.0", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_pdm_lock_package_missing"); + assert!(err.1.contains("1.16.0"), "{}", err.1); + + // user-authored local path dependency + let user = LOCK_DIRECT_VENDORED.replace( + "path = \"./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl\"", + "path = \"./vendor/six-1.16.0-py2.py3-none-any.whl\"", + ); + let tmp = write_project(&user, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "six", "1.16.0", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_pdm_source_already_exists"); + assert!(err.1.contains("user-authored"), "{}", err.1); + + // user-declared direct URL source + let url_unit = LOCK_DIRECT_REGISTRY.replace( + "requires_python = \">=2.7, !=3.0.*, !=3.1.*, !=3.2.*\"\nsummary", + "requires_python = \">=2.7, !=3.0.*, !=3.1.*, !=3.2.*\"\nurl = \"https://example.com/six-1.16.0-py2.py3-none-any.whl\"\nsummary", + ); + let tmp = write_project(&url_unit, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "six", "1.16.0", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_pdm_source_already_exists"); + + // wire re-runs the guards itself (refusal before any write) + let before = read_lock(tmp.path()).await; + let err = wire_pdm(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UUID) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_pdm_source_already_exists"); + assert_eq!(read_lock(tmp.path()).await, before, "refusal writes nothing"); + } + + #[tokio::test] + async fn newer_minor_lock_version_warns_not_refuses() { + let lock = + LOCK_DIRECT_REGISTRY.replace("lock_version = \"4.5.0\"", "lock_version = \"4.6.0\""); + let tmp = write_project(&lock, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + assert_eq!(p.warnings.len(), 1); + assert_eq!(p.warnings[0].code, "pypi_pdm_lock_version_untested"); + assert_eq!(p.lock_version, "4.6.0"); + // The wiring itself still works on the warned lock. + let (wiring, meta) = wire_default(&p, tmp.path()).await; + assert_eq!(wiring.len(), 1); + assert_eq!(meta.lock_version, "4.6.0"); + } + + /// Re-running vendor on an already-wired lock with the SAME uuid is the + /// in-sync hot path: the caller synthesizes AlreadyPatched and records + /// nothing; a DIFFERENT uuid refuses with `vendor --revert` guidance. + #[tokio::test] + async fn rerun_same_uuid_in_sync_and_stale_uuid_refuses_with_guidance() { + let tmp = write_project(LOCK_DIRECT_VENDORED, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + assert_eq!( + check_target_guards(&p, "six", "1.16.0", UUID).unwrap(), + PdmTarget::InSync + ); + + let stale_uuid = "00000000-0000-4000-8000-000000000000"; + let err = check_target_guards(&p, "six", "1.16.0", stale_uuid).unwrap_err(); + assert_eq!(err.0, "pypi_pdm_source_already_exists"); + assert!(err.1.contains("--revert"), "{}", err.1); + assert!(err.1.contains(UUID), "names the wired uuid: {}", err.1); + } + + #[tokio::test] + async fn classify_dependency_covers_every_declaration_surface() { + let p = |pyproject: Option<&str>| PdmProject { + lock_text: String::new(), + lock: DocumentMut::new(), + pyproject_text: pyproject.map(str::to_string), + lock_version: "4.5.0".into(), + strategy: Vec::new(), + warnings: Vec::new(), + }; + // PEP 621 dependency specs (with PEP 503 canonicalization). + assert_eq!(classify_dependency(&p(Some(PYPROJECT_DIRECT)), "six"), "direct"); + assert_eq!( + classify_dependency(&p(Some("[project]\ndependencies = [\"Six_Pkg>=1\"]\n")), "six-pkg"), + "direct" + ); + assert_eq!( + classify_dependency( + &p(Some("[project.optional-dependencies]\nextra = [\"six==1.16.0\"]\n")), + "six" + ), + "direct" + ); + // tool.pdm dev groups + PEP 735 dependency-groups. + assert_eq!( + classify_dependency( + &p(Some("[tool.pdm.dev-dependencies]\ntest = [\"six>=1\"]\n")), + "six" + ), + "direct" + ); + assert_eq!( + classify_dependency(&p(Some("[dependency-groups]\ndev = [\"six\"]\n")), "six"), + "direct" + ); + // Not declared / no pyproject → transitive (diagnostics-only). + assert_eq!(classify_dependency(&p(Some(PYPROJECT_TRANSITIVE)), "six"), "transitive"); + assert_eq!(classify_dependency(&p(None), "six"), "transitive"); + } + + /// Dry-run purity: load + classify + guards are pure reads, mirroring + /// pypi_uv's compute/write split (the orchestrator never calls wire on a + /// dry run). + #[tokio::test] + async fn load_classify_and_guards_write_nothing() { + let tmp = write_project(LOCK_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let _ = classify_dependency(&p, "six"); + let _ = check_target_guards(&p, "six", "1.16.0", UUID).unwrap(); + assert_eq!(read_lock(tmp.path()).await, LOCK_DIRECT_REGISTRY); + assert_eq!( + tokio::fs::read_to_string(tmp.path().join("pyproject.toml")).await.unwrap(), + PYPROJECT_DIRECT + ); + } + + #[tokio::test] + async fn revert_round_trip_restores_lock_byte_identically() { + for (before, pyproject) in [ + (LOCK_DIRECT_REGISTRY, PYPROJECT_DIRECT), + (LOCK_TRANSITIVE_REGISTRY, PYPROJECT_TRANSITIVE), + ] { + let tmp = write_project(before, pyproject).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_default(&p, tmp.path()).await; + let entry = entry_for(wiring, meta); + + let outcome = revert_pdm(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(read_lock(tmp.path()).await, before, "byte-identical revert"); + } + } + + #[tokio::test] + async fn revert_dry_run_changes_nothing() { + let tmp = write_project(LOCK_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_default(&p, tmp.path()).await; + let wired = read_lock(tmp.path()).await; + + let outcome = revert_pdm(&entry_for(wiring, meta), tmp.path(), true).await; + assert!(outcome.success); + assert_eq!(read_lock(tmp.path()).await, wired, "dry run must not write"); + } + + /// SECURITY: a poisoned state.json wiring record naming any file other + /// than pdm.lock is skipped fail-closed — the named path is never read + /// or written. + #[tokio::test] + async fn revert_allowlist_skips_unexpected_files_fail_closed() { + let outer = tempfile::tempdir().unwrap(); + let root = outer.path().join("project"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(root.join("pdm.lock"), LOCK_DIRECT_REGISTRY).await.unwrap(); + let precious = outer.path().join("precious.txt"); + tokio::fs::write(&precious, "keep me intact\n").await.unwrap(); + + for bad in ["pyproject.toml", "../precious.txt", "/etc/hosts"] { + let wiring = vec![WiringRecord { + file: bad.to_string(), + kind: KIND_LOCK_PACKAGE.to_string(), + action: WiringAction::Rewritten, + key: Some("six".into()), + original: Some(serde_json::json!("malicious payload")), + new: Some(serde_json::json!("keep me intact")), + }]; + let meta = PdmMeta { + dep_class: "direct".into(), + lock_version: "4.5.0".into(), + strategy: vec!["inherit_metadata".into()], + }; + let outcome = revert_pdm(&entry_for(wiring, meta), &root, false).await; + assert!(outcome.success, "skipped fail-closed, not a hard error: {bad}"); + assert!( + outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + "skip surfaced for {bad}: {:?}", + outcome.warnings + ); + } + assert_eq!( + tokio::fs::read_to_string(&precious).await.unwrap(), + "keep me intact\n", + "out-of-tree file byte-untouched" + ); + assert_eq!( + tokio::fs::read_to_string(root.join("pdm.lock")).await.unwrap(), + LOCK_DIRECT_REGISTRY, + "the lock itself is untouched too (no record matched it)" + ); + } + + /// A third-party edit to the unit we wrote (e.g. `pdm update six` + /// reverted it to registry shape — spike D5) is left alone with a drift + /// warning; unknown wiring kinds from a newer ledger degrade the same way. + #[tokio::test] + async fn revert_warns_and_skips_on_drifted_fragment_and_unknown_kind() { + let tmp = write_project(LOCK_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let (mut wiring, meta) = wire_default(&p, tmp.path()).await; + wiring.push(WiringRecord { + file: "pdm.lock".into(), + kind: "pdm_future_kind".into(), + action: WiringAction::Added, + key: Some("six".into()), + original: None, + new: Some(serde_json::json!("x")), + }); + + // Drift: someone re-hashed the vendored files entry. + let drifted = read_lock(tmp.path()).await.replace(WHEEL_SHA, &"0".repeat(64)); + tokio::fs::write(tmp.path().join("pdm.lock"), &drifted).await.unwrap(); + + let outcome = revert_pdm(&entry_for(wiring, meta), tmp.path(), false).await; + assert!(outcome.success); + assert_eq!( + outcome + .warnings + .iter() + .filter(|w| w.code == "vendor_lock_entry_drifted") + .count(), + 2, + "drifted fragment + unknown kind: {:?}", + outcome.warnings + ); + assert_eq!(read_lock(tmp.path()).await, drifted, "drifted lock left alone"); + } + + #[test] + fn lock_version_series_classifier() { + assert!(matches!(lock_version_series("4.5.0"), LockVersionSeries::Supported)); + assert!(matches!(lock_version_series("4.5.1"), LockVersionSeries::Supported)); + assert!(matches!(lock_version_series("4.6.0"), LockVersionSeries::NewerMinor)); + assert!(matches!(lock_version_series("4.10.2"), LockVersionSeries::NewerMinor)); + assert!(matches!(lock_version_series("4.4.1"), LockVersionSeries::Unsupported)); + assert!(matches!(lock_version_series("5.0.0"), LockVersionSeries::Unsupported)); + assert!(matches!(lock_version_series("garbage"), LockVersionSeries::Unsupported)); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs b/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs index 6c90a08..8681033 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs @@ -1 +1,997 @@ -//! (stub — backend lands behind the npm_flavor / pypi router) +//! pipenv wiring: a lock-ONLY `default`/`develop` entry rewrite of +//! `Pipfile.lock` (pipfile-spec 6). +//! +//! `pipenv verify` / `install --deploy` compare only `_meta.hash` (derived +//! from the Pipfile), so replacing a section entry with the V1/V2-captured +//! file-ref shape — `{"file": "./", "hashes": +//! ["sha256:"], "markers": }`, `index`/`version` dropped, +//! `_meta` untouched — survives `pipenv sync`, `install --deploy`, `verify` +//! and bare `pipenv install` byte-stably from a fresh checkout (spike +//! V2/V3). The serializer is pinned to pipenv's own +//! `json.dumps(obj, indent=4, sort_keys=True) + "\n"` (spike V7) so the lock +//! never churns. See `spikes/pipenv/` and the pipenv section of +//! `spikes/PHASE0-V2-FINDINGS.txt`. +//! +//! INTEGRITY caveat (spike V4, REFUTED claim): pipenv installs file-ref +//! entries through a separate pip phase with no `--hash`/`--require-hashes`, +//! so the recorded hash is NEVER enforced by pipenv itself — every vendor +//! run pushes a `vendor_integrity_unverified` warning and the committed +//! wheel bytes are the only tamper evidence (the hash we write becomes +//! enforced for free if pipenv ever fixes that phase). +//! +//! Drift caveat (spike V6): `pipenv lock` regenerates the entry to registry +//! shape and `pipenv update ` additionally rewrites the user's Pipfile +//! pin to `*` — both silent unpatch events; bare `pipenv install` is safe. + +use std::path::Path; + +use serde::Serialize; +use serde_json::{Map, Value}; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::fs::atomic_write_bytes; + +use super::path::parse_vendor_path; +use super::state::{PipenvMeta, VendorEntry, WiringAction, WiringRecord}; +use super::{RevertOutcome, VendorWarning}; + +/// The only file this backend ever writes (and the revert allowlist). +const LOCK_FILE: &str = "Pipfile.lock"; + +/// The `WiringRecord.kind` discriminator this backend owns. +const KIND_LOCK_ENTRY: &str = "pipenv_lock_entry"; + +/// The Pipfile.lock sections searched/wired, in application order. +const SECTIONS: [&str; 2] = ["default", "develop"]; + +/// Pipfile.lock entry keys that mark a user-declared non-registry source. +const NON_REGISTRY_KEYS: [&str; 6] = ["path", "git", "hg", "svn", "bzr", "editable"]; + +/// A loaded-and-guard-checked pipenv project. +#[derive(Debug)] +pub struct PipenvProject { + /// Verbatim Pipfile.lock text (byte-stability oracle for reverts). + pub lock_text: String, + /// Parsed lock (the edit substrate — re-serialized canonically). + pub lock: Value, + /// Non-fatal advisories raised during load. ALWAYS contains the + /// `vendor_integrity_unverified` warning (spike V4: pipenv never enforces + /// hashes on file-ref entries) — the orchestrator must surface these. + pub warnings: Vec, +} + +/// What the target entries already look like. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PipenvTarget { + /// At least one registry-shaped entry: proceed to build the wheel and + /// wire. + Fresh, + /// Every matching entry is already wired to THIS patch uuid — the caller + /// synthesizes an AlreadyPatched success, builds nothing, and records + /// nothing (the first run's ledger entry holds the only copy of the + /// originals). + InSync, +} + +/// Read + parse Pipfile.lock and run every project-level guard. Refuses +/// before ANY write — the orchestrator runs this (and the target guards) +/// before the wheel is built, so a refusal leaves the tree byte-untouched. +pub async fn load_pipenv_project(root: &Path) -> Result { + let lock_text = match tokio::fs::read_to_string(root.join(LOCK_FILE)).await { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(( + "pypi_pipenv_no_lockfile", + format!("no {LOCK_FILE} at the project root; run `pipenv lock` and re-run vendor"), + )) + } + Err(e) => { + return Err(( + "pypi_pipenv_lock_parse_failed", + format!("cannot read {LOCK_FILE}: {e}"), + )) + } + }; + let lock: Value = serde_json::from_str(&lock_text).map_err(|e| { + ( + "pypi_pipenv_lock_parse_failed", + format!("{LOCK_FILE} is not parseable JSON: {e}"), + ) + })?; + if !lock.is_object() { + return Err(( + "pypi_pipenv_lock_parse_failed", + format!("{LOCK_FILE} root is not a JSON object"), + )); + } + let spec = lock + .get("_meta") + .and_then(|m| m.get("pipfile-spec")) + .and_then(Value::as_u64); + if spec != Some(6) { + return Err(( + "pypi_pipenv_spec_unsupported", + format!( + "{LOCK_FILE} _meta.pipfile-spec is {spec:?}; only spec 6 locks are \ + fixture-tested" + ), + )); + } + + // ALWAYS pushed (spike V4 refuted hash enforcement): the recorded hash is + // self-documentation, not a pipenv-enforced check. + let warnings = vec![VendorWarning::new( + "vendor_integrity_unverified", + "pipenv never enforces the hashes recorded on file-ref lock entries (its file-ref \ + install phase invokes pip without --hash/--require-hashes), so the vendored wheel is \ + protected only by the committed wheel itself; `socket-patch verify` re-checks its \ + sha256 against the lock entry", + )]; + Ok(PipenvProject { + lock_text, + lock, + warnings, + }) +} + +/// Target-specific guards (also re-run by [`wire_pipenv`] right before +/// writing). Entries match by PEP 503 canonical NAME in `default` and +/// `develop`; there is no version guard — the file-ref entry carries no +/// version key and the spike proved pipenv accepts a version pin-down +/// (V3's 1.17.0 → 1.16.0 splice installed cleanly). +pub(super) fn check_target_guards( + p: &PipenvProject, + canon_name: &str, + record_uuid: &str, +) -> Result { + let entries = find_entries(&p.lock, canon_name); + if entries.is_empty() { + return Err(( + "pypi_pipenv_lock_package_missing", + format!( + "{LOCK_FILE} names {canon_name} in neither default nor develop; run \ + `pipenv lock` first" + ), + )); + } + let mut all_in_sync = true; + for (section, key, entry) in &entries { + let Some(obj) = entry.as_object() else { + return Err(( + "pypi_pipenv_lock_parse_failed", + format!("{LOCK_FILE} {section}.{key} is not a JSON object"), + )); + }; + if let Some(file_ref) = obj.get("file").and_then(Value::as_str) { + match parse_vendor_path(file_ref) { + // Ours, same patch generation. + Some(parts) if parts.eco == "pypi" && parts.uuid == record_uuid => continue, + // Ours, but a STALE patch generation: wiring over it would + // lose the only recorded registry original — refuse with the + // repair path (mirrors gem's stale-checksum refusal). + Some(parts) if parts.eco == "pypi" => { + return Err(( + "pypi_pipenv_source_already_exists", + format!( + "{LOCK_FILE} already routes {section}.{key} through \ + .socket/vendor/pypi/{} (an earlier socket-patch vendor); run \ + `socket-patch vendor --revert` for it and re-vendor", + parts.uuid + ), + )) + } + // A user-authored local file reference. + _ => { + return Err(( + "pypi_pipenv_source_already_exists", + format!( + "{LOCK_FILE} {section}.{key} is a user-declared file reference; \ + refusing to overwrite it" + ), + )) + } + } + } + if let Some(non_registry) = NON_REGISTRY_KEYS.iter().find(|k| obj.contains_key(**k)) { + return Err(( + "pypi_pipenv_source_already_exists", + format!( + "{LOCK_FILE} {section}.{key} is a user-declared non-registry reference \ + ({non_registry}); refusing to overwrite it" + ), + )); + } + all_in_sync = false; + } + Ok(if all_in_sync { + PipenvTarget::InSync + } else { + PipenvTarget::Fresh + }) +} + +/// Wire Pipfile.lock for the vendored wheel: replace every matching +/// `default`/`develop` entry with the V1/V2-captured file-ref shape (the new +/// document is fully computed, then committed atomically with the pinned +/// pipenv serialization). `rel_wheel` is the project-relative wheel path +/// (`.socket/vendor/pypi//`, no `./` prefix — the fixture's +/// `./` spelling is applied here). +pub async fn wire_pipenv( + p: &PipenvProject, + root: &Path, + canon_name: &str, + rel_wheel: &str, + wheel_sha256_hex: &str, + record_uuid: &str, +) -> Result<(Vec, PipenvMeta), (&'static str, String)> { + match check_target_guards(p, canon_name, record_uuid)? { + // Defensive: the orchestrator short-circuits in-sync pre-flight and + // never calls wire on it (we must never re-record our own edit as an + // "original"). + PipenvTarget::InSync => { + return Err(( + "pypi_pipenv_source_already_exists", + format!( + "{LOCK_FILE} already wires {canon_name} to this patch's vendored wheel; \ + nothing to wire" + ), + )) + } + PipenvTarget::Fresh => {} + } + + let mut lock = p.lock.clone(); + let mut wiring: Vec = Vec::new(); + let mut sections: Vec = Vec::new(); + for section in SECTIONS { + let Some(map) = lock.get_mut(section).and_then(Value::as_object_mut) else { + continue; + }; + let keys: Vec = map + .keys() + .filter(|k| canonicalize_pypi_name(k) == canon_name) + .cloned() + .collect(); + for key in keys { + let old = map.get(&key).cloned().unwrap_or(Value::Null); + // The V1/V2 entry shape: file + OUR hash; markers preserved + // verbatim; index/version dropped (transitive entries never had + // an index key — V3). + let mut new_entry = Map::new(); + new_entry.insert("file".to_string(), Value::String(format!("./{rel_wheel}"))); + new_entry.insert( + "hashes".to_string(), + Value::Array(vec![Value::String(format!("sha256:{wheel_sha256_hex}"))]), + ); + if let Some(markers) = old.get("markers") { + new_entry.insert("markers".to_string(), markers.clone()); + } + let new_value = Value::Object(new_entry); + if old == new_value { + // Per-entry idempotency: an entry already carrying our exact + // shape needs no edit and no wiring record. + continue; + } + // Never record one of our own edits as the "original" — revert + // must restore the pre-vendor registry fragment (a vendor-pointing + // old entry can only reach here through a same-uuid hash refresh; + // stale uuids refuse in the guards). + let was_vendored = old + .get("file") + .and_then(Value::as_str) + .and_then(parse_vendor_path) + .is_some(); + map.insert(key.clone(), new_value.clone()); + wiring.push(WiringRecord { + file: LOCK_FILE.to_string(), + kind: KIND_LOCK_ENTRY.to_string(), + action: WiringAction::Rewritten, + key: Some(format!("{section}:{key}")), + original: if was_vendored { None } else { Some(old) }, + new: Some(new_value), + }); + if !sections.iter().any(|s| s == section) { + sections.push(section.to_string()); + } + } + } + + let new_text = to_canonical_json(&lock); + atomic_write_bytes(&root.join(LOCK_FILE), new_text.as_bytes()) + .await + .map_err(|e| { + ( + "pypi_pipenv_write_failed", + format!("cannot write {LOCK_FILE}: {e}"), + ) + })?; + Ok((wiring, PipenvMeta { sections })) +} + +/// Reverse the wiring: restore the verbatim original entries (deep-equality +/// gated). An entry that no longer matches what we wrote is left alone with +/// a `vendor_lock_entry_drifted` warning — revert never clobbers third-party +/// edits. +pub async fn revert_pipenv(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { + let lock_path = root.join(LOCK_FILE); + let lock_text = match tokio::fs::read_to_string(&lock_path).await { + Ok(t) => t, + Err(e) => return RevertOutcome::failed(format!("cannot read {LOCK_FILE}: {e}")), + }; + // Fail-closed: editing a lock we cannot parse risks destroying it. + let mut lock: Value = match serde_json::from_str(&lock_text) { + Ok(v) => v, + Err(e) => { + return RevertOutcome::failed(format!( + "{LOCK_FILE} is not parseable JSON ({e}); fix it and re-run revert" + )) + } + }; + let mut warnings: Vec = Vec::new(); + let mut changed = false; + + for rec in entry.wiring.iter().rev() { + // SECURITY: `rec.file` comes verbatim from the committed, tamper-able + // state.json. This backend only ever wrote Pipfile.lock (the + // per-flavor file allowlist); any other recorded path is skipped + // fail-closed with a warning and is NEVER resolved against the + // filesystem. + if rec.file != LOCK_FILE { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "ignoring wiring record for unexpected file `{}` (only {LOCK_FILE} is \ + pipenv-owned)", + rec.file + ), + )); + continue; + } + let drifted = || { + VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "{LOCK_FILE} entry for {:?} changed since vendoring; left untouched", + rec.key + ), + ) + }; + if rec.kind != KIND_LOCK_ENTRY { + // Forward compatibility: a newer ledger's unknown kind degrades + // to a warning (never guess at a fragment shape). + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("unknown pipenv wiring kind {:?}; skipped", rec.kind), + )); + continue; + } + // SECURITY: the section component is also untrusted — only the two + // known section names are ever dereferenced. + let Some((section, name)) = rec.key.as_deref().and_then(|k| k.split_once(':')) else { + warnings.push(drifted()); + continue; + }; + if !SECTIONS.contains(§ion) { + warnings.push(drifted()); + continue; + } + let Some(map) = lock.get_mut(section).and_then(Value::as_object_mut) else { + warnings.push(drifted()); + continue; + }; + let (Some(new_value), Some(live)) = (rec.new.as_ref(), map.get(name)) else { + warnings.push(drifted()); + continue; + }; + if live != new_value { + warnings.push(drifted()); + continue; + } + match (rec.action, rec.original.as_ref()) { + (WiringAction::Rewritten, Some(orig)) => { + map.insert(name.to_string(), orig.clone()); + changed = true; + } + // original=None means the pre-vendor entry was already + // vendor-pointing (never recorded as an original) — there is no + // registry fragment to restore. + (WiringAction::Rewritten, None) => warnings.push(drifted()), + (WiringAction::Added, _) => { + map.remove(name); + changed = true; + } + } + } + + // Only re-serialize when something was restored: a no-op revert must not + // churn a lock whose formatting we did not produce. + if changed && !dry_run { + let new_text = to_canonical_json(&lock); + if let Err(e) = atomic_write_bytes(&lock_path, new_text.as_bytes()).await { + return RevertOutcome { + success: false, + warnings, + error: Some(format!("cannot write {LOCK_FILE}: {e}")), + }; + } + } + RevertOutcome { + success: true, + warnings, + error: None, + } +} + +// ── helpers ────────────────────────────────────────────────────────────── + +/// Every `(section, key, entry)` whose key canonicalizes to `canon_name`. +fn find_entries<'a>(lock: &'a Value, canon_name: &str) -> Vec<(&'static str, String, &'a Value)> { + let mut out = Vec::new(); + for section in SECTIONS { + let Some(map) = lock.get(section).and_then(Value::as_object) else { + continue; + }; + for (key, value) in map { + if canonicalize_pypi_name(key) == canon_name { + out.push((section, key.clone(), value)); + } + } + } + out +} + +/// pipenv's exact serialization (spike V7): 4-space indent, ALL keys sorted +/// at every nesting level, default separators, one trailing newline — +/// byte-identical to `json.dumps(obj, indent=4, sort_keys=True) + "\n"` for +/// the ASCII content pipenv locks carry. +fn to_canonical_json(value: &Value) -> String { + fn sorted(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + let mut out = Map::new(); + for k in keys { + out.insert(k.clone(), sorted(&map[k])); + } + Value::Object(out) + } + Value::Array(arr) => Value::Array(arr.iter().map(sorted).collect()), + other => other.clone(), + } + } + let mut buf = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); + sorted(value) + .serialize(&mut ser) + .expect("serializing a serde_json::Value cannot fail"); + let mut text = String::from_utf8(buf).expect("serde_json emits UTF-8"); + text.push('\n'); + text +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::patch::vendor::state::VendorArtifact; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const REL_WHEEL: &str = + ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"; + /// sha256 of the spike's patched wheel (spikes/pipenv/artifacts/SHA256SUMS). + const WHEEL_SHA: &str = "573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0"; + + // ── fixture constants ────────────────────────────────────────────── + // Byte-exact copies of the spikes/pipenv/ fixtures (pipenv 2026.6.2, + // pipfile-spec 6; spike date 2026-06-10). The registry locks are + // tool-generated (`pipenv lock`); the vendored locks are the + // `.lock-only-edit` splices that pass sync / --deploy / verify + // byte-stably (V2/V3). If these drift from the committed fixtures, the + // spike dirs are the source of truth. + + /// spikes/pipenv/direct-registry/Pipfile.lock (verbatim). + const LOCK_DIRECT_REGISTRY: &str = r#"{ + "_meta": { + "hash": { + "sha256": "55f44fe4c8bc29094f3076c7eddb912ca00f80c016020ffa2bcbd67ccc7114a1" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.14" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==1.16.0" + } + }, + "develop": {} +} +"#; + + /// spikes/pipenv/direct-file/Pipfile.lock.lock-only-edit (verbatim — + /// the V2 splice: file + patched hash, index/version dropped, markers + /// kept, _meta untouched). + const LOCK_DIRECT_VENDORED: &str = r#"{ + "_meta": { + "hash": { + "sha256": "55f44fe4c8bc29094f3076c7eddb912ca00f80c016020ffa2bcbd67ccc7114a1" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.14" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "six": { + "file": "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", + "hashes": [ + "sha256:573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'" + } + }, + "develop": {} +} +"#; + + /// spikes/pipenv/transitive-registry/Pipfile.lock (verbatim — six is + /// FLAT in default at the resolver's 1.17.0, no index key). + const LOCK_TRANSITIVE_REGISTRY: &str = r#"{ + "_meta": { + "hash": { + "sha256": "58546015c76e8085bff3be981f626feed276df866834bb057ab1c118de09ff77" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.14" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==2.8.2" + }, + "six": { + "hashes": [ + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", + "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==1.17.0" + } + }, + "develop": {} +} +"#; + + /// spikes/pipenv/transitive-file/Pipfile.lock.lock-only-edit (verbatim — + /// the V3 splice; note the silent 1.17.0 → 1.16.0 pin-down, which pipenv + /// accepts: install is per-entry with no cross-check). + const LOCK_TRANSITIVE_VENDORED: &str = r#"{ + "_meta": { + "hash": { + "sha256": "58546015c76e8085bff3be981f626feed276df866834bb057ab1c118de09ff77" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.14" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==2.8.2" + }, + "six": { + "file": "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", + "hashes": [ + "sha256:573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'" + } + }, + "develop": {} +} +"#; + + async fn write_lock(lock: &str) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("Pipfile.lock"), lock).await.unwrap(); + tmp + } + + async fn read_lock(root: &Path) -> String { + tokio::fs::read_to_string(root.join("Pipfile.lock")).await.unwrap() + } + + fn entry_for(wiring: Vec, meta: PipenvMeta) -> VendorEntry { + VendorEntry { + ecosystem: "pypi".into(), + base_purl: "pkg:pypi/six@1.16.0".into(), + uuid: UUID.into(), + artifact: VendorArtifact { + path: REL_WHEEL.into(), + sha256: WHEEL_SHA.into(), + size: Some(11053), + platform_locked: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + flavor: Some("pipenv".into()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: Some(meta), + } + } + + async fn wire_default(p: &PipenvProject, root: &Path) -> (Vec, PipenvMeta) { + wire_pipenv(p, root, "six", REL_WHEEL, WHEEL_SHA, UUID).await.unwrap() + } + + /// The load-bearing oracle: wiring the registry lock must produce the + /// `.lock-only-edit` fixture BYTE-IDENTICALLY (direct V2 + transitive V3, + /// which includes the version pin-down replacement), `_meta` untouched. + #[tokio::test] + async fn wiring_matches_fixtures_byte_identically() { + let cases = [ + (LOCK_DIRECT_REGISTRY, LOCK_DIRECT_VENDORED, "direct"), + (LOCK_TRANSITIVE_REGISTRY, LOCK_TRANSITIVE_VENDORED, "transitive"), + ]; + for (before, after, label) in cases { + let tmp = write_lock(before).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + assert_eq!( + check_target_guards(&p, "six", UUID).unwrap(), + PipenvTarget::Fresh + ); + + let (wiring, meta) = wire_default(&p, tmp.path()).await; + assert_eq!( + read_lock(tmp.path()).await, + after, + "{label}: Pipfile.lock must byte-match the lock-only-edit fixture" + ); + + assert_eq!(wiring.len(), 1); + assert_eq!(wiring[0].file, "Pipfile.lock"); + assert_eq!(wiring[0].kind, KIND_LOCK_ENTRY); + assert_eq!(wiring[0].action, WiringAction::Rewritten); + assert_eq!(wiring[0].key.as_deref(), Some("default:six")); + // The verbatim registry entry is recorded for revert. + assert!( + wiring[0].original.as_ref().unwrap().get("hashes").is_some(), + "original carries the registry entry: {:?}", + wiring[0].original + ); + assert_eq!(meta.sections, vec!["default".to_string()]); + } + } + + /// A package present in BOTH sections is wired in both, with one record + /// per entry and both sections in the meta. + #[tokio::test] + async fn both_sections_wired_with_per_entry_records() { + // Derive the before/after pair from the fixture parts: develop gets + // the same registry entry (before) / vendored entry (after) as + // default, re-rendered with the pinned pipenv serializer. + let mut before: Value = serde_json::from_str(LOCK_DIRECT_REGISTRY).unwrap(); + let six_registry = before["default"]["six"].clone(); + before["develop"]["six"] = six_registry; + let before_text = to_canonical_json(&before); + + let mut after: Value = serde_json::from_str(LOCK_DIRECT_VENDORED).unwrap(); + let six_vendored = after["default"]["six"].clone(); + after["develop"]["six"] = six_vendored; + let after_text = to_canonical_json(&after); + + let tmp = write_lock(&before_text).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_default(&p, tmp.path()).await; + + assert_eq!(read_lock(tmp.path()).await, after_text); + assert_eq!(wiring.len(), 2); + let keys: Vec<&str> = wiring.iter().filter_map(|w| w.key.as_deref()).collect(); + assert_eq!(keys, vec!["default:six", "develop:six"]); + assert_eq!(meta.sections, vec!["default".to_string(), "develop".to_string()]); + + // Round trip: both entries restored byte-identically. + let outcome = revert_pipenv(&entry_for(wiring, meta), tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(read_lock(tmp.path()).await, before_text); + } + + /// Spike V4 (REFUTED): pipenv never enforces file-ref hashes, so EVERY + /// load carries the integrity warning for the orchestrator to surface. + #[tokio::test] + async fn integrity_unverified_warning_always_present() { + let tmp = write_lock(LOCK_DIRECT_REGISTRY).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + assert_eq!(p.warnings.len(), 1); + assert_eq!(p.warnings[0].code, "vendor_integrity_unverified"); + assert!( + p.warnings[0].detail.contains("protected only by the committed wheel itself"), + "{}", + p.warnings[0].detail + ); + // Present on the already-vendored (in-sync) lock too. + let tmp = write_lock(LOCK_DIRECT_VENDORED).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + assert_eq!(p.warnings[0].code, "vendor_integrity_unverified"); + } + + /// Spike V7: our serializer reproduces pipenv's own + /// `json.dumps(indent=4, sort_keys=True) + "\n"` byte-for-byte, so a + /// parse → serialize round trip of a pipenv-written lock is the identity. + #[test] + fn canonical_serializer_is_byte_stable_against_pipenv_output() { + for fixture in [ + LOCK_DIRECT_REGISTRY, + LOCK_DIRECT_VENDORED, + LOCK_TRANSITIVE_REGISTRY, + LOCK_TRANSITIVE_VENDORED, + ] { + let value: Value = serde_json::from_str(fixture).unwrap(); + assert_eq!(to_canonical_json(&value), fixture); + } + // And it actively sorts keys at every level (pipenv's sort_keys). + let scrambled: Value = serde_json::from_str(r#"{"b": {"z": 1, "a": 2}, "a": []}"#).unwrap(); + assert_eq!( + to_canonical_json(&scrambled), + "{\n \"a\": [],\n \"b\": {\n \"a\": 2,\n \"z\": 1\n }\n}\n" + ); + } + + #[tokio::test] + async fn guards_refuse_missing_lock_parse_spec_package_and_sources() { + // missing lockfile + let tmp = tempfile::tempdir().unwrap(); + let err = load_pipenv_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_pipenv_no_lockfile"); + assert!(err.1.contains("pipenv lock"), "{}", err.1); + + // unparseable / non-object lock + let tmp = write_lock("{not json").await; + let err = load_pipenv_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_pipenv_lock_parse_failed"); + let tmp = write_lock("[]").await; + let err = load_pipenv_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_pipenv_lock_parse_failed"); + + // pipfile-spec != 6 (and missing entirely) + let tmp = write_lock(&LOCK_DIRECT_REGISTRY.replace("\"pipfile-spec\": 6", "\"pipfile-spec\": 7")).await; + let err = load_pipenv_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_pipenv_spec_unsupported"); + let tmp = write_lock("{\"default\": {}}").await; + let err = load_pipenv_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_pipenv_spec_unsupported"); + + // package missing from both sections + let tmp = write_lock(LOCK_DIRECT_REGISTRY).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "absent-pkg", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_pipenv_lock_package_missing"); + + // user-declared file reference + let user = LOCK_DIRECT_VENDORED.replace( + "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", + "./local/six-1.16.0-py2.py3-none-any.whl", + ); + let tmp = write_lock(&user).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "six", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_pipenv_source_already_exists"); + assert!(err.1.contains("user-declared"), "{}", err.1); + + // user-declared vcs reference + let git = LOCK_DIRECT_REGISTRY.replace( + "\"index\": \"pypi\",", + "\"git\": \"https://github.com/benjaminp/six.git\",", + ); + let tmp = write_lock(&git).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "six", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_pipenv_source_already_exists"); + assert!(err.1.contains("git"), "{}", err.1); + + // wire re-runs the guards itself (refusal before any write) + let before = read_lock(tmp.path()).await; + let err = wire_pipenv(&p, tmp.path(), "six", REL_WHEEL, WHEEL_SHA, UUID) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_pipenv_source_already_exists"); + assert_eq!(read_lock(tmp.path()).await, before, "refusal writes nothing"); + } + + /// Re-running vendor on an already-wired lock with the SAME uuid is the + /// in-sync hot path: the caller synthesizes AlreadyPatched and records + /// nothing; a DIFFERENT uuid refuses with `vendor --revert` guidance. + #[tokio::test] + async fn rerun_same_uuid_in_sync_and_stale_uuid_refuses_with_guidance() { + let tmp = write_lock(LOCK_DIRECT_VENDORED).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + assert_eq!( + check_target_guards(&p, "six", UUID).unwrap(), + PipenvTarget::InSync + ); + + let stale_uuid = "00000000-0000-4000-8000-000000000000"; + let err = check_target_guards(&p, "six", stale_uuid).unwrap_err(); + assert_eq!(err.0, "pypi_pipenv_source_already_exists"); + assert!(err.1.contains("--revert"), "{}", err.1); + assert!(err.1.contains(UUID), "names the wired uuid: {}", err.1); + } + + /// Dry-run purity: load + guards are pure reads, mirroring pypi_uv's + /// compute/write split (the orchestrator never calls wire on a dry run). + #[tokio::test] + async fn load_and_guards_write_nothing() { + let tmp = write_lock(LOCK_DIRECT_REGISTRY).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + let _ = check_target_guards(&p, "six", UUID).unwrap(); + assert_eq!(read_lock(tmp.path()).await, LOCK_DIRECT_REGISTRY); + } + + #[tokio::test] + async fn revert_round_trip_restores_lock_byte_identically() { + for before in [LOCK_DIRECT_REGISTRY, LOCK_TRANSITIVE_REGISTRY] { + let tmp = write_lock(before).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_default(&p, tmp.path()).await; + let entry = entry_for(wiring, meta); + + let outcome = revert_pipenv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(read_lock(tmp.path()).await, before, "byte-identical revert"); + } + } + + #[tokio::test] + async fn revert_dry_run_changes_nothing() { + let tmp = write_lock(LOCK_DIRECT_REGISTRY).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_default(&p, tmp.path()).await; + let wired = read_lock(tmp.path()).await; + + let outcome = revert_pipenv(&entry_for(wiring, meta), tmp.path(), true).await; + assert!(outcome.success); + assert_eq!(read_lock(tmp.path()).await, wired, "dry run must not write"); + } + + /// SECURITY: a poisoned state.json wiring record naming any file other + /// than Pipfile.lock (or smuggling an unknown section into the key) is + /// skipped fail-closed — the named path/pointer is never dereferenced. + #[tokio::test] + async fn revert_allowlist_skips_unexpected_files_and_sections_fail_closed() { + let outer = tempfile::tempdir().unwrap(); + let root = outer.path().join("project"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(root.join("Pipfile.lock"), LOCK_DIRECT_REGISTRY) + .await + .unwrap(); + let precious = outer.path().join("precious.txt"); + tokio::fs::write(&precious, "keep me intact\n").await.unwrap(); + + let bad_records = [ + ("Pipfile", "default:six"), + ("../precious.txt", "default:six"), + ("/etc/hosts", "default:six"), + ("Pipfile.lock", "_meta:six"), + ("Pipfile.lock", "no-colon-key"), + ]; + for (file, key) in bad_records { + let wiring = vec![WiringRecord { + file: file.to_string(), + kind: KIND_LOCK_ENTRY.to_string(), + action: WiringAction::Rewritten, + key: Some(key.to_string()), + original: Some(serde_json::json!({"malicious": true})), + new: Some(serde_json::json!("keep me intact")), + }]; + let meta = PipenvMeta { sections: vec!["default".into()] }; + let outcome = revert_pipenv(&entry_for(wiring, meta), &root, false).await; + assert!(outcome.success, "skipped fail-closed, not a hard error: {file}/{key}"); + assert!( + outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + "skip surfaced for {file}/{key}: {:?}", + outcome.warnings + ); + } + assert_eq!( + tokio::fs::read_to_string(&precious).await.unwrap(), + "keep me intact\n", + "out-of-tree file byte-untouched" + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Pipfile.lock")).await.unwrap(), + LOCK_DIRECT_REGISTRY, + "no record matched: the lock is not even re-serialized" + ); + } + + /// A third-party edit to the entry we wrote (e.g. `pipenv lock` + /// regenerated it — spike V6) is left alone with a drift warning; + /// unknown wiring kinds from a newer ledger degrade the same way. + #[tokio::test] + async fn revert_warns_and_skips_on_drifted_entry_and_unknown_kind() { + let tmp = write_lock(LOCK_DIRECT_REGISTRY).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + let (mut wiring, meta) = wire_default(&p, tmp.path()).await; + wiring.push(WiringRecord { + file: "Pipfile.lock".into(), + kind: "pipenv_future_kind".into(), + action: WiringAction::Added, + key: Some("default:six".into()), + original: None, + new: Some(serde_json::json!("x")), + }); + + // Drift: someone replaced our hash in the vendored entry. + let drifted = read_lock(tmp.path()).await.replace(WHEEL_SHA, &"0".repeat(64)); + tokio::fs::write(tmp.path().join("Pipfile.lock"), &drifted).await.unwrap(); + + let outcome = revert_pipenv(&entry_for(wiring, meta), tmp.path(), false).await; + assert!(outcome.success); + assert_eq!( + outcome + .warnings + .iter() + .filter(|w| w.code == "vendor_lock_entry_drifted") + .count(), + 2, + "drifted entry + unknown kind: {:?}", + outcome.warnings + ); + assert_eq!(read_lock(tmp.path()).await, drifted, "drifted lock left alone"); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs b/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs index 6c90a08..4d5d0d7 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs @@ -1 +1,1225 @@ -//! (stub — backend lands behind the npm_flavor / pypi router) +//! poetry-project wiring: a lock-ONLY `[[package]]` splice (poetry.lock +//! lock-versions 2.0 and 2.1). +//! +//! Unlike uv (whose sources entry must be paired into pyproject.toml), poetry +//! installs are 100% lock-driven and `metadata.content-hash` covers ONLY the +//! pyproject — so the vendored wheel is wired by rewriting just the target +//! `[[package]]` unit (files[] → the single patched-wheel hash, plus a +//! `[package.source] type = "file"` table) and touching nothing else. The +//! spike proved this splice passes `poetry install`/`sync`/`check --lock` +//! byte-stably on BOTH supported majors (Poetry 2.4.1 = lock 2.1, Poetry +//! 1.8.5 = lock 2.0), is hash-fail-closed against a tampered wheel, and works +//! for direct AND transitive deps — see `spikes/poetry/` and the poetry +//! section of `spikes/PHASE0-V2-FINDINGS.txt`. +//! +//! Drift caveat (spike P5): `poetry update `, 2.x `poetry lock +//! --regenerate` and 1.x plain `poetry lock` silently revert the splice with +//! exit 0; the lock's files[] hash is the drift oracle. `pyproject.toml` and +//! `metadata.content-hash` are NEVER written by this backend. + +use std::path::Path; + +use toml_edit::{DocumentMut, Item, Value}; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::fs::atomic_write_bytes; + +use super::path::parse_vendor_path; +use super::state::{PoetryMeta, VendorEntry, WiringAction, WiringRecord}; +use super::toml_surgery::find_unit_span; +use super::{RevertOutcome, VendorWarning}; + +/// The only file this backend ever writes (and the revert allowlist). +const LOCK_FILE: &str = "poetry.lock"; + +/// The `WiringRecord.kind` discriminator this backend owns. +const KIND_LOCK_PACKAGE: &str = "poetry_lock_package"; + +/// A loaded-and-guard-checked poetry project. +#[derive(Debug)] +pub struct PoetryProject { + /// Verbatim poetry.lock text (the surgery substrate). + pub lock_text: String, + /// Parsed lock (guard checks only — every edit is text surgery). + pub lock: DocumentMut, + /// pyproject.toml content when present. NEVER written; read only to + /// classify the dependency for [`PoetryMeta::dep_class`] diagnostics. + pub pyproject_text: Option, + /// poetry.lock `[metadata] lock-version` (recorded into [`PoetryMeta`]). + pub lock_version: String, + /// Non-fatal advisories raised during load (untested lock version). + pub warnings: Vec, +} + +/// What the target `[[package]]` unit already looks like. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PoetryTarget { + /// Registry-shaped: proceed to build the wheel and wire. + Fresh, + /// Already wired to THIS patch uuid — the caller synthesizes an + /// AlreadyPatched success, builds nothing, and records nothing (the + /// first run's ledger entry holds the only copy of the original). + InSync, +} + +/// Read + parse poetry.lock and run every project-level guard. Refuses +/// before ANY write — the orchestrator runs this (and the target guards) +/// before the wheel is built, so a refusal leaves the tree byte-untouched. +pub async fn load_poetry_project(root: &Path) -> Result { + let lock_text = tokio::fs::read_to_string(root.join(LOCK_FILE)) + .await + .map_err(|e| { + ( + "pypi_poetry_lock_parse_failed", + format!("cannot read {LOCK_FILE}: {e}"), + ) + })?; + let lock: DocumentMut = lock_text.parse().map_err(|e| { + ( + "pypi_poetry_lock_parse_failed", + format!("{LOCK_FILE} does not parse: {e}"), + ) + })?; + + let lock_version = lock + .get("metadata") + .and_then(|m| item_get(m, "lock-version")) + .and_then(Item::as_str) + .map(str::to_string) + .ok_or_else(|| { + ( + "pypi_poetry_lock_version_unsupported", + format!("{LOCK_FILE} has no [metadata] lock-version; only 2.x locks are supported"), + ) + })?; + let mut warnings = Vec::new(); + match lock_version.as_str() { + // The fixture-tested versions (Poetry 1.8.x writes 2.0, 2.x writes 2.1). + "2.0" | "2.1" => {} + // A newer 2.x minor keeps the shapes we rewrite (additive schema), so + // it warns instead of refusing; `poetry check --lock` is the backstop. + v if is_newer_2x(v) => warnings.push(VendorWarning::new( + "pypi_poetry_lock_version_untested", + format!( + "poetry.lock lock-version {v} is newer than the fixture-tested 2.0/2.1; \ + verify with `poetry check --lock` after vendoring" + ), + )), + v => { + return Err(( + "pypi_poetry_lock_version_unsupported", + format!( + "poetry.lock lock-version {v:?} is not a supported 2.x lock; re-lock with \ + Poetry >= 1.3" + ), + )) + } + } + + let pyproject_text = tokio::fs::read_to_string(root.join("pyproject.toml")).await.ok(); + Ok(PoetryProject { + lock_text, + lock, + pyproject_text, + lock_version, + warnings, + }) +} + +/// `"direct"` iff the package is declared in the pyproject — +/// `[tool.poetry.dependencies]` / `dev-dependencies` / +/// `[tool.poetry.group.*.dependencies]` keys, or PEP 621 +/// `[project] dependencies` / `optional-dependencies` specs — else +/// `"transitive"`. Diagnostics ONLY ([`PoetryMeta::dep_class`]): the splice +/// is identical either way, so a missing/unparseable pyproject degrades to +/// `"transitive"` instead of refusing. +pub fn classify_dependency(p: &PoetryProject, canon_name: &str) -> &'static str { + let Some(text) = p.pyproject_text.as_deref() else { + return "transitive"; + }; + let Ok(doc) = text.parse::() else { + return "transitive"; + }; + let mut declared: Vec = Vec::new(); + if let Some(poetry) = doc.get("tool").and_then(|t| item_get(t, "poetry")) { + for table in ["dependencies", "dev-dependencies"] { + if let Some(deps) = item_get(poetry, table).and_then(Item::as_table_like) { + declared.extend(deps.iter().map(|(k, _)| k.to_string())); + } + } + if let Some(groups) = item_get(poetry, "group").and_then(Item::as_table_like) { + for (_, group) in groups.iter() { + if let Some(deps) = item_get(group, "dependencies").and_then(Item::as_table_like) { + declared.extend(deps.iter().map(|(k, _)| k.to_string())); + } + } + } + } + if let Some(project) = doc.get("project") { + if let Some(deps) = item_get(project, "dependencies").and_then(Item::as_array) { + declared.extend( + deps.iter() + .filter_map(Value::as_str) + .map(|s| pep508_name(s).to_string()), + ); + } + if let Some(optional) = + item_get(project, "optional-dependencies").and_then(Item::as_table_like) + { + for (_, item) in optional.iter() { + if let Some(arr) = item.as_array() { + declared.extend( + arr.iter() + .filter_map(Value::as_str) + .map(|s| pep508_name(s).to_string()), + ); + } + } + } + } + if declared + .iter() + .any(|n| canonicalize_pypi_name(n) == canon_name) + { + "direct" + } else { + "transitive" + } +} + +/// Target-specific guards (also re-run by [`wire_poetry`] right before +/// writing). The orchestrator runs them pre-flight so a refusal happens +/// before the wheel artifact is built. Lock names match by PEP 503 canonical +/// form (spike P8: the lock records `pyyaml` for a `PyYAML` pyproject spec). +pub(super) fn check_target_guards( + p: &PoetryProject, + canon_name: &str, + version: &str, + record_uuid: &str, +) -> Result { + let units: Vec<&toml_edit::Table> = p + .lock + .get("package") + .and_then(Item::as_array_of_tables) + .map(|pkgs| { + pkgs.iter() + .filter(|t| { + t.get("name") + .and_then(Item::as_str) + .map(canonicalize_pypi_name) + .as_deref() + == Some(canon_name) + }) + .collect() + }) + .unwrap_or_default(); + if units.is_empty() { + return Err(( + "pypi_poetry_lock_package_missing", + format!("{LOCK_FILE} has no [[package]] entry for {canon_name}; run `poetry lock` first"), + )); + } + // Marker-forked resolutions list the same name at multiple versions; one + // surgical rewrite would mispin the other forks — refuse (mirrors uv). + if units.len() > 1 { + return Err(( + "pypi_poetry_lock_forked_package", + format!( + "{LOCK_FILE} resolves {canon_name} at multiple versions/markers (a forked \ + resolution); vendoring would mispin the other forks" + ), + )); + } + let unit = units[0]; + + if let Some(source) = unit.get("source") { + let url = source + .as_table_like() + .and_then(|t| t.get("url")) + .and_then(Item::as_str) + .unwrap_or(""); + return match parse_vendor_path(url) { + // Ours, same patch generation: the in-sync hot path. + Some(parts) if parts.eco == "pypi" && parts.uuid == record_uuid => { + Ok(PoetryTarget::InSync) + } + // Ours, but a STALE patch generation: wiring over it would lose + // the only recorded registry original — refuse with the repair + // path (mirrors gem's stale-checksum refusal). + Some(parts) if parts.eco == "pypi" => Err(( + "pypi_poetry_source_already_exists", + format!( + "{LOCK_FILE} already routes {canon_name} through \ + .socket/vendor/pypi/{} (an earlier socket-patch vendor); run \ + `socket-patch vendor --revert` for it and re-vendor", + parts.uuid + ), + )), + // A user-authored source (path/url/git/private registry). + _ => Err(( + "pypi_poetry_source_already_exists", + format!( + "{LOCK_FILE} already declares a [package.source] for {canon_name}; \ + refusing to overwrite a user-authored source" + ), + )), + }; + } + + // The splice keeps the unit's version line verbatim, so the lock must + // already resolve the version being patched (lock/venv drift otherwise). + let locked_version = unit.get("version").and_then(Item::as_str).unwrap_or(""); + if locked_version != version { + return Err(( + "pypi_poetry_lock_package_missing", + format!( + "{LOCK_FILE} resolves {canon_name} at {locked_version:?}, not the patched \ + {version}; re-lock so the lock matches the installed version" + ), + )); + } + Ok(PoetryTarget::Fresh) +} + +/// Wire poetry.lock for the vendored wheel: rewrite ONLY the target +/// `[[package]]` unit (the new text is fully computed before any write, then +/// committed atomically). `rel_wheel` is the project-relative wheel path +/// (`.socket/vendor/pypi//`, no `./` prefix — the lock url is +/// recorded exactly as poetry itself writes it, fixture-pinned). +#[allow(clippy::too_many_arguments)] +pub async fn wire_poetry( + p: &PoetryProject, + root: &Path, + canon_name: &str, + version: &str, + rel_wheel: &str, + wheel_file_name: &str, + wheel_sha256_hex: &str, + record_uuid: &str, +) -> Result<(Vec, PoetryMeta), (&'static str, String)> { + match check_target_guards(p, canon_name, version, record_uuid)? { + // Defensive: the orchestrator short-circuits in-sync pre-flight and + // never calls wire on it (we must never re-record our own edit as an + // "original"). + PoetryTarget::InSync => { + return Err(( + "pypi_poetry_source_already_exists", + format!( + "{LOCK_FILE} already wires {canon_name} to this patch's vendored wheel; \ + nothing to wire" + ), + )) + } + PoetryTarget::Fresh => {} + } + + let (old_unit, new_unit) = rewrite_target_package_unit( + &p.lock_text, + canon_name, + rel_wheel, + wheel_file_name, + wheel_sha256_hex, + )?; + let new_lock = p.lock_text.replacen(&old_unit, &new_unit, 1); + atomic_write_bytes(&root.join(LOCK_FILE), new_lock.as_bytes()) + .await + .map_err(|e| { + ( + "pypi_poetry_write_failed", + format!("cannot write {LOCK_FILE}: {e}"), + ) + })?; + + let wiring = vec![record( + KIND_LOCK_PACKAGE, + WiringAction::Rewritten, + canon_name, + Some(old_unit), + new_unit, + )]; + let meta = PoetryMeta { + dep_class: classify_dependency(p, canon_name).to_string(), + lock_version: p.lock_version.clone(), + }; + Ok((wiring, meta)) +} + +/// Reverse the wiring: restore the verbatim original `[[package]]` unit. A +/// fragment that no longer matches what we wrote is left alone with a +/// `vendor_lock_entry_drifted` warning — revert never clobbers third-party +/// edits. +pub async fn revert_poetry(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { + let lock_path = root.join(LOCK_FILE); + let mut lock_text = match tokio::fs::read_to_string(&lock_path).await { + Ok(t) => t, + Err(e) => return RevertOutcome::failed(format!("cannot read {LOCK_FILE}: {e}")), + }; + let mut warnings: Vec = Vec::new(); + + for rec in entry.wiring.iter().rev() { + // SECURITY: `rec.file` comes verbatim from the committed, tamper-able + // state.json. This backend only ever wrote poetry.lock (the per-flavor + // file allowlist); any other recorded path is skipped fail-closed with + // a warning and is NEVER resolved against the filesystem. + if rec.file != LOCK_FILE { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "ignoring wiring record for unexpected file `{}` (only {LOCK_FILE} is \ + poetry-owned)", + rec.file + ), + )); + continue; + } + let drifted = || { + VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "{LOCK_FILE} fragment for {:?} changed since vendoring; left untouched", + rec.key + ), + ) + }; + match rec.kind.as_str() { + KIND_LOCK_PACKAGE => { + let new_text = rec.new.as_ref().and_then(serde_json::Value::as_str); + let original_text = rec.original.as_ref().and_then(serde_json::Value::as_str); + let (Some(new), Some(orig)) = (new_text, original_text) else { + warnings.push(drifted()); + continue; + }; + if lock_text.contains(new) { + lock_text = lock_text.replacen(new, orig, 1); + } else { + warnings.push(drifted()); + } + } + // Forward compatibility: a newer ledger's unknown kind degrades + // to a warning (never guess at a fragment shape). + other => warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("unknown poetry wiring kind {other:?}; skipped"), + )), + } + } + + if !dry_run { + if let Err(e) = atomic_write_bytes(&lock_path, lock_text.as_bytes()).await { + return RevertOutcome { + success: false, + warnings, + error: Some(format!("cannot write {LOCK_FILE}: {e}")), + }; + } + } + RevertOutcome { + success: true, + warnings, + error: None, + } +} + +// ── helpers ────────────────────────────────────────────────────────────── + +fn record( + kind: &str, + action: WiringAction, + key: &str, + original: Option, + new: String, +) -> WiringRecord { + WiringRecord { + file: LOCK_FILE.to_string(), + kind: kind.to_string(), + action, + key: Some(key.to_string()), + original: original.map(serde_json::Value::String), + new: Some(serde_json::Value::String(new)), + } +} + +fn item_get<'a>(item: &'a Item, key: &str) -> Option<&'a Item> { + item.as_table_like().and_then(|t| t.get(key)) +} + +/// `2.` with minor > 1 (the lock-versions newer than the fixtures). +fn is_newer_2x(v: &str) -> bool { + v.strip_prefix("2.") + .and_then(|rest| rest.split('.').next()) + .and_then(|minor| minor.parse::().ok()) + .is_some_and(|minor| minor > 1) +} + +/// Leading PEP 508 distribution name of a dependency spec. +fn pep508_name(spec: &str) -> &str { + let s = spec.trim_start(); + let end = s + .char_indices() + .find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) + .map(|(i, _)| i) + .unwrap_or(s.len()); + &s[..end] +} + +fn unit_has_canon_name(lines: &[&str], canon: &str) -> bool { + lines + .iter() + .find_map(|l| l.strip_prefix("name = ")) + .map(|r| canonicalize_pypi_name(r.trim().trim_matches('"'))) + .as_deref() + == Some(canon) +} + +/// Rewrite the target `[[package]]` unit to the file-source shape proven by +/// the fixture pairs: `files = [...]` becomes the single +/// `{file = "", hash = "sha256:"}` element and a +/// `[package.source] type = "file"` table is appended as the LAST subtable +/// (poetry's own placement on both majors — spike P1). Every other line — +/// version, python-versions, groups (2.1) / no groups (2.0), description, +/// existing subtables — is preserved verbatim. Returns `(old_unit, new_unit)` +/// for the wiring record. +fn rewrite_target_package_unit( + lock_text: &str, + canon: &str, + rel_wheel: &str, + wheel_file_name: &str, + wheel_sha256_hex: &str, +) -> Result<(String, String), (&'static str, String)> { + let span = find_unit_span(lock_text, |lines| unit_has_canon_name(lines, canon)).ok_or_else( + || { + ( + "pypi_poetry_lock_package_missing", + format!("{LOCK_FILE} has no [[package]] entry for {canon}"), + ) + }, + )?; + // `find_unit_span` ends a unit at the NEXT `[[package]]` or EOF, but + // poetry's `[metadata]` section trails the LAST unit — truncate at the + // first top-level header that is not a `[package.*]` subtable so the + // splice never swallows it. + let mut unit: Vec<&str> = lock_text[span].lines().collect(); + if let Some(stop) = unit + .iter() + .enumerate() + .skip(1) + .find_map(|(i, l)| (l.starts_with('[') && !l.starts_with("[package.")).then_some(i)) + { + unit.truncate(stop); + while unit.last().is_some_and(|l| l.trim().is_empty()) { + unit.pop(); + } + } + let old_unit = unit.join("\n"); + let files_lines = [ + "files = [".to_string(), + format!(" {{file = \"{wheel_file_name}\", hash = \"sha256:{wheel_sha256_hex}\"}},"), + "]".to_string(), + ]; + + let mut out: Vec = Vec::new(); + let mut files_done = false; + let mut i = 0; + while i < unit.len() { + let line = unit[i]; + if line.starts_with("files = [") { + out.extend(files_lines.iter().cloned()); + files_done = true; + if !line.trim_end().ends_with(']') { + // skip the original multi-line array body + closing bracket + while i + 1 < unit.len() && unit[i + 1].trim() != "]" { + i += 1; + } + i += 1; + } + } else { + out.push(line.to_string()); + } + i += 1; + } + if !files_done { + // 2.x locks always carry files[]; a unit without one is a shape we + // have no fixture for — fail closed rather than guess a placement. + return Err(( + "pypi_poetry_lock_parse_failed", + format!("the {canon} [[package]] entry has no files array to rewrite"), + )); + } + out.push(String::new()); + out.push("[package.source]".to_string()); + out.push("type = \"file\"".to_string()); + out.push(format!("url = \"{rel_wheel}\"")); + Ok((old_unit, out.join("\n"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::patch::vendor::state::VendorArtifact; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const REL_WHEEL: &str = + ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"; + const WHEEL_NAME: &str = "six-1.16.0-py2.py3-none-any.whl"; + /// sha256 of the spike's patched wheel (spikes/poetry/wheels/patched/). + const WHEEL_SHA: &str = "0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"; + + // ── fixture constants ────────────────────────────────────────────── + // Byte-exact copies of the spikes/poetry/ fixtures (Poetry 2.4.1 for + // lock 2.1, Poetry 1.8.5 for lock 2.0; spike date 2026-06-10). The + // registry locks are tool-generated (`poetry lock`); the vendored locks + // are the evidence-lockonly/ splices both majors install byte-stably. + // If these drift from the committed fixtures, the spike dirs are the + // source of truth. + + /// spikes/poetry/lock-2.1/direct-registry/pyproject.toml (verbatim). + const PYPROJECT_DIRECT: &str = r#"[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +six = "1.16.0" +"#; + + /// spikes/poetry/lock-2.1/transitive-registry/pyproject.toml (verbatim). + const PYPROJECT_TRANSITIVE: &str = r#"[tool.poetry] +name = "scratch" +version = "0.1.0" +description = "" +authors = ["Spike "] +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.9" +python-dateutil = "2.8.2" +"#; + + /// spikes/poetry/lock-2.1/direct-registry/poetry.lock (verbatim). + const LOCK21_DIRECT_REGISTRY: &str = r#"# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9" +content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" +"#; + + /// spikes/poetry/evidence-lockonly/lock-2.1-direct/poetry.lock (verbatim + /// — the spliced state both majors install byte-stably, spike P2). + const LOCK21_DIRECT_VENDORED: &str = r#"# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, +] + +[package.source] +type = "file" +url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9" +content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" +"#; + + /// The transitive "before": dateutil unit + [metadata] verbatim from + /// spikes/poetry/lock-2.1/transitive-registry/poetry.lock, with the six + /// unit verbatim from lock-2.1/direct-registry — the registry resolution + /// poetry produced when 1.16.0 was current (the production case: the lock + /// resolves the version being patched; today's resolver picks 1.17.0, + /// spike P3). + const LOCK21_TRANSITIVE_REGISTRY: &str = r#"# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9" +content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca" +"#; + + /// spikes/poetry/evidence-lockonly/lock-2.1-transitive/poetry.lock + /// (verbatim — the transitive splice, spike P3). + const LOCK21_TRANSITIVE_VENDORED: &str = r#"# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, +] + +[package.source] +type = "file" +url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9" +content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca" +"#; + + /// spikes/poetry/lock-2.0/direct-registry/poetry.lock (verbatim — Poetry + /// 1.8.5; lock 2.0 has NO groups key). + const LOCK20_DIRECT_REGISTRY: &str = r#"# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9" +content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" +"#; + + /// spikes/poetry/evidence-lockonly/lock-2.0-direct/poetry.lock (verbatim). + const LOCK20_DIRECT_VENDORED: &str = r#"# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, +] + +[package.source] +type = "file" +url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9" +content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" +"#; + + /// The lock-2.0 transitive "before" (assembled like the 2.1 twin: units + /// verbatim from the lock-2.0 tool-generated fixtures, six pinned at the + /// patched 1.16.0). + const LOCK20_TRANSITIVE_REGISTRY: &str = r#"# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9" +content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca" +"#; + + /// spikes/poetry/evidence-lockonly/lock-2.0-transitive/poetry.lock + /// (verbatim). + const LOCK20_TRANSITIVE_VENDORED: &str = r#"# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, +] + +[package.source] +type = "file" +url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9" +content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca" +"#; + + async fn write_project(lock: &str, pyproject: &str) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("poetry.lock"), lock).await.unwrap(); + tokio::fs::write(tmp.path().join("pyproject.toml"), pyproject) + .await + .unwrap(); + tmp + } + + async fn read_lock(root: &Path) -> String { + tokio::fs::read_to_string(root.join("poetry.lock")).await.unwrap() + } + + fn entry_for(wiring: Vec, meta: PoetryMeta) -> VendorEntry { + VendorEntry { + ecosystem: "pypi".into(), + base_purl: "pkg:pypi/six@1.16.0".into(), + uuid: UUID.into(), + artifact: VendorArtifact { + path: REL_WHEEL.into(), + sha256: WHEEL_SHA.into(), + size: Some(11053), + platform_locked: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + flavor: Some("poetry".into()), + uv: None, + pnpm: None, + poetry: Some(meta), + pdm: None, + pipenv: None, + } + } + + async fn wire_default(p: &PoetryProject, root: &Path) -> (Vec, PoetryMeta) { + wire_poetry(p, root, "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UUID) + .await + .unwrap() + } + + /// The load-bearing oracle: wiring the registry lock must produce the + /// spliced evidence-lockonly lock BYTE-IDENTICALLY (per lock version, + /// direct and transitive), leaving pyproject and content-hash untouched. + #[tokio::test] + async fn wiring_matches_fixtures_byte_identically_both_lock_versions() { + let cases = [ + ("2.1", LOCK21_DIRECT_REGISTRY, LOCK21_DIRECT_VENDORED, PYPROJECT_DIRECT, "direct"), + ( + "2.1", + LOCK21_TRANSITIVE_REGISTRY, + LOCK21_TRANSITIVE_VENDORED, + PYPROJECT_TRANSITIVE, + "transitive", + ), + ("2.0", LOCK20_DIRECT_REGISTRY, LOCK20_DIRECT_VENDORED, PYPROJECT_DIRECT, "direct"), + ( + "2.0", + LOCK20_TRANSITIVE_REGISTRY, + LOCK20_TRANSITIVE_VENDORED, + PYPROJECT_TRANSITIVE, + "transitive", + ), + ]; + for (lock_version, before, after, pyproject, dep_class) in cases { + let tmp = write_project(before, pyproject).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + assert!(p.warnings.is_empty(), "{lock_version}: {:?}", p.warnings); + assert_eq!(p.lock_version, lock_version); + assert_eq!(classify_dependency(&p, "six"), dep_class); + assert_eq!( + check_target_guards(&p, "six", "1.16.0", UUID).unwrap(), + PoetryTarget::Fresh + ); + + let (wiring, meta) = wire_default(&p, tmp.path()).await; + assert_eq!( + read_lock(tmp.path()).await, + after, + "{lock_version}/{dep_class}: poetry.lock must byte-match the spliced fixture" + ); + // pyproject + content-hash are NEVER touched (lock-only splice). + assert_eq!( + tokio::fs::read_to_string(tmp.path().join("pyproject.toml")).await.unwrap(), + pyproject + ); + + assert_eq!(wiring.len(), 1); + assert_eq!(wiring[0].kind, KIND_LOCK_PACKAGE); + assert_eq!(wiring[0].action, WiringAction::Rewritten); + assert_eq!(wiring[0].file, "poetry.lock"); + assert_eq!(wiring[0].key.as_deref(), Some("six")); + assert_eq!(meta.dep_class, dep_class); + assert_eq!(meta.lock_version, lock_version); + } + } + + #[tokio::test] + async fn guards_refuse_parse_version_missing_forked_and_sources() { + // unreadable / unparseable lock + let tmp = tempfile::tempdir().unwrap(); + let err = load_poetry_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_poetry_lock_parse_failed"); + let tmp = write_project("[[package]\nbroken", PYPROJECT_DIRECT).await; + let err = load_poetry_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_poetry_lock_parse_failed"); + + // lock-version absent / non-2.x + let tmp = write_project("[[package]]\nname = \"six\"\n", PYPROJECT_DIRECT).await; + let err = load_poetry_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_poetry_lock_version_unsupported"); + for bad in ["1.1", "3.0"] { + let lock = LOCK21_DIRECT_REGISTRY + .replace("lock-version = \"2.1\"", &format!("lock-version = \"{bad}\"")); + let tmp = write_project(&lock, PYPROJECT_DIRECT).await; + let err = load_poetry_project(tmp.path()).await.unwrap_err(); + assert_eq!(err.0, "pypi_poetry_lock_version_unsupported", "{bad}"); + } + + // target absent from the lock + let tmp = write_project(LOCK21_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "absent-pkg", "1.0.0", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_poetry_lock_package_missing"); + + // forked: the same name at two versions (marker fork) + let fork = format!( + "{LOCK21_DIRECT_REGISTRY}\n[[package]]\nname = \"six\"\nversion = \"1.17.0\"\noptional = false\npython-versions = \"*\"\ngroups = [\"main\"]\nfiles = []\n" + ); + let tmp = write_project(&fork, PYPROJECT_DIRECT).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "six", "1.16.0", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_poetry_lock_forked_package"); + + // single unit at a DIFFERENT version than the patch target + let tmp = write_project(LOCK21_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "six", "1.17.0", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_poetry_lock_package_missing"); + assert!(err.1.contains("1.16.0"), "{}", err.1); + + // user-authored [package.source] + let user = LOCK21_DIRECT_VENDORED.replace( + "url = \".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl\"", + "url = \"../local/six-1.16.0-py2.py3-none-any.whl\"", + ); + let tmp = write_project(&user, PYPROJECT_DIRECT).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + let err = check_target_guards(&p, "six", "1.16.0", UUID).unwrap_err(); + assert_eq!(err.0, "pypi_poetry_source_already_exists"); + assert!(err.1.contains("user-authored"), "{}", err.1); + + // wire re-runs the guards itself (refusal before any write) + let before = read_lock(tmp.path()).await; + let err = + wire_poetry(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UUID) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_poetry_source_already_exists"); + assert_eq!(read_lock(tmp.path()).await, before, "refusal writes nothing"); + } + + #[tokio::test] + async fn newer_2x_lock_version_warns_not_refuses() { + let lock = LOCK21_DIRECT_REGISTRY.replace("lock-version = \"2.1\"", "lock-version = \"2.5\""); + let tmp = write_project(&lock, PYPROJECT_DIRECT).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + assert_eq!(p.warnings.len(), 1); + assert_eq!(p.warnings[0].code, "pypi_poetry_lock_version_untested"); + assert_eq!(p.lock_version, "2.5"); + // The wiring itself still works on the warned lock. + let (wiring, meta) = wire_default(&p, tmp.path()).await; + assert_eq!(wiring.len(), 1); + assert_eq!(meta.lock_version, "2.5"); + } + + /// Re-running vendor on an already-wired lock with the SAME uuid is the + /// in-sync hot path: the caller synthesizes AlreadyPatched and records + /// nothing; a DIFFERENT uuid refuses with `vendor --revert` guidance. + #[tokio::test] + async fn rerun_same_uuid_in_sync_and_stale_uuid_refuses_with_guidance() { + let tmp = write_project(LOCK21_DIRECT_VENDORED, PYPROJECT_DIRECT).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + assert_eq!( + check_target_guards(&p, "six", "1.16.0", UUID).unwrap(), + PoetryTarget::InSync + ); + + // A different (stale) patch generation must NOT be silently rewired. + let stale_uuid = "00000000-0000-4000-8000-000000000000"; + let err = check_target_guards(&p, "six", "1.16.0", stale_uuid).unwrap_err(); + assert_eq!(err.0, "pypi_poetry_source_already_exists"); + assert!(err.1.contains("--revert"), "{}", err.1); + assert!(err.1.contains(UUID), "names the wired uuid: {}", err.1); + } + + #[tokio::test] + async fn classify_dependency_covers_every_declaration_surface() { + let p = |pyproject: Option<&str>| PoetryProject { + lock_text: String::new(), + lock: DocumentMut::new(), + pyproject_text: pyproject.map(str::to_string), + lock_version: "2.1".into(), + warnings: Vec::new(), + }; + // [tool.poetry.dependencies] key (with PEP 503 canonicalization). + assert_eq!(classify_dependency(&p(Some(PYPROJECT_DIRECT)), "six"), "direct"); + assert_eq!( + classify_dependency( + &p(Some("[tool.poetry.dependencies]\nPyYAML = \"6.0.1\"\n")), + "pyyaml" + ), + "direct" + ); + // group + dev-dependencies keys. + assert_eq!( + classify_dependency( + &p(Some("[tool.poetry.group.dev.dependencies]\nsix = \"1.16.0\"\n")), + "six" + ), + "direct" + ); + assert_eq!( + classify_dependency(&p(Some("[tool.poetry.dev-dependencies]\nsix = \"*\"\n")), "six"), + "direct" + ); + // PEP 621 dependency specs. + assert_eq!( + classify_dependency(&p(Some("[project]\ndependencies = [\"six==1.16.0\"]\n")), "six"), + "direct" + ); + assert_eq!( + classify_dependency( + &p(Some("[project.optional-dependencies]\nextra = [\"Six_Pkg>=1\"]\n")), + "six-pkg" + ), + "direct" + ); + // Not declared / no pyproject → transitive (diagnostics-only). + assert_eq!(classify_dependency(&p(Some(PYPROJECT_TRANSITIVE)), "six"), "transitive"); + assert_eq!(classify_dependency(&p(None), "six"), "transitive"); + } + + /// Dry-run purity: load + classify + guards are pure reads, mirroring + /// pypi_uv's compute/write split (the orchestrator never calls wire on a + /// dry run). + #[tokio::test] + async fn load_classify_and_guards_write_nothing() { + let tmp = write_project(LOCK21_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + let _ = classify_dependency(&p, "six"); + let _ = check_target_guards(&p, "six", "1.16.0", UUID).unwrap(); + assert_eq!(read_lock(tmp.path()).await, LOCK21_DIRECT_REGISTRY); + assert_eq!( + tokio::fs::read_to_string(tmp.path().join("pyproject.toml")).await.unwrap(), + PYPROJECT_DIRECT + ); + } + + #[tokio::test] + async fn revert_round_trip_restores_lock_byte_identically() { + for (before, pyproject) in [ + (LOCK21_DIRECT_REGISTRY, PYPROJECT_DIRECT), + (LOCK20_TRANSITIVE_REGISTRY, PYPROJECT_TRANSITIVE), + ] { + let tmp = write_project(before, pyproject).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_default(&p, tmp.path()).await; + let entry = entry_for(wiring, meta); + + let outcome = revert_poetry(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(read_lock(tmp.path()).await, before, "byte-identical revert"); + } + } + + #[tokio::test] + async fn revert_dry_run_changes_nothing() { + let tmp = write_project(LOCK21_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_default(&p, tmp.path()).await; + let wired = read_lock(tmp.path()).await; + + let outcome = revert_poetry(&entry_for(wiring, meta), tmp.path(), true).await; + assert!(outcome.success); + assert_eq!(read_lock(tmp.path()).await, wired, "dry run must not write"); + } + + /// SECURITY: a poisoned state.json wiring record naming any file other + /// than poetry.lock is skipped fail-closed — the named path is never + /// read or written. + #[tokio::test] + async fn revert_allowlist_skips_unexpected_files_fail_closed() { + let outer = tempfile::tempdir().unwrap(); + let root = outer.path().join("project"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(root.join("poetry.lock"), LOCK21_DIRECT_REGISTRY) + .await + .unwrap(); + let precious = outer.path().join("precious.txt"); + tokio::fs::write(&precious, "keep me intact\n").await.unwrap(); + + for bad in ["pyproject.toml", "../precious.txt", "/etc/hosts"] { + let wiring = vec![WiringRecord { + file: bad.to_string(), + kind: KIND_LOCK_PACKAGE.to_string(), + action: WiringAction::Rewritten, + key: Some("six".into()), + original: Some(serde_json::json!("malicious payload")), + new: Some(serde_json::json!("keep me intact")), + }]; + let meta = PoetryMeta { dep_class: "direct".into(), lock_version: "2.1".into() }; + let outcome = revert_poetry(&entry_for(wiring, meta), &root, false).await; + assert!(outcome.success, "skipped fail-closed, not a hard error: {bad}"); + assert!( + outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + "skip surfaced for {bad}: {:?}", + outcome.warnings + ); + } + assert_eq!( + tokio::fs::read_to_string(&precious).await.unwrap(), + "keep me intact\n", + "out-of-tree file byte-untouched" + ); + assert_eq!( + tokio::fs::read_to_string(root.join("poetry.lock")).await.unwrap(), + LOCK21_DIRECT_REGISTRY, + "the lock itself is untouched too (no record matched it)" + ); + } + + /// A third-party edit to the unit we wrote (e.g. `poetry update six` + /// reverted it to registry hashes — spike P5) is left alone with a drift + /// warning; unknown wiring kinds from a newer ledger degrade the same way. + #[tokio::test] + async fn revert_warns_and_skips_on_drifted_fragment_and_unknown_kind() { + let tmp = write_project(LOCK21_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + let (mut wiring, meta) = wire_default(&p, tmp.path()).await; + wiring.push(WiringRecord { + file: "poetry.lock".into(), + kind: "poetry_future_kind".into(), + action: WiringAction::Added, + key: Some("six".into()), + original: None, + new: Some(serde_json::json!("x")), + }); + + // Drift: someone re-hashed the vendored files entry. + let drifted = read_lock(tmp.path()).await.replace(WHEEL_SHA, &"0".repeat(64)); + tokio::fs::write(tmp.path().join("poetry.lock"), &drifted).await.unwrap(); + + let outcome = revert_poetry(&entry_for(wiring, meta), tmp.path(), false).await; + assert!(outcome.success); + assert_eq!( + outcome + .warnings + .iter() + .filter(|w| w.code == "vendor_lock_entry_drifted") + .count(), + 2, + "drifted fragment + unknown kind: {:?}", + outcome.warnings + ); + assert_eq!(read_lock(tmp.path()).await, drifted, "drifted lock left alone"); + } + + #[test] + fn newer_2x_classifier() { + assert!(is_newer_2x("2.2")); + assert!(is_newer_2x("2.10")); + assert!(!is_newer_2x("2.0")); + assert!(!is_newer_2x("2.1")); + assert!(!is_newer_2x("3.0")); + assert!(!is_newer_2x("garbage")); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs index 6c90a08..ac1eb17 100644 --- a/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs @@ -1 +1,1578 @@ -//! (stub — backend lands behind the npm_flavor / pypi router) +//! yarn berry (4.x) vendor backend: paired `package.json` resolutions + +//! `yarn.lock` entry surgery. +//! +//! Berry verifies every install against the sha512 of the *converted cache +//! zip* (`checksum: 10c0/`), so a lock-only rewrite à la classic is not +//! enough — but spike B2/B3 (`spikes/PHASE0-V2-FINDINGS.txt` + +//! `spikes/yarn-berry-nm/`) proved the full recipe is reproducible offline: +//! +//! 1. `package.json` gains `"resolutions": {"": "file:./"}` +//! (the dependency ranges stay untouched); +//! 2. `yarn.lock` replaces the `"@npm:"` entry with the exact +//! entry yarn emits for that resolution — key and resolution locator +//! embed the ROOT WORKSPACE NAME (from the lock's `@workspace:.` entry) +//! and the relative tgz path, `hash=` is the first 6 hex chars of +//! sha512(tgz bytes), and `checksum:` is `10c0/` + sha512 of the +//! deterministic cache zip rebuilt by [`super::berry_zip`]. +//! +//! A fresh checkout of exactly {package.json, yarn.lock, .yarnrc.yml, +//! .socket/} then passes `yarn install --immutable --check-cache` fully +//! offline (spike B5). +//! +//! Fail-closed gates, all BEFORE any write: the checksum recipe only holds +//! for cacheKey `10c0` (compressionLevel 0, the yarn 4 default — B4 showed +//! `compressionLevel: mixed` changes both the cacheKey and the checksum), and +//! a user-authored resolutions entry for the same package is never +//! overwritten. The pair is committed package.json-first, lock-second, and +//! the package.json edit is unwound when the lock write fails — a resolutions +//! entry without its lock counterpart would make a plain `yarn install` +//! re-resolve and rewrite the lock underneath the user. + +use std::path::Path; + +use serde_json::Value; +use sha2::{Digest, Sha512}; + +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{normalize_file_path, PatchSources}; +use crate::patch::copy_tree::remove_tree; +use crate::utils::fs::atomic_write_bytes; + +use super::berry_zip::berry_cache_checksum_10c0; +use super::npm_common::{ + done_failure, guard_coordinates, refused, stage_patch_pack, tgz_rel_leaf, +}; +use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; +use super::state::{ + write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, +}; +use super::yarn_classic_lock::{ + already_patched_verify, body_field_line, detect_eol, json_to_lines, lines_to_json, + replace_block, scan_blocks, split_key_patterns, split_pattern, synthesized_result, LockBlock, +}; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +const YARN_LOCK: &str = "yarn.lock"; +const PACKAGE_JSON: &str = "package.json"; +const YARNRC: &str = ".yarnrc.yml"; + +/// Wiring kinds this backend owns. +const KIND_RESOLUTION: &str = "yarn_berry_resolution"; +const KIND_LOCK_ENTRY: &str = "yarn_berry_lock_entry"; + +/// The only cache key the offline checksum recipe reproduces (yarn 4's +/// internal CACHE_VERSION `10` + compressionLevel 0 → `c0`). +const SUPPORTED_CACHE_KEY: &str = "10c0"; + +/// Vendor one installed npm package into a yarn-berry (4.x, cacheKey 10c0) +/// project. Same contract as [`super::npm_lock::vendor_npm`]: refuse-early, +/// wire-last; `entry` is `None` for dry runs and the in-sync re-run. +#[allow(clippy::too_many_arguments)] +pub async fn vendor_yarn_berry( + purl: &str, + installed_dir: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, +) -> VendorOutcome { + let mut warnings: Vec = Vec::new(); + + // ── 1. Coordinates (shared fail-closed guard, before any disk access) ─ + let coords = match guard_coordinates(purl, record) { + Ok(coords) => coords, + Err(outcome) => return *outcome, + }; + let (name, version) = (coords.name, coords.version); + let uuid_dir_rel = coords.uuid_dir_rel.clone(); + let base_purl = coords.base_purl.clone(); + let rel_tgz = format!("{}/{}", coords.uuid_dir_rel, tgz_rel_leaf(name, version)); + // The resolutions spec — `file:./` spelling per the B3 fixture. + let spec = format!("file:./{rel_tgz}"); + + // ── 2. Lockfile + cacheKey gate ─────────────────────────────────────── + let lock_path = project_root.join(YARN_LOCK); + let lock_text = match tokio::fs::read_to_string(&lock_path).await { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return refused( + "vendor_lockfile_missing", + format!( + "no {YARN_LOCK} at {} — vendoring rewires the lockfile, so one must \ + exist (run `yarn install` first)", + project_root.display() + ), + ); + } + Err(e) => { + return refused( + "vendor_lockfile_missing", + format!("cannot read {YARN_LOCK}: {e}"), + ); + } + }; + let blocks = scan_blocks(&lock_text); + let Some(meta) = blocks.iter().find(|b| b.key == "__metadata") else { + return refused( + "vendor_lockfile_version_unsupported", + "yarn.lock has no `__metadata:` entry — not a yarn berry lockfile".to_string(), + ); + }; + let cache_key = berry_field(&meta.lines, "cacheKey").unwrap_or(""); + if cache_key != SUPPORTED_CACHE_KEY { + // The checksum is sha512 of the cache archive, whose bytes depend on + // the cache format version + compression; only 10c0 (stored entries) + // is reproducible offline. Emitting a guess would brick installs + // with YN0018, so refuse. + return refused( + "vendor_yarn_berry_cache_unsupported", + format!( + "yarn.lock cacheKey is `{cache_key}`; only `{SUPPORTED_CACHE_KEY}` (yarn 4 \ + with compressionLevel 0, the default) has an offline-reproducible cache \ + checksum — remove custom compression settings and re-run `yarn install`" + ), + ); + } + + // ── 3. .yarnrc.yml knobs that change the checksum (spike B4) ───────── + match tokio::fs::read_to_string(project_root.join(YARNRC)).await { + Ok(rc) => { + if let Some(level) = yarnrc_compression_level(&rc) { + if level != "0" { + return refused( + "vendor_yarn_berry_cache_unsupported", + format!( + "{YARNRC} sets `compressionLevel: {level}`, which changes berry's \ + cache checksums; only compressionLevel 0 (the yarn 4 default) is \ + supported" + ), + ); + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return refused( + "vendor_yarn_berry_cache_unsupported", + format!("cannot read {YARNRC} to verify the cache configuration: {e}"), + ); + } + } + + // ── 4. Root workspace name (the lock key/resolution embed it) ──────── + let Some(workspace) = root_workspace_name(&blocks) else { + return refused( + "vendor_lockfile_version_unsupported", + "yarn.lock has no root `@workspace:.` entry; cannot build the \ + workspace-bound file: locator" + .to_string(), + ); + }; + + // ── 5. package.json + user-override conflict gate ───────────────────── + let pkg_path = project_root.join(PACKAGE_JSON); + let pkg_bytes = match tokio::fs::read(&pkg_path).await { + Ok(b) => b, + Err(e) => { + return refused( + "vendor_yarn_berry_manifest_unreadable", + format!("cannot read the project {PACKAGE_JSON}: {e}"), + ); + } + }; + let pkg: Value = match serde_json::from_slice(&pkg_bytes) { + Ok(v) => v, + Err(e) => { + return refused( + "vendor_yarn_berry_manifest_unreadable", + format!("{PACKAGE_JSON} is not parseable JSON: {e}"), + ); + } + }; + let Some(pkg_obj) = pkg.as_object() else { + return refused( + "vendor_yarn_berry_manifest_unreadable", + format!("{PACKAGE_JSON} root is not an object"), + ); + }; + if let Some(res) = pkg_obj.get("resolutions") { + let Some(res_obj) = res.as_object() else { + return refused( + "vendor_override_conflict", + format!("{PACKAGE_JSON} `resolutions` is not an object"), + ); + }; + for (selector, value) in res_obj { + let sel_name = split_pattern(selector).map(|(n, _)| n).unwrap_or(selector.as_str()); + if sel_name != name { + continue; + } + // Our own (possibly stale-uuid) entry is fine to overwrite; a + // user-authored override is never clobbered. + let ours = value + .as_str() + .is_some_and(|v| parse_vendor_path(v).is_some_and(|p| p.eco == "npm")); + if !ours { + return refused( + "vendor_override_conflict", + format!( + "{PACKAGE_JSON} already has a resolutions entry for `{selector}` \ + ({value}); vendor will not overwrite a user-authored override" + ), + ); + } + } + } + + // ── 6. The single replaceable lock entry ────────────────────────────── + let target = match scan_berry_target(&blocks, name, version) { + Ok(Some(t)) => t, + Ok(None) => { + return refused( + "vendor_lock_entry_not_found", + format!( + "{YARN_LOCK} has no `{name}@npm:` entry resolving {version} — make sure \ + the package is installed and locked (`yarn install`) before vendoring" + ), + ); + } + Err((code, detail)) => return refused(code, detail), + }; + let patches_manifest = record + .files + .keys() + .any(|k| normalize_file_path(k) == "package.json"); + + // ── 7. Stage → patch → pack (shared flavor-agnostic pipeline) ───────── + let (staged, result) = match stage_patch_pack( + purl, + installed_dir, + project_root, + record, + sources, + dry_run, + force, + ) + .await + { + Ok(pair) => pair, + Err(outcome) => return *outcome, + }; + let Some(staged) = staged else { + // Failed patch (wiring is last — project byte-untouched) or dry run. + return VendorOutcome::Done { result, entry: None, warnings }; + }; + debug_assert_eq!(staged.rel_tgz, rel_tgz); + let packed = staged.packed; + let dest = project_root.join(&rel_tgz); + + // ── 8. Berry identity facts of the packed tarball ───────────────────── + let tgz_bytes = match tokio::fs::read(&dest).await { + Ok(b) => b, + Err(e) => return done_failure(purl, format!("cannot re-read the packed tarball: {e}")), + }; + let tgz_sha512 = hex::encode(Sha512::digest(&tgz_bytes)); + // `hash=` — the first 6 hex chars of sha512(tgz): the lock-committed + // tamper guard on the tarball itself (spike B3, flips on any byte edit). + let hash6 = &tgz_sha512[..6]; + let checksum = match berry_cache_checksum_10c0(&tgz_bytes, name) { + Ok(c) => c, + Err(e) => { + return done_failure( + purl, + format!("cannot compute the berry cache checksum for {name}: {e}"), + ) + } + }; + + // ── 9. The replacement lock entry (verbatim B3 shape) ───────────────── + let locator = encode_uri_component(&format!("{workspace}@workspace:.")); + let lock_key = format!("\"{name}@file:./{rel_tgz}::locator={locator}\""); + let resolution = + format!("{name}@file:./{rel_tgz}#./{rel_tgz}::hash={hash6}&locator={locator}"); + // Sections beyond the five we own (dependencies:, peerDependencies:, + // bin:, …) describe the same package version and carry over verbatim. + let carried = carried_sections(&target.lines); + if patches_manifest { + warnings.push(VendorWarning::new( + "vendor_dep_manifest_stale", + format!( + "the patch rewrites {name}@{version}'s package.json; the yarn.lock entry \ + keeps the registry entry's dependency fields — if the patch changed \ + dependencies, run `yarn install` once to refresh them" + ), + )); + } + let new_lines = build_entry_lines(&lock_key, version, &resolution, &checksum, &carried); + + // ── 10. In-sync hot path: nothing to write, nothing to record ───────── + let pkg_in_sync = pkg_obj + .get("resolutions") + .and_then(|r| r.get(name)) + .and_then(Value::as_str) + == Some(spec.as_str()); + if pkg_in_sync && target.is_ours && target.lines == new_lines { + let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + return VendorOutcome::Done { + result: synthesized_result(purl, &dest, verified, true, None), + entry: None, + warnings, + }; + } + + // ── 11. Build both new byte images, then commit pkg-first/lock-second ─ + let existing_entry = pkg_obj + .get("resolutions") + .and_then(|r| r.get(name)) + .is_some(); + let mut new_pkg = pkg.clone(); + { + let obj = new_pkg.as_object_mut().expect("validated above"); + let res = obj + .entry("resolutions".to_string()) + .or_insert_with(|| Value::Object(serde_json::Map::new())); + let Some(res_obj) = res.as_object_mut() else { + return done_failure(purl, "resolutions table vanished mid-edit".to_string()); + }; + res_obj.insert(name.to_string(), Value::String(spec.clone())); + } + let pkg_indent = detect_indent(&String::from_utf8_lossy(&pkg_bytes)); + let new_pkg_bytes = match serialize_json(&new_pkg, &pkg_indent) { + Ok(b) => b, + Err(e) => return done_failure(purl, format!("cannot serialize {PACKAGE_JSON}: {e}")), + }; + let new_lock_text = { + let blocks_now = scan_blocks(&lock_text); + let Some(block) = blocks_now.iter().find(|b| b.key == target.key) else { + return done_failure( + purl, + format!("lock entry `{}` vanished mid-rewrite", target.key), + ); + }; + replace_block(&lock_text, block, &new_lines, detect_eol(&lock_text)) + }; + if let Err(e) = + commit_pair(project_root, &new_pkg_bytes, &pkg_bytes, new_lock_text.as_bytes()).await + { + return done_failure(purl, e); + } + + // ── 12. Marker + ledger entry ───────────────────────────────────────── + let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); + vulnerabilities.sort(); + let marker = VendorMarker { + schema_version: 1, + purl: base_purl.clone(), + patch_uuid: record.uuid.clone(), + ecosystem: "npm".to_string(), + vulnerabilities, + vendored_at: vendored_at.to_string(), + }; + if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { + warnings.push(VendorWarning::new( + "vendor_marker_write_failed", + format!("could not write the informational vendor marker: {e}"), + )); + } + + let wiring = vec![ + WiringRecord { + file: PACKAGE_JSON.to_string(), + kind: KIND_RESOLUTION.to_string(), + // Rewritten only when replacing our own stale entry — and then + // there is deliberately no `original` (never record our own edit + // as a pre-vendor fragment). + action: if existing_entry { WiringAction::Rewritten } else { WiringAction::Added }, + key: Some(name.to_string()), + original: None, + new: Some(Value::String(spec)), + }, + WiringRecord { + file: YARN_LOCK.to_string(), + kind: KIND_LOCK_ENTRY.to_string(), + action: WiringAction::Rewritten, + key: Some(lock_key), + original: if target.is_ours { None } else { Some(lines_to_json(&target.lines)) }, + new: Some(lines_to_json(&new_lines)), + }, + ]; + let entry = VendorEntry { + ecosystem: "npm".to_string(), + base_purl, + uuid: record.uuid.clone(), + artifact: VendorArtifact { + path: rel_tgz, + sha256: packed.sha256_hex, + size: Some(packed.size), + platform_locked: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + flavor: Some("yarn-berry".to_string()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + }; + VendorOutcome::Done { result, entry: Some(entry), warnings } +} + +/// Undo one yarn-berry vendored package: restore the recorded lock entry, +/// remove the resolutions entry, and remove the artifact dir. +pub async fn revert_yarn_berry( + entry: &VendorEntry, + project_root: &Path, + dry_run: bool, +) -> RevertOutcome { + // SECURITY: validate the tamper-able uuid before any disk access — it + // names the directory tree this revert DELETES. + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &entry.uuid) else { + return RevertOutcome::failed(format!( + "refusing revert: `{}` is not a canonical patch uuid (tampered state.json?)", + entry.uuid + )); + }; + if dry_run { + return RevertOutcome::ok(); + } + + let mut outcome = RevertOutcome::ok(); + + // SECURITY: per-flavor FILE ALLOWLIST — this backend only ever writes + // yarn.lock and package.json; a poisoned state.json naming any other + // path is skipped fail-closed (warned, never read or written). + let mut lock_recs: Vec<&WiringRecord> = Vec::new(); + let mut pkg_recs: Vec<&WiringRecord> = Vec::new(); + for rec in entry.wiring.iter().rev() { + match rec.file.as_str() { + YARN_LOCK => lock_recs.push(rec), + PACKAGE_JSON => pkg_recs.push(rec), + other => outcome.warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "ignoring wiring record for file `{other}` outside the yarn-berry \ + allowlist [\"{YARN_LOCK}\", \"{PACKAGE_JSON}\"]" + ), + )), + } + } + + // yarn.lock fragments (reverse application order). + if !lock_recs.is_empty() { + let lock_path = project_root.join(YARN_LOCK); + match tokio::fs::read_to_string(&lock_path).await { + Ok(mut text) => { + let mut changed = false; + for rec in lock_recs { + revert_lock_record( + &mut text, + rec, + &entry.uuid, + &mut changed, + &mut outcome.warnings, + ); + } + if changed { + if let Err(e) = atomic_write_bytes(&lock_path, text.as_bytes()).await { + return RevertOutcome::failed(format!("cannot write {YARN_LOCK}: {e}")); + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + outcome.warnings.push(VendorWarning::new( + "vendor_lockfile_missing", + format!("{YARN_LOCK} is missing; lock fragments cannot be restored"), + )); + } + Err(e) => return RevertOutcome::failed(format!("cannot read {YARN_LOCK}: {e}")), + } + } + + // package.json resolutions entries. + if !pkg_recs.is_empty() { + let pkg_path = project_root.join(PACKAGE_JSON); + match tokio::fs::read(&pkg_path).await { + Ok(bytes) => { + let mut pkg: Value = match serde_json::from_slice(&bytes) { + Ok(v) => v, + // Fail-closed: rewriting a manifest we cannot parse + // risks destroying it. + Err(e) => { + return RevertOutcome::failed(format!( + "{PACKAGE_JSON} is not parseable JSON ({e}); fix it and re-run revert" + )) + } + }; + let mut changed = false; + for rec in pkg_recs { + revert_resolution_record( + &mut pkg, + rec, + &entry.uuid, + &mut changed, + &mut outcome.warnings, + ); + } + if changed { + let indent = detect_indent(&String::from_utf8_lossy(&bytes)); + match serialize_json(&pkg, &indent) { + Ok(out) => { + if let Err(e) = atomic_write_bytes(&pkg_path, &out).await { + return RevertOutcome::failed(format!( + "cannot write {PACKAGE_JSON}: {e}" + )); + } + } + Err(e) => { + return RevertOutcome::failed(format!( + "cannot serialize {PACKAGE_JSON}: {e}" + )) + } + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + outcome.warnings.push(VendorWarning::new( + "vendor_lockfile_missing", + format!("{PACKAGE_JSON} is missing; the resolutions entry cannot be removed"), + )); + } + Err(e) => return RevertOutcome::failed(format!("cannot read {PACKAGE_JSON}: {e}")), + } + } + + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + } + + outcome +} + +// ───────────────────────────── revert internals ───────────────────────────── + +/// Restore one recorded lock entry iff the live entry is still ours +/// (resolution parses into `.socket/vendor/npm//…`). +fn revert_lock_record( + text: &mut String, + rec: &WiringRecord, + entry_uuid: &str, + changed: &mut bool, + warnings: &mut Vec, +) { + let Some(key) = rec.key.as_deref() else { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("wiring record in {} has no key; left alone", rec.file), + )); + return; + }; + if rec.kind != KIND_LOCK_ENTRY { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("unknown wiring kind `{}` for `{key}`; left alone", rec.kind), + )); + return; + } + let edit = { + let blocks = scan_blocks(text); + let Some(block) = blocks.iter().find(|b| b.key == key) else { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("lock entry `{key}` no longer exists; nothing to restore"), + )); + return; + }; + let ours = berry_field(&block.lines, "resolution") + .and_then(parse_vendor_path) + .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if !ours { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("lock entry `{key}` was re-resolved since vendoring; left alone"), + )); + return; + } + let Some(original) = rec.original.as_ref().and_then(json_to_lines) else { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "lock entry `{key}` has no recorded pre-vendor original; left as-is \ + (re-run `yarn install` to re-resolve it from the registry)" + ), + )); + return; + }; + replace_block(text, block, &original, detect_eol(text)) + }; + *text = edit; + *changed = true; +} + +/// Remove our resolutions entry iff the live value still points into our +/// uuid dir; drop the `resolutions` table when that leaves it empty (we only +/// ever ADD entries — an empty table would be vendor residue). +fn revert_resolution_record( + pkg: &mut Value, + rec: &WiringRecord, + entry_uuid: &str, + changed: &mut bool, + warnings: &mut Vec, +) { + let Some(key) = rec.key.as_deref() else { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("wiring record in {} has no key; left alone", rec.file), + )); + return; + }; + if rec.kind != KIND_RESOLUTION { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("unknown wiring kind `{}` for `{key}`; left alone", rec.kind), + )); + return; + } + let Some(obj) = pkg.as_object_mut() else { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("{PACKAGE_JSON} root is not an object; resolutions entry left alone"), + )); + return; + }; + let Some(res_obj) = obj.get_mut("resolutions").and_then(Value::as_object_mut) else { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("resolutions entry `{key}` no longer exists; nothing to remove"), + )); + return; + }; + let ours = res_obj + .get(key) + .and_then(Value::as_str) + .and_then(parse_vendor_path) + .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if !ours { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("resolutions entry `{key}` was changed since vendoring; left alone"), + )); + return; + } + res_obj.shift_remove(key); + if res_obj.is_empty() { + obj.shift_remove("resolutions"); + } + *changed = true; +} + +// ───────────────────────────── vendor internals ───────────────────────────── + +/// Commit the pair in contract order — package.json first, yarn.lock second +/// — unwinding package.json to its original bytes when the lock write fails +/// (a resolutions entry without its lock counterpart would let a plain +/// `yarn install` silently re-resolve around the patch). +async fn commit_pair( + project_root: &Path, + new_pkg: &[u8], + orig_pkg: &[u8], + new_lock: &[u8], +) -> Result<(), String> { + let pkg_path = project_root.join(PACKAGE_JSON); + atomic_write_bytes(&pkg_path, new_pkg) + .await + .map_err(|e| format!("cannot write {PACKAGE_JSON}: {e}"))?; + if let Err(e) = atomic_write_bytes(&project_root.join(YARN_LOCK), new_lock).await { + return match atomic_write_bytes(&pkg_path, orig_pkg).await { + Ok(()) => Err(format!("cannot write {YARN_LOCK}: {e} ({PACKAGE_JSON} restored)")), + Err(e2) => Err(format!( + "cannot write {YARN_LOCK}: {e} — and restoring {PACKAGE_JSON} failed too: \ + {e2}; restore {PACKAGE_JSON} from version control" + )), + }; + } + Ok(()) +} + +/// The single lock entry the rewrite replaces. +struct BerryTarget { + /// Verbatim key (no trailing colon, quotes kept). + key: String, + lines: Vec, + /// Already one of our `file:` entries (stale uuid or current). + is_ours: bool, +} + +/// Find the one replaceable entry for `name@version`, refusing fail-closed on +/// anything a bare-name resolutions entry would also move (other versions of +/// the name, non-npm protocols, ambiguous duplicates). +fn scan_berry_target( + blocks: &[LockBlock], + name: &str, + version: &str, +) -> Result, (&'static str, String)> { + let mut found: Vec = Vec::new(); + for block in blocks { + if block.key == "__metadata" { + continue; + } + let patterns = split_key_patterns(&block.key); + let parsed: Vec<(&str, &str)> = + patterns.iter().filter_map(|p| split_pattern(p)).collect(); + if parsed.len() != patterns.len() || parsed.is_empty() { + continue; // not a descriptor key we understand; not ours to touch + } + if !parsed.iter().any(|(n, _)| *n == name) { + continue; + } + if !parsed.iter().all(|(n, _)| *n == name) { + return Err(( + "vendor_override_conflict", + format!( + "lock entry `{}` mixes `{name}` with other descriptors; refusing the \ + ambiguous rewrite", + block.key + ), + )); + } + if parsed.iter().all(|(_, r)| r.starts_with("npm:")) { + let v = berry_field(&block.lines, "version").unwrap_or(""); + if v == version { + found.push(BerryTarget { + key: block.key.clone(), + lines: block.lines.clone(), + is_ours: false, + }); + } else { + // SECURITY/CORRECTNESS: resolutions selectors are name-keyed; + // ours would force-move this OTHER version too on the next + // install — refuse rather than silently change versions. + return Err(( + "vendor_override_conflict", + format!( + "yarn.lock also resolves {name}@{v} (`{}`); the name-keyed \ + resolutions entry vendoring writes would move that version too — \ + refusing", + block.key + ), + )); + } + } else if parsed + .iter() + .all(|(_, r)| parse_vendor_path(r).is_some_and(|p| p.eco == "npm")) + { + found.push(BerryTarget { + key: block.key.clone(), + lines: block.lines.clone(), + is_ours: true, + }); + } else { + return Err(( + "vendor_override_conflict", + format!( + "lock entry `{}` resolves {name} through a protocol vendor cannot own \ + (workspace:/patch:/portal:/link:, or a file: outside .socket/vendor) — \ + refusing", + block.key + ), + )); + } + } + match found.len() { + 0 => Ok(None), + 1 => Ok(found.into_iter().next()), + _ => Err(( + "vendor_override_conflict", + format!("multiple yarn.lock entries resolve {name}@{version}; refusing the \ + ambiguous rewrite"), + )), + } +} + +/// The exact entry yarn 4 emits for a resolutions-driven `file:` tarball +/// (spike B3, verbatim), with any carried-over sections (dependencies:, …) +/// in yarn's position between `resolution` and `checksum`. +fn build_entry_lines( + lock_key: &str, + version: &str, + resolution: &str, + checksum: &str, + carried: &[String], +) -> Vec { + let mut out = vec![format!("{lock_key}:")]; + out.push(format!(" version: {version}")); + out.push(format!(" resolution: \"{resolution}\"")); + out.extend(carried.iter().cloned()); + out.push(format!(" checksum: {checksum}")); + out.push(" languageName: node".to_string()); + out.push(" linkType: hard".to_string()); + out +} + +/// Body sections of a lock entry that are NOT the five scalar fields we own +/// — dependency sub-maps, bin:, conditions:, … — verbatim, in order. +fn carried_sections(lines: &[String]) -> Vec { + const OWNED: [&str; 5] = ["version", "resolution", "checksum", "languageName", "linkType"]; + let mut out = Vec::new(); + let mut i = 1; + while i < lines.len() { + if let Some(rest) = body_field_line(&lines[i]) { + let field = rest.split(':').next().unwrap_or(""); + if OWNED.contains(&field) { + i += 1; + continue; + } + out.push(lines[i].clone()); + i += 1; + // Sub-map entries (deeper indent) belong to this section. + while i < lines.len() && body_field_line(&lines[i]).is_none() { + out.push(lines[i].clone()); + i += 1; + } + } else { + out.push(lines[i].clone()); + i += 1; + } + } + out +} + +/// Read a berry scalar field (`: `, value possibly quoted). +fn berry_field<'a>(lines: &'a [String], field: &str) -> Option<&'a str> { + for line in lines.iter().skip(1) { + let Some(rest) = body_field_line(line) else { continue }; + let Some(value) = rest.strip_prefix(field) else { continue }; + let Some(value) = value.strip_prefix(':') else { continue }; + return Some(value.trim().trim_matches('"')); + } + None +} + +/// The root workspace's name: the lock's single-pattern `@workspace:.` +/// entry (the key + resolution of our file: entry embed it). +fn root_workspace_name(blocks: &[LockBlock]) -> Option { + for block in blocks { + if let [single] = split_key_patterns(&block.key).as_slice() { + if let Some(name) = single.strip_suffix("@workspace:.") { + if !name.is_empty() { + return Some(name.to_string()); + } + } + } + } + None +} + +/// The `.yarnrc.yml` `compressionLevel` value, when set. A flat line scan is +/// enough: yarn writes the knob as a top-level scalar (spike B4), and any +/// value we cannot positively read as `0` makes the caller refuse. +fn yarnrc_compression_level(rc: &str) -> Option<&str> { + rc.lines().find_map(|line| { + let rest = line.strip_prefix("compressionLevel:")?; + Some(rest.trim().trim_matches(['\'', '"'])) + }) +} + +/// JS `encodeURIComponent` (uppercase hex, RFC 2396 unreserved set) — the +/// encoding yarn uses for the `locator=` binding in keys/resolutions. +fn encode_uri_component(s: &str) -> String { + const UNRESERVED: &[u8] = b"-_.!~*'()"; + let mut out = String::with_capacity(s.len()); + for &b in s.as_bytes() { + if b.is_ascii_alphanumeric() || UNRESERVED.contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// The manifest's indent unit (mirrors `npm_lock::detect_indent`); defaults +/// to npm's 2 spaces. +fn detect_indent(text: &str) -> String { + for line in text.lines() { + let trimmed = line.trim_start_matches([' ', '\t']); + if !trimmed.is_empty() && trimmed.len() < line.len() { + return line[..line.len() - trimmed.len()].to_string(); + } + } + " ".to_string() +} + +/// Pretty-print with the detected indent + trailing newline (mirrors +/// `npm_lock::serialize_lock`), so untouched keys stay byte-identical. +fn serialize_json(value: &Value, indent: &str) -> std::io::Result> { + use serde::Serialize; + let mut out = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes()); + let mut ser = serde_json::Serializer::with_formatter(&mut out, formatter); + value.serialize(&mut ser).map_err(std::io::Error::other)?; + out.push(b'\n'); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; + use crate::patch::apply::{ApplyResult, VerifyStatus}; + use serde_json::json; + use std::collections::HashMap; + use std::path::PathBuf; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; + const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + + /// Verbatim `spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/package.json`. + const B3_BEFORE_PKG: &str = r#"{ + "name": "vendor-spike", + "version": "1.0.0", + "packageManager": "yarn@4.12.0", + "dependencies": { + "left-pad": "1.3.0" + } +} +"#; + + /// Verbatim `…/b3-vendored-resolutions/after/package.json`. + const B3_AFTER_PKG: &str = r#"{ + "name": "vendor-spike", + "version": "1.0.0", + "packageManager": "yarn@4.12.0", + "dependencies": { + "left-pad": "1.3.0" + }, + "resolutions": { + "left-pad": "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" + } +} +"#; + + /// Verbatim `…/b3-vendored-resolutions/before/yarn.lock` (yarn 4.12.0). + const B3_BEFORE_LOCK: &str = r#"# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"left-pad@npm:1.3.0": + version: 1.3.0 + resolution: "left-pad@npm:1.3.0" + checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955 + languageName: node + linkType: hard + +"vendor-spike@workspace:.": + version: 0.0.0-use.local + resolution: "vendor-spike@workspace:." + dependencies: + left-pad: "npm:1.3.0" + languageName: unknown + linkType: soft +"#; + + /// Verbatim `…/b3-vendored-resolutions/after/yarn.lock` (yarn-emitted). + const B3_AFTER_LOCK: &str = r#"# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": + version: 1.3.0 + resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." + checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 + languageName: node + linkType: hard + +"vendor-spike@workspace:.": + version: 0.0.0-use.local + resolution: "vendor-spike@workspace:." + dependencies: + left-pad: "npm:1.3.0" + languageName: unknown + linkType: soft +"#; + + /// The spike tarball's hash constants inside the after-lock fixture; the + /// tests substitute the recomputed hashes of the tarball this build + /// packs (everything else must match byte-for-byte). + const SPIKE_HASH6: &str = "39ea9b"; + const SPIKE_CHECKSUM: &str = "10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3"; + + const YARNRC_DEFAULT: &str = "nodeLinker: node-modules\nenableGlobalCache: true\nenableTelemetry: false\n"; + + fn spike_after_lock(hash6: &str, checksum: &str) -> String { + B3_AFTER_LOCK + .replace( + &format!("::hash={SPIKE_HASH6}&"), + &format!("::hash={hash6}&"), + ) + .replace(SPIKE_CHECKSUM, checksum) + } + + struct Fixture { + tmp: tempfile::TempDir, + record: PatchRecord, + pkg_bytes: Vec, + lock_bytes: Vec, + } + + impl Fixture { + fn root(&self) -> &Path { + self.tmp.path() + } + + fn installed(&self) -> PathBuf { + self.root().join("node_modules/left-pad") + } + + fn lock_path(&self) -> PathBuf { + self.root().join(YARN_LOCK) + } + + fn pkg_path(&self) -> PathBuf { + self.root().join(PACKAGE_JSON) + } + + fn tgz_path(&self) -> PathBuf { + self.root() + .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")) + } + + /// (hash6, full `10c0/` checksum) of the packed tarball. + async fn packed_berry_facts(&self) -> (String, String) { + let tgz = tokio::fs::read(self.tgz_path()).await.unwrap(); + let hash6 = hex::encode(Sha512::digest(&tgz))[..6].to_string(); + let checksum = berry_cache_checksum_10c0(&tgz, "left-pad").unwrap(); + (hash6, checksum) + } + + async fn vendor(&self, dry_run: bool) -> VendorOutcome { + let blobs = self.root().join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + vendor_yarn_berry( + "pkg:npm/left-pad@1.3.0", + &self.installed(), + self.root(), + &self.record, + &sources, + "2026-06-09T00:00:00Z", + dry_run, + false, + ) + .await + } + + async fn assert_untouched(&self) { + assert_eq!(tokio::fs::read(self.pkg_path()).await.unwrap(), self.pkg_bytes); + assert_eq!(tokio::fs::read(self.lock_path()).await.unwrap(), self.lock_bytes); + assert!(!self.root().join(".socket/vendor").exists()); + } + } + + async fn fixture_with(pkg: &str, lock: &str) -> Fixture { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + let installed = root.join("node_modules/left-pad"); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write( + installed.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .await + .unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX).await.unwrap(); + + let blobs = root.join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX).await.unwrap(); + + tokio::fs::write(root.join(PACKAGE_JSON), pkg.as_bytes()).await.unwrap(); + tokio::fs::write(root.join(YARN_LOCK), lock.as_bytes()).await.unwrap(); + tokio::fs::write(root.join(YARNRC), YARNRC_DEFAULT).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(ORIG_INDEX), + after_hash, + }, + ); + let record = PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-06-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: "test patch".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }; + + Fixture { + tmp, + record, + pkg_bytes: pkg.as_bytes().to_vec(), + lock_bytes: lock.as_bytes().to_vec(), + } + } + + async fn fixture() -> Fixture { + fixture_with(B3_BEFORE_PKG, B3_BEFORE_LOCK).await + } + + fn expect_done( + outcome: VendorOutcome, + ) -> (ApplyResult, Option, Vec) { + match outcome { + VendorOutcome::Done { result, entry, warnings } => (result, entry, warnings), + VendorOutcome::Refused { code, detail } => { + panic!("expected Done, got Refused {code}: {detail}") + } + } + } + + fn expect_refused(outcome: VendorOutcome, want_code: &str) -> String { + match outcome { + VendorOutcome::Refused { code, detail } => { + assert_eq!(code, want_code, "wrong refusal code ({detail})"); + detail + } + VendorOutcome::Done { result, .. } => { + panic!("expected Refused {want_code}, got Done (success={})", result.success) + } + } + } + + #[tokio::test] + async fn b3_fixture_oracle_pair_edit_is_byte_exact() { + let fx = fixture().await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(warnings.is_empty(), "{warnings:?}"); + let entry = entry.expect("success carries a ledger entry"); + + // package.json: byte-for-byte the spike's after fixture. + assert_eq!( + tokio::fs::read_to_string(fx.pkg_path()).await.unwrap(), + B3_AFTER_PKG + ); + // yarn.lock: byte-for-byte modulo the recomputed hash= + checksum of + // the tarball THIS build packed (checksum equality with the + // spike-captured value is berry_zip's own oracle test). + let (hash6, checksum) = fx.packed_berry_facts().await; + assert_eq!( + tokio::fs::read_to_string(fx.lock_path()).await.unwrap(), + spike_after_lock(&hash6, &checksum) + ); + + // Ledger shape: pkg record first (application order), lock second. + assert_eq!(entry.flavor.as_deref(), Some("yarn-berry")); + assert_eq!(entry.wiring.len(), 2); + let pkg_rec = &entry.wiring[0]; + assert_eq!((pkg_rec.file.as_str(), pkg_rec.kind.as_str()), (PACKAGE_JSON, KIND_RESOLUTION)); + assert_eq!(pkg_rec.action, WiringAction::Added); + assert_eq!(pkg_rec.key.as_deref(), Some("left-pad")); + assert_eq!( + pkg_rec.new, + Some(json!(format!( + "file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz" + ))) + ); + let lock_rec = &entry.wiring[1]; + assert_eq!((lock_rec.file.as_str(), lock_rec.kind.as_str()), (YARN_LOCK, KIND_LOCK_ENTRY)); + assert_eq!(lock_rec.action, WiringAction::Rewritten); + assert_eq!( + lock_rec.key.as_deref(), + Some(format!( + "\"left-pad@file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.\"" + ).as_str()) + ); + assert_eq!( + lock_rec.original.as_ref().unwrap(), + &json!([ + "\"left-pad@npm:1.3.0\":", + " version: 1.3.0", + " resolution: \"left-pad@npm:1.3.0\"", + " checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955", + " languageName: node", + " linkType: hard" + ]), + "original must be the verbatim pre-vendor entry" + ); + + // Artifact facts + marker. + let tgz = tokio::fs::read(fx.tgz_path()).await.unwrap(); + assert_eq!(entry.artifact.sha256, hex::encode(sha2::Sha256::digest(&tgz))); + assert_eq!(entry.artifact.size, Some(tgz.len() as u64)); + assert!(fx + .root() + .join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) + .exists()); + } + + #[tokio::test] + async fn non_10c0_cache_key_is_refused_before_any_write() { + let lock = B3_BEFORE_LOCK.replace("cacheKey: 10c0", "cacheKey: 10"); + let fx = fixture_with(B3_BEFORE_PKG, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_yarn_berry_cache_unsupported"); + assert!(detail.contains("`10`"), "names the found cacheKey: {detail}"); + fx.assert_untouched().await; + } + + #[tokio::test] + async fn checksum_changing_yarnrc_knob_is_refused_by_name() { + let fx = fixture().await; + tokio::fs::write( + fx.root().join(YARNRC), + "nodeLinker: node-modules\ncompressionLevel: mixed\n", + ) + .await + .unwrap(); + let detail = expect_refused(fx.vendor(false).await, "vendor_yarn_berry_cache_unsupported"); + assert!(detail.contains("compressionLevel"), "names the knob: {detail}"); + fx.assert_untouched().await; + + // An explicit `compressionLevel: 0` (the default) is fine. + tokio::fs::write( + fx.root().join(YARNRC), + "nodeLinker: node-modules\ncompressionLevel: 0\n", + ) + .await + .unwrap(); + let (result, _, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + } + + #[tokio::test] + async fn user_resolutions_entry_is_refused_never_overwritten() { + let pkg = B3_BEFORE_PKG.replace( + " }\n}", + " },\n \"resolutions\": {\n \"left-pad\": \"1.2.0\"\n }\n}", + ); + let fx = fixture_with(&pkg, B3_BEFORE_LOCK).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_override_conflict"); + assert!(detail.contains("left-pad"), "{detail}"); + assert!(!fx.root().join(".socket/vendor").exists()); + assert_eq!(tokio::fs::read(fx.pkg_path()).await.unwrap(), fx.pkg_bytes); + } + + #[tokio::test] + async fn missing_entry_and_other_version_guards() { + // No left-pad entry at all. + let lock = B3_BEFORE_LOCK.replace("left-pad@npm:1.3.0", "is-odd@npm:1.3.0"); + let fx = fixture_with(B3_BEFORE_PKG, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); + assert!(detail.contains("yarn install"), "{detail}"); + + // A SECOND version of the name in the lock: the name-keyed + // resolutions entry would move it too — refuse. + let lock = format!( + "{B3_BEFORE_LOCK}\n\"left-pad@npm:^1.2.0\":\n version: 1.2.0\n resolution: \"left-pad@npm:1.2.0\"\n checksum: 10c0/aa\n languageName: node\n linkType: hard\n" + ); + let fx = fixture_with(B3_BEFORE_PKG, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_override_conflict"); + assert!(detail.contains("1.2.0"), "names the other version: {detail}"); + fx.assert_untouched().await; + } + + #[tokio::test] + async fn rerun_is_in_sync_and_byte_stable() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + assert!(entry.is_some()); + let pkg_first = tokio::fs::read(fx.pkg_path()).await.unwrap(); + let lock_first = tokio::fs::read(fx.lock_path()).await.unwrap(); + let tgz_first = tokio::fs::read(fx.tgz_path()).await.unwrap(); + + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none(), "in-sync re-run must not produce a new ledger entry"); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!( + result + .files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), + "{:?}", + result.files_verified + ); + assert_eq!(tokio::fs::read(fx.pkg_path()).await.unwrap(), pkg_first); + assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), lock_first); + assert_eq!(tokio::fs::read(fx.tgz_path()).await.unwrap(), tgz_first); + } + + #[tokio::test] + async fn dry_run_writes_nothing() { + let fx = fixture().await; + let (result, entry, _) = expect_done(fx.vendor(true).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none()); + assert!(result.files_patched.is_empty()); + fx.assert_untouched().await; + assert_eq!( + tokio::fs::read(fx.installed().join("index.js")).await.unwrap(), + ORIG_INDEX, + "vendor never patches the installed copy in place" + ); + } + + #[tokio::test] + async fn dependency_submaps_are_carried_into_the_new_entry() { + // A target entry WITH a dependencies sub-map; the patch also rewrites + // package.json, which must surface the loud staleness advisory. + let lock = B3_BEFORE_LOCK.replace( + " resolution: \"left-pad@npm:1.3.0\"\n checksum:", + " resolution: \"left-pad@npm:1.3.0\"\n dependencies:\n wow: \"npm:^1.0.0\"\n checksum:", + ); + let mut fx = fixture_with(B3_BEFORE_PKG, &lock).await; + let before: &[u8] = br#"{"name":"left-pad","version":"1.3.0"}"#; + let after: &[u8] = br#"{"name":"left-pad","version":"1.3.0","description":"patched"}"#; + let after_hash = compute_git_sha256_from_bytes(after); + tokio::fs::write(fx.root().join(".socket/blobs").join(&after_hash), after) + .await + .unwrap(); + fx.record.files.insert( + "package/package.json".to_string(), + PatchFileInfo { before_hash: compute_git_sha256_from_bytes(before), after_hash }, + ); + + let (result, _, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!( + warnings.iter().any(|w| w.code == "vendor_dep_manifest_stale"), + "{warnings:?}" + ); + + let text = tokio::fs::read_to_string(fx.lock_path()).await.unwrap(); + let (_, checksum) = fx.packed_berry_facts().await; + assert!( + text.contains(&format!( + "&locator=vendor-spike%40workspace%3A.\"\n dependencies:\n wow: \"npm:^1.0.0\"\n checksum: {checksum}" + )), + "sub-map carried between resolution and checksum: {text}" + ); + } + + #[tokio::test] + async fn commit_pair_unwinds_package_json_when_the_lock_write_fails() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::write(root.join(PACKAGE_JSON), b"orig-pkg").await.unwrap(); + // A directory at the lock path makes the atomic rename fail. + tokio::fs::create_dir(root.join(YARN_LOCK)).await.unwrap(); + + let err = commit_pair(root, b"new-pkg", b"orig-pkg", b"new-lock") + .await + .unwrap_err(); + assert!(err.contains("restored"), "{err}"); + assert_eq!( + tokio::fs::read(root.join(PACKAGE_JSON)).await.unwrap(), + b"orig-pkg", + "package.json unwound to its original bytes" + ); + } + + #[tokio::test] + async fn revert_round_trips_both_files_and_removes_the_artifact() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + // Dry-run revert: nothing restored or removed. + let outcome = revert_yarn_berry(&entry, fx.root(), true).await; + assert!(outcome.success); + assert!(fx.tgz_path().exists()); + + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + tokio::fs::read(fx.pkg_path()).await.unwrap(), + fx.pkg_bytes, + "package.json restored byte-for-byte (empty resolutions table dropped)" + ); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "yarn.lock restored byte-for-byte" + ); + assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + } + + #[tokio::test] + async fn revert_leaves_drifted_fragments_alone_with_warnings() { + // Lock drift: the user re-resolved our entry back to the registry. + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let text = tokio::fs::read_to_string(fx.lock_path()).await.unwrap(); + // Replace the ENTIRE resolution line (any leftover vendor-path tail + // would still parse as ours and defeat the drift simulation). + let drifted: String = text + .lines() + .map(|l| { + if l.starts_with(" resolution: \"left-pad@file:") { + " resolution: \"left-pad@npm:1.3.0\"".to_string() + } else { + l.to_string() + } + }) + .collect::>() + .join("\n") + + "\n"; + assert_ne!(drifted, text, "the drift edit must hit"); + tokio::fs::write(fx.lock_path(), &drifted).await.unwrap(); + + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + "{:?}", + outcome.warnings + ); + // The drifted lock entry stays; the (still-ours) resolutions entry + // was removed; the artifact is gone. + let after = tokio::fs::read_to_string(fx.lock_path()).await.unwrap(); + assert!( + after.contains("left-pad@file:") + && after.contains(" resolution: \"left-pad@npm:1.3.0\""), + "drifted entry left alone: {after}" + ); + let pkg: Value = + serde_json::from_slice(&tokio::fs::read(fx.pkg_path()).await.unwrap()).unwrap(); + assert!(pkg.get("resolutions").is_none()); + assert!(!fx.tgz_path().exists()); + + // Manifest drift: the user repointed the resolutions entry. + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let pkg_text = tokio::fs::read_to_string(fx.pkg_path()).await.unwrap(); + tokio::fs::write( + fx.pkg_path(), + pkg_text.replace( + &format!("file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"), + "npm:1.3.1", + ), + ) + .await + .unwrap(); + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted" && w.detail.contains("resolutions")), + "{:?}", + outcome.warnings + ); + let pkg: Value = + serde_json::from_slice(&tokio::fs::read(fx.pkg_path()).await.unwrap()).unwrap(); + assert_eq!( + pkg["resolutions"]["left-pad"], + json!("npm:1.3.1"), + "user-repointed entry left alone" + ); + // The lock was still restored (independent fragment). + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes + ); + } + + #[tokio::test] + async fn revert_allowlist_fails_closed_on_foreign_files() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + for evil in ["../x", "Cargo.toml"] { + entry.wiring.push(WiringRecord { + file: evil.to_string(), + kind: KIND_LOCK_ENTRY.to_string(), + action: WiringAction::Rewritten, + key: Some("whatever".to_string()), + original: Some(json!(["pwned:"])), + new: Some(json!(["pwned:"])), + }); + } + + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + let allow = outcome + .warnings + .iter() + .filter(|w| w.detail.contains("allowlist")) + .count(); + assert_eq!(allow, 2, "every foreign file warned: {:?}", outcome.warnings); + // The legitimate records still reverted both files; the foreign + // paths were never created or touched. + assert_eq!(tokio::fs::read(fx.pkg_path()).await.unwrap(), fx.pkg_bytes); + assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + assert!(!fx.root().join("Cargo.toml").exists()); + assert!(!fx.root().parent().unwrap().join("x").exists()); + } + + #[tokio::test] + async fn revert_refuses_tampered_uuid_fail_closed() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.uuid = "../../escape".to_string(); + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(!outcome.success, "tampered uuid must fail closed"); + } + + #[test] + fn helper_grammar() { + // encodeURIComponent semantics, incl. a scoped workspace name. + assert_eq!( + encode_uri_component("vendor-spike@workspace:."), + "vendor-spike%40workspace%3A." + ); + assert_eq!( + encode_uri_component("@acme/root@workspace:."), + "%40acme%2Froot%40workspace%3A." + ); + + // Root workspace name extraction + berry field reads. + let blocks = scan_blocks(B3_BEFORE_LOCK); + assert_eq!(root_workspace_name(&blocks).as_deref(), Some("vendor-spike")); + let meta = blocks.iter().find(|b| b.key == "__metadata").unwrap(); + assert_eq!(berry_field(&meta.lines, "cacheKey"), Some("10c0")); + let lp = blocks.iter().find(|b| b.key == "\"left-pad@npm:1.3.0\"").unwrap(); + assert_eq!(berry_field(&lp.lines, "version"), Some("1.3.0")); + assert_eq!(berry_field(&lp.lines, "resolution"), Some("left-pad@npm:1.3.0")); + + // Carried sections: dep sub-maps survive, owned scalars do not. + let lines: Vec = [ + "\"left-pad@npm:1.3.0\":", + " version: 1.3.0", + " resolution: \"left-pad@npm:1.3.0\"", + " dependencies:", + " wow: \"npm:^1.0.0\"", + " checksum: 10c0/aa", + " languageName: node", + " linkType: hard", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!( + carried_sections(&lines), + vec![" dependencies:".to_string(), " wow: \"npm:^1.0.0\"".to_string()] + ); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs index 6c90a08..825c18d 100644 --- a/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs @@ -1 +1,1413 @@ -//! (stub — backend lands behind the npm_flavor / pypi router) +//! yarn classic (v1 lockfile) vendor backend: lock-only block surgery. +//! +//! Vendoring under yarn classic = pack the patched tree into the +//! deterministic tarball under `.socket/vendor/npm//` (shared npm +//! pipeline) and rewrite every matching `yarn.lock` block's +//! `resolved "file:./#"` + `integrity `. +//! `package.json` is untouched — the block's range keys still match. +//! Spike-proven (Y2/Y5/Y6 in `spikes/PHASE0-V2-FINDINGS.txt`): the rewrite +//! passes `--frozen-lockfile`, installs offline from a fresh checkout, and +//! round-trips yarn's own serializer byte-for-byte. +//! +//! Two spellings are LOAD-BEARING: +//! * `resolved` must keep a `file:./` (or `./`) prefix — a bare path is +//! treated as registry-relative and 404s against registry.yarnpkg.com; +//! * the `#` fragment carries the tgz sha1 and the `integrity` line +//! the tgz sha512 — yarn enforces BOTH on every install (even when the +//! integrity line was absent before, adding it turns the check on), so the +//! hashes are always the recomputed ones of OUR tarball, never inherited. +//! +//! The edit is line-oriented and splice-based: every byte outside the edited +//! blocks (comments, blank lines, other blocks, CRLF line endings) is +//! preserved verbatim, so yarn's re-serialization produces no churn. + +use std::collections::HashMap; +use std::path::Path; + +use serde_json::Value; + +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{ApplyResult, PatchSources, VerifyResult, VerifyStatus}; +use crate::patch::copy_tree::remove_tree; +use crate::utils::fs::atomic_write_bytes; + +use super::npm_common::{done_failure, guard_coordinates, refused, stage_patch_pack}; +use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; +use super::state::{ + write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, +}; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +const YARN_LOCK: &str = "yarn.lock"; + +/// The `WiringRecord.kind` this backend owns: one rewritten lock block, +/// `original`/`new` = verbatim block line arrays (key line included). +const KIND_LOCK_BLOCK: &str = "yarn_lock_block"; + +/// Vendor one installed npm package into a yarn-classic project. +/// +/// Same contract as [`super::npm_lock::vendor_npm`]: refuse-early, wire-last +/// (every refusal fires before any write inside the project; the lock edit is +/// the final mutation), `entry` is `None` for dry runs and the in-sync +/// re-run. +#[allow(clippy::too_many_arguments)] +pub async fn vendor_yarn_classic( + purl: &str, + installed_dir: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, +) -> VendorOutcome { + let mut warnings: Vec = Vec::new(); + + // ── 1. Coordinates (shared fail-closed guard, before any disk access) ─ + let coords = match guard_coordinates(purl, record) { + Ok(coords) => coords, + Err(outcome) => return *outcome, + }; + let (name, version) = (coords.name, coords.version); + let uuid_dir_rel = coords.uuid_dir_rel; + let base_purl = coords.base_purl; + + // ── 2. Lockfile ─────────────────────────────────────────────────────── + let lock_path = project_root.join(YARN_LOCK); + let text = match tokio::fs::read_to_string(&lock_path).await { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return refused( + "vendor_lockfile_missing", + format!( + "no {YARN_LOCK} at {} — vendoring rewires the lockfile, so one must \ + exist (run `yarn install` first)", + project_root.display() + ), + ); + } + Err(e) => { + return refused( + "vendor_lockfile_missing", + format!("cannot read {YARN_LOCK}: {e}"), + ); + } + }; + // Defensive re-sniff: the flavor router already separates classic from + // berry, but rewriting a berry lock with classic grammar would corrupt + // it — never proceed past a `__metadata:` key. + if text.lines().any(|l| l.starts_with("__metadata:")) { + return refused( + "vendor_lockfile_version_unsupported", + "yarn.lock is a yarn berry (v2+) lockfile (top-level `__metadata:` key); the \ + yarn-classic backend cannot rewrite it" + .to_string(), + ); + } + + // ── 3. Find the rewritable blocks (pre-flight, BEFORE staging) ──────── + let mut candidate_keys: Vec = Vec::new(); + for block in scan_blocks(&text) { + match classify_classic_block(&block, name, version) { + BlockClass::Candidate => candidate_keys.push(block.key.clone()), + BlockClass::LinkSkip(detail) => { + warnings.push(VendorWarning::new("vendor_link_entry_skipped", detail)); + } + BlockClass::NoMatch => {} + } + } + if candidate_keys.is_empty() { + return refused( + "vendor_lock_entry_not_found", + format!( + "{YARN_LOCK} has no rewritable block for {name}@{version} — make sure the \ + package is installed and locked (`yarn install`) before vendoring" + ), + ); + } + + // ── 4–7. Stage → patch → pack (shared flavor-agnostic pipeline) ─────── + let (staged, result) = match stage_patch_pack( + purl, + installed_dir, + project_root, + record, + sources, + dry_run, + force, + ) + .await + { + Ok(pair) => pair, + Err(outcome) => return *outcome, + }; + let Some(staged) = staged else { + // Failed patch (no lock writes — wiring is last) or a dry run. + return VendorOutcome::Done { result, entry: None, warnings }; + }; + let rel_tgz = staged.rel_tgz; + let packed = staged.packed; + let staged_pkg_json = staged.staged_pkg_json; + let dest = project_root.join(&rel_tgz); + // SECURITY/CORRECTNESS: the `file:./` prefix is load-bearing — a bare + // path is registry-relative to yarn classic (spike Y2: 404). + let resolved_value = format!("file:./{rel_tgz}#{}", packed.sha1_hex); + + // ── 8. Lock rewrite: splice each candidate block, byte-preserving ───── + let eol = detect_eol(&text); + let mut new_text = text.clone(); + let mut wiring: Vec = Vec::new(); + let mut recomputed_deps = false; + for key in &candidate_keys { + let edit = { + let blocks = scan_blocks(&new_text); + let Some(block) = blocks.iter().find(|b| &b.key == key) else { + return done_failure(purl, format!("lock block `{key}` vanished mid-rewrite")); + }; + let new_lines = rewrite_classic_block( + &block.lines, + &resolved_value, + &packed.integrity, + staged_pkg_json.as_ref(), + ); + if new_lines == block.lines { + // Idempotency: already carrying our exact spec — no edit, no + // wiring record. + None + } else { + // Never record one of our own (stale) edits as the + // "original" — revert must restore the pre-vendor registry + // fragment, not a dangling `.socket/vendor/` pointer. + let was_vendored = block_points_into_vendor(&block.lines); + let rec = WiringRecord { + file: YARN_LOCK.to_string(), + kind: KIND_LOCK_BLOCK.to_string(), + action: WiringAction::Rewritten, + key: Some(key.clone()), + original: if was_vendored { None } else { Some(lines_to_json(&block.lines)) }, + new: Some(lines_to_json(&new_lines)), + }; + Some((replace_block(&new_text, block, &new_lines, eol), rec)) + } + }; + if let Some((replaced, rec)) = edit { + new_text = replaced; + wiring.push(rec); + if staged_pkg_json.is_some() { + recomputed_deps = true; + } + } + } + if recomputed_deps { + warnings.push(VendorWarning::new( + "vendor_dep_manifest_rewritten", + format!( + "the patch rewrites {name}@{version}'s package.json; its lock blocks' \ + dependencies/optionalDependencies sub-maps were recomputed from the patched \ + manifest" + ), + )); + } + + if wiring.is_empty() { + // Every block already points at this uuid with the packed hashes: + // in sync. Touch nothing (the tarball re-pack above was + // byte-identical by determinism) and synthesize AlreadyPatched. + let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + return VendorOutcome::Done { + result: synthesized_result(purl, &dest, verified, true, None), + entry: None, + warnings, + }; + } + + if let Err(e) = atomic_write_bytes(&lock_path, new_text.as_bytes()).await { + return done_failure(purl, format!("cannot write {YARN_LOCK}: {e}")); + } + + // ── 9. Marker + ledger entry ────────────────────────────────────────── + let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); + vulnerabilities.sort(); + let marker = VendorMarker { + schema_version: 1, + purl: base_purl.clone(), + patch_uuid: record.uuid.clone(), + ecosystem: "npm".to_string(), + vulnerabilities, + vendored_at: vendored_at.to_string(), + }; + if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { + warnings.push(VendorWarning::new( + "vendor_marker_write_failed", + format!("could not write the informational vendor marker: {e}"), + )); + } + + let entry = VendorEntry { + ecosystem: "npm".to_string(), + base_purl, + uuid: record.uuid.clone(), + artifact: VendorArtifact { + path: rel_tgz, + sha256: packed.sha256_hex, + size: Some(packed.size), + platform_locked: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + flavor: Some("yarn-classic".to_string()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + }; + VendorOutcome::Done { result, entry: Some(entry), warnings } +} + +/// Undo one yarn-classic vendored package: restore the recorded lock blocks +/// and remove the artifact dir. +pub async fn revert_yarn_classic( + entry: &VendorEntry, + project_root: &Path, + dry_run: bool, +) -> RevertOutcome { + // SECURITY: `entry.uuid` comes from the committed, tamper-able + // state.json and names the directory tree we are about to DELETE — + // validate fail-closed before any disk access. + let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &entry.uuid) else { + return RevertOutcome::failed(format!( + "refusing revert: `{}` is not a canonical patch uuid (tampered state.json?)", + entry.uuid + )); + }; + if dry_run { + return RevertOutcome::ok(); + } + + let mut outcome = RevertOutcome::ok(); + + // SECURITY: per-flavor FILE ALLOWLIST — this backend only ever wrote + // yarn.lock, so a poisoned state.json must not be able to point the + // restore at any other project file. Violations are skipped fail-closed + // with a warning, before any read or write of the named path. + let mut records: Vec<&WiringRecord> = Vec::new(); + for rec in entry.wiring.iter().rev() { + if rec.file == YARN_LOCK { + records.push(rec); + } else { + outcome.warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "ignoring wiring record for file `{}` outside the yarn-classic \ + allowlist [\"{YARN_LOCK}\"]", + rec.file + ), + )); + } + } + + let lock_path = project_root.join(YARN_LOCK); + let text = match tokio::fs::read_to_string(&lock_path).await { + Ok(t) => Some(t), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + outcome.warnings.push(VendorWarning::new( + "vendor_lockfile_missing", + format!("{YARN_LOCK} is missing; lock blocks cannot be restored"), + )); + None + } + Err(e) => return RevertOutcome::failed(format!("cannot read {YARN_LOCK}: {e}")), + }; + + if let Some(mut text) = text { + let mut changed = false; + for rec in records { + revert_one_block(&mut text, rec, &entry.uuid, &mut changed, &mut outcome.warnings); + } + if changed { + if let Err(e) = atomic_write_bytes(&lock_path, text.as_bytes()).await { + return RevertOutcome::failed(format!("cannot write {YARN_LOCK}: {e}")); + } + } + } + + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + } + + outcome +} + +/// Apply one wiring record in reverse: restore `original` iff the live block +/// is still ours (drift = a third party re-resolved it; leave theirs alone, +/// with a warning). +fn revert_one_block( + text: &mut String, + rec: &WiringRecord, + entry_uuid: &str, + changed: &mut bool, + warnings: &mut Vec, +) { + let Some(key) = rec.key.as_deref() else { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("wiring record in {} has no key; left alone", rec.file), + )); + return; + }; + if rec.kind != KIND_LOCK_BLOCK { + // Forward compatibility: an unknown kind from a newer binary + // degrades to a warning (see state.rs schema docs). + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("unknown wiring kind `{}` for `{key}`; left alone", rec.kind), + )); + return; + } + let edit = { + let blocks = scan_blocks(text); + let Some(block) = blocks.iter().find(|b| b.key == key) else { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("lock block `{key}` no longer exists; nothing to restore"), + )); + return; + }; + // Ownership gate: the live block's resolved must still point into + // OUR uuid dir — anything else means a third party re-resolved it. + let ours = classic_field(&block.lines, "resolved") + .and_then(parse_vendor_path) + .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if !ours { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("lock block `{key}` was re-resolved since vendoring; left alone"), + )); + return; + } + let Some(original) = rec.original.as_ref().and_then(json_to_lines) else { + // The record rewrote one of our own earlier edits, so there is + // no pre-vendor fragment to restore (by design). Surface it + // instead of guessing a registry URL. + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "lock block `{key}` has no recorded pre-vendor original; left as-is \ + (re-run `yarn install` to re-resolve it from the registry)" + ), + )); + return; + }; + replace_block(text, block, &original, detect_eol(text)) + }; + *text = edit; + *changed = true; +} + +// ─────────────────────────── block classification ─────────────────────────── + +#[derive(Debug)] +enum BlockClass { + /// Rewritable instance of the target package. + Candidate, + /// Matches the target but cannot be rewired; carries the warning detail. + LinkSkip(String), + NoMatch, +} + +/// Does this block stand for `name@version`, and can it be rewired? +fn classify_classic_block(block: &LockBlock, name: &str, version: &str) -> BlockClass { + let patterns = split_key_patterns(&block.key); + if patterns.is_empty() { + return BlockClass::NoMatch; + } + // Every key pattern must resolve to the target package's real name (an + // `alias@npm:left-pad@^1.3.0` pattern carries the real name inside the + // range — spike Y5's alias block). + if !patterns.iter().all(|p| pattern_real_name(p) == Some(name)) { + return BlockClass::NoMatch; + } + if classic_field(&block.lines, "version") != Some(version) { + return BlockClass::NoMatch; + } + // link: and file:-DIRECTORY ranges resolve from the working tree, not a + // tarball — rewriting their resolved would not change what installs. + for pattern in &patterns { + let range = split_pattern(pattern).map(|(_, r)| r).unwrap_or(""); + if range.starts_with("link:") { + return BlockClass::LinkSkip(format!( + "lock block `{}` is a link: dependency; skipped", + block.key + )); + } + if let Some(path) = range.strip_prefix("file:") { + if !is_tarball_path(path) { + return BlockClass::LinkSkip(format!( + "lock block `{}` is a file: directory dependency; skipped", + block.key + )); + } + } + } + if classic_field(&block.lines, "resolved").is_none() { + return BlockClass::LinkSkip(format!( + "lock block `{}` has no resolved tarball; skipped", + block.key + )); + } + BlockClass::Candidate +} + +/// Rebuild a block's lines with the vendored `resolved`/`integrity` (adding +/// the integrity line when absent — yarn then enforces both hashes) and, +/// when the patch rewrote the package's own manifest, the recomputed +/// dependency sub-maps. +fn rewrite_classic_block( + lines: &[String], + resolved_value: &str, + integrity_value: &str, + staged_pkg: Option<&Value>, +) -> Vec { + let has_integrity = lines + .iter() + .skip(1) + .any(|l| body_field_line(l).is_some_and(|r| r.starts_with("integrity "))); + let mut out = vec![lines[0].clone()]; + let mut i = 1; + while i < lines.len() { + let line = &lines[i]; + if let Some(rest) = body_field_line(line) { + if rest.starts_with("resolved ") { + out.push(format!(" resolved \"{resolved_value}\"")); + if !has_integrity { + // yarn's field order: version, resolved, integrity, deps. + out.push(format!(" integrity {integrity_value}")); + } + i += 1; + continue; + } + if rest.starts_with("integrity ") { + out.push(format!(" integrity {integrity_value}")); + i += 1; + continue; + } + if staged_pkg.is_some() && (rest == "dependencies:" || rest == "optionalDependencies:") + { + // Drop the stale sub-map (header + 4-space entries); the + // recomputed ones are appended below in yarn's order. + i += 1; + while i < lines.len() && body_field_line(&lines[i]).is_none() { + i += 1; + } + continue; + } + } + out.push(line.clone()); + i += 1; + } + if let Some(pkg) = staged_pkg { + for field in ["dependencies", "optionalDependencies"] { + let Some(map) = pkg.get(field).and_then(Value::as_object) else { continue }; + if map.is_empty() { + continue; + } + out.push(format!(" {field}:")); + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort_unstable(); + for k in keys { + if let Some(range) = map.get(k).and_then(Value::as_str) { + out.push(format!(" {} \"{range}\"", quote_yarn_key(k))); + } + } + } + } + out +} + +/// Does this block's `resolved` already point into `.socket/vendor/npm/` +/// (ours — current or stale uuid)? +fn block_points_into_vendor(lines: &[String]) -> bool { + classic_field(lines, "resolved") + .and_then(parse_vendor_path) + .is_some_and(|p| p.eco == "npm") +} + +/// `file:` path → tarball or directory? Directories cannot be rewired. +fn is_tarball_path(path: &str) -> bool { + let path = path.split('#').next().unwrap_or(path).trim_end_matches('/'); + path.ends_with(".tgz") || path.ends_with(".tar.gz") +} + +// ─────────────────── shared yarn-lock text helpers ─────────────────── +// (pub(super): the berry backend reuses the same block grammar — key line at +// column 0 ending `:`, indented body, blank-line separated) + +/// One key-line block of a yarn lockfile (classic or berry). +#[derive(Debug)] +pub(super) struct LockBlock { + /// Byte offset of the key line's first byte. + pub start: usize, + /// Byte offset one past the last body line (incl. its terminator). + pub end: usize, + /// Whether the final line carried a terminator (false only at EOF). + pub terminated: bool, + /// Key line text without the trailing `:` (quotes kept verbatim). + pub key: String, + /// Verbatim block lines (key line first), without line terminators. + pub lines: Vec, +} + +/// Scan a lockfile into blocks, CRLF-aware. Comments, blank lines, and +/// anything else outside blocks are left to the splicer untouched. +pub(super) fn scan_blocks(text: &str) -> Vec { + // (start, end-incl-terminator, content-without-terminator, terminated) + let mut lines: Vec<(usize, usize, &str, bool)> = Vec::new(); + let mut pos = 0; + for seg in text.split_inclusive('\n') { + let start = pos; + pos += seg.len(); + let terminated = seg.ends_with('\n'); + let mut content = seg; + if terminated { + content = &content[..content.len() - 1]; + } + let content = content.strip_suffix('\r').unwrap_or(content); + lines.push((start, pos, content, terminated)); + } + let mut blocks = Vec::new(); + let mut i = 0; + while i < lines.len() { + let (start, _, content, _) = lines[i]; + if is_key_line(content) { + let mut j = i + 1; + while j < lines.len() && is_body_line(lines[j].2) { + j += 1; + } + blocks.push(LockBlock { + start, + end: lines[j - 1].1, + terminated: lines[j - 1].3, + key: content[..content.len() - 1].to_string(), + lines: lines[i..j].iter().map(|l| l.2.to_string()).collect(), + }); + i = j; + } else { + i += 1; + } + } + blocks +} + +fn is_key_line(s: &str) -> bool { + !s.is_empty() && !s.starts_with([' ', '\t', '#']) && s.ends_with(':') +} + +fn is_body_line(s: &str) -> bool { + s.starts_with(' ') || s.starts_with('\t') +} + +/// The file's dominant line terminator (new lines we write use it; bytes +/// outside edited blocks keep whatever they had). +pub(super) fn detect_eol(text: &str) -> &'static str { + if text.contains("\r\n") { + "\r\n" + } else { + "\n" + } +} + +/// Splice `new_lines` over `block`'s byte range, preserving every byte +/// outside it. +pub(super) fn replace_block( + text: &str, + block: &LockBlock, + new_lines: &[String], + eol: &str, +) -> String { + let mut replacement = new_lines.join(eol); + if block.terminated { + replacement.push_str(eol); + } + format!("{}{}{}", &text[..block.start], replacement, &text[block.end..]) +} + +/// A 2-space body field line (`version "1.3.0"` / `resolution: "..."`), +/// returned without the indent; deeper sub-map lines return `None`. +pub(super) fn body_field_line(line: &str) -> Option<&str> { + let rest = line.strip_prefix(" ")?; + if rest.starts_with(' ') { + return None; + } + Some(rest) +} + +/// Read a classic scalar field (` ""`, integrity unquoted). +pub(super) fn classic_field<'a>(lines: &'a [String], field: &str) -> Option<&'a str> { + for line in lines.iter().skip(1) { + let Some(rest) = body_field_line(line) else { continue }; + let Some(value) = rest.strip_prefix(field) else { continue }; + let Some(value) = value.strip_prefix(' ') else { continue }; + return Some(value.trim().trim_matches('"')); + } + None +} + +/// Split a comma-joined key into its patterns, honoring quoting; the +/// surrounding quotes are dropped from each pattern. +pub(super) fn split_key_patterns(key: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_quotes = false; + for ch in key.chars() { + match ch { + '"' => in_quotes = !in_quotes, + ',' if !in_quotes => { + let p = cur.trim(); + if !p.is_empty() { + out.push(p.to_string()); + } + cur.clear(); + } + _ => cur.push(ch), + } + } + let p = cur.trim(); + if !p.is_empty() { + out.push(p.to_string()); + } + out +} + +/// Split `name@range` at the first `@` past a leading `@scope/` marker. +pub(super) fn split_pattern(pattern: &str) -> Option<(&str, &str)> { + let from = usize::from(pattern.starts_with('@')); + let at = pattern[from..].find('@')? + from; + let (name, range) = (&pattern[..at], &pattern[at + 1..]); + if name.is_empty() || range.is_empty() { + return None; + } + Some((name, range)) +} + +/// The real package a key pattern stands for: its name, unless the range is +/// an `npm:` alias — then the aliased target's name. +pub(super) fn pattern_real_name(pattern: &str) -> Option<&str> { + let (name, range) = split_pattern(pattern)?; + if let Some(aliased) = range.strip_prefix("npm:") { + return match split_pattern(aliased) { + Some((real, _)) => Some(real), + None => Some(aliased), // `npm:left-pad` with no range + }; + } + Some(name) +} + +/// yarn v1's lockfile key quoting (stringify.js `shouldWrapKey`): wrap when +/// the key would not parse bare. +pub(super) fn quote_yarn_key(key: &str) -> String { + let needs = key.is_empty() + || key.starts_with("true") + || key.starts_with("false") + || !key.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + || key + .chars() + .any(|c| matches!(c, ':' | ' ' | '\n' | '\t' | '\\' | '"' | ',' | '[' | ']')); + if needs { + format!("\"{key}\"") + } else { + key.to_string() + } +} + +pub(super) fn lines_to_json(lines: &[String]) -> Value { + Value::Array(lines.iter().map(|l| Value::String(l.clone())).collect()) +} + +pub(super) fn json_to_lines(value: &Value) -> Option> { + value + .as_array()? + .iter() + .map(|v| v.as_str().map(str::to_string)) + .collect() +} + +// ─────────────── shared synthesized-result helpers ─────────────── +// (mirrors npm_lock's private helpers; pub(super) so the berry backend can +// synthesize the same in-sync AlreadyPatched shape) + +pub(super) fn synthesized_result( + package_key: &str, + path: &Path, + files_verified: Vec, + success: bool, + error: Option, +) -> ApplyResult { + ApplyResult { + package_key: package_key.to_string(), + package_path: path.display().to_string(), + success, + files_verified, + files_patched: Vec::new(), + applied_via: HashMap::new(), + error, + sidecar: None, + } +} + +pub(super) fn already_patched_verify(file: &str) -> VerifyResult { + VerifyResult { + file: file.to_string(), + status: VerifyStatus::AlreadyPatched, + message: None, + current_hash: None, + expected_hash: None, + target_hash: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; + use base64::Engine as _; + use serde_json::json; + use sha1::Digest as _; + use std::path::PathBuf; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; + const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + + /// The hash constants of the SPIKE's tarball inside the after-lock + /// fixtures; the tests substitute the recomputed hashes of the tarball + /// this build packs (everything else must match byte-for-byte). + const SPIKE_SHA1: &str = "fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6"; + const SPIKE_SRI: &str = + "sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw=="; + + /// Verbatim `spikes/yarn-classic/y2-lock-rewrite/before/yarn.lock` + /// (yarn 1.22.22-generated). + const Y2_BEFORE: &str = r#"# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +left-pad@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== +"#; + + /// Verbatim `spikes/yarn-classic/y2-lock-rewrite/after/yarn.lock` — yarn + /// itself round-tripped this byte-for-byte (spike Y2's re-serialization + /// oracle), so it IS yarn's own output shape. + const Y2_AFTER: &str = r#"# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +left-pad@^1.3.0: + version "1.3.0" + resolved "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" + integrity sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw== +"#; + + /// Verbatim `spikes/yarn-classic/y5-merged-alias/before/yarn.lock`: + /// a merged two-pattern block, a separate alias block, and a folder dep. + const Y5_BEFORE: &str = r#"# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"alias@npm:left-pad@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== + +"dep-a@file:./dep-a": + version "1.0.0" + dependencies: + left-pad "~1.3.0" + +left-pad@^1.3.0, left-pad@~1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== +"#; + + /// Verbatim `spikes/yarn-classic/y5-merged-alias/after/yarn.lock`. + const Y5_AFTER: &str = r#"# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"alias@npm:left-pad@^1.3.0": + version "1.3.0" + resolved "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" + integrity sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw== + +"dep-a@file:./dep-a": + version "1.0.0" + dependencies: + left-pad "~1.3.0" + +left-pad@^1.3.0, left-pad@~1.3.0: + version "1.3.0" + resolved "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" + integrity sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw== +"#; + + /// Substitute the spike tarball's hashes with this build's recomputed + /// ones (the only legal difference vs the fixture). + fn spike_after(template: &str, sha1: &str, sri: &str) -> String { + template.replace(SPIKE_SHA1, sha1).replace(SPIKE_SRI, sri) + } + + struct Fixture { + tmp: tempfile::TempDir, + record: PatchRecord, + lock_bytes: Vec, + } + + impl Fixture { + fn root(&self) -> &Path { + self.tmp.path() + } + + fn installed(&self) -> PathBuf { + self.root().join("node_modules/left-pad") + } + + fn lock_path(&self) -> PathBuf { + self.root().join(YARN_LOCK) + } + + fn tgz_path(&self) -> PathBuf { + self.root() + .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")) + } + + async fn lock_text(&self) -> String { + tokio::fs::read_to_string(self.lock_path()).await.unwrap() + } + + /// (sha1 hex, sha512 SRI) of the packed tarball on disk. + async fn packed_hashes(&self) -> (String, String) { + let tgz = tokio::fs::read(self.tgz_path()).await.unwrap(); + let sha1 = hex::encode(sha1::Sha1::digest(&tgz)); + let sri = format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(sha2::Sha512::digest(&tgz)) + ); + (sha1, sri) + } + + async fn vendor(&self, dry_run: bool) -> VendorOutcome { + let blobs = self.root().join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + vendor_yarn_classic( + "pkg:npm/left-pad@1.3.0", + &self.installed(), + self.root(), + &self.record, + &sources, + "2026-06-09T00:00:00Z", + dry_run, + false, + ) + .await + } + } + + /// Build a project tempdir: installed left-pad, patched blob, the given + /// yarn.lock bytes, and the PatchRecord. + async fn fixture_with_lock(lock_text: &str) -> Fixture { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + let installed = root.join("node_modules/left-pad"); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write( + installed.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .await + .unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX).await.unwrap(); + + let blobs = root.join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX).await.unwrap(); + + tokio::fs::write(root.join(YARN_LOCK), lock_text.as_bytes()).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(ORIG_INDEX), + after_hash, + }, + ); + let record = PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-06-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: "test patch".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }; + + Fixture { tmp, record, lock_bytes: lock_text.as_bytes().to_vec() } + } + + fn expect_done( + outcome: VendorOutcome, + ) -> (ApplyResult, Option, Vec) { + match outcome { + VendorOutcome::Done { result, entry, warnings } => (result, entry, warnings), + VendorOutcome::Refused { code, detail } => { + panic!("expected Done, got Refused {code}: {detail}") + } + } + } + + fn expect_refused(outcome: VendorOutcome, want_code: &str) -> String { + match outcome { + VendorOutcome::Refused { code, detail } => { + assert_eq!(code, want_code, "wrong refusal code ({detail})"); + detail + } + VendorOutcome::Done { result, .. } => { + panic!("expected Refused {want_code}, got Done (success={})", result.success) + } + } + } + + #[tokio::test] + async fn y2_fixture_oracle_rewrite_is_byte_exact() { + let fx = fixture_with_lock(Y2_BEFORE).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(warnings.is_empty(), "{warnings:?}"); + let entry = entry.expect("success carries a ledger entry"); + + // Byte-for-byte the spike's after-lock, modulo the recomputed hashes. + let (sha1, sri) = fx.packed_hashes().await; + assert_eq!(fx.lock_text().await, spike_after(Y2_AFTER, &sha1, &sri)); + + // Ledger shape: flavor, artifact facts, one Rewritten block record + // with verbatim line arrays. + assert_eq!(entry.flavor.as_deref(), Some("yarn-classic")); + assert_eq!( + entry.artifact.path, + format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") + ); + let tgz = tokio::fs::read(fx.tgz_path()).await.unwrap(); + assert_eq!(entry.artifact.size, Some(tgz.len() as u64)); + assert_eq!(entry.artifact.sha256, hex::encode(sha2::Sha256::digest(&tgz))); + assert_eq!(entry.wiring.len(), 1); + let rec = &entry.wiring[0]; + assert_eq!(rec.file, YARN_LOCK); + assert_eq!(rec.kind, KIND_LOCK_BLOCK); + assert_eq!(rec.action, WiringAction::Rewritten); + assert_eq!(rec.key.as_deref(), Some("left-pad@^1.3.0")); + assert_eq!( + rec.original.as_ref().unwrap(), + &json!([ + "left-pad@^1.3.0:", + " version \"1.3.0\"", + " resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e\"", + " integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==" + ]), + "original must be the verbatim pre-vendor block" + ); + let new_lines = rec.new.as_ref().unwrap().as_array().unwrap(); + assert!(new_lines[2].as_str().unwrap().contains("file:./.socket/vendor/npm/")); + + // The marker sits next to the artifact. + let marker = tokio::fs::read_to_string( + fx.root().join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")), + ) + .await + .unwrap(); + assert!(marker.contains("pkg:npm/left-pad@1.3.0")); + } + + #[tokio::test] + async fn y5_merged_keys_and_alias_block_both_rewritten() { + let fx = fixture_with_lock(Y5_BEFORE).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + // The folder dep `dep-a@file:./dep-a` is name-mismatched, not a + // candidate — no skip warning either. + assert!(warnings.is_empty(), "{warnings:?}"); + let entry = entry.unwrap(); + + let (sha1, sri) = fx.packed_hashes().await; + assert_eq!(fx.lock_text().await, spike_after(Y5_AFTER, &sha1, &sri)); + + // One record per block: the alias block AND the merged block. + let mut keys: Vec<&str> = + entry.wiring.iter().map(|r| r.key.as_deref().unwrap()).collect(); + keys.sort_unstable(); + assert_eq!( + keys, + vec!["\"alias@npm:left-pad@^1.3.0\"", "left-pad@^1.3.0, left-pad@~1.3.0"], + "verbatim key lines (no colon), quotes preserved" + ); + } + + #[tokio::test] + async fn missing_integrity_line_is_added_after_resolved() { + // A y1-shaped entry (native file: deps get no integrity from yarn); + // the rewrite must ADD the line so both hash checks are enforced. + let lock = r#"# yarn lockfile v1 + +left-pad@^1.3.0: + version "1.3.0" + resolved "file:./elsewhere/left-pad-1.3.0.tgz#0123456789abcdef0123456789abcdef01234567" +"#; + let fx = fixture_with_lock(lock).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + + let (sha1, sri) = fx.packed_hashes().await; + let text = fx.lock_text().await; + let lines: Vec<&str> = text.lines().collect(); + assert_eq!( + lines[4], + format!( + " resolved \"file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz#{sha1}\"" + ) + ); + assert_eq!(lines[5], format!(" integrity {sri}"), "integrity line gained"); + + // The record's original is the 3-line block, new is the 4-line one. + let rec = &entry.unwrap().wiring[0]; + assert_eq!(rec.original.as_ref().unwrap().as_array().unwrap().len(), 3); + assert_eq!(rec.new.as_ref().unwrap().as_array().unwrap().len(), 4); + } + + #[tokio::test] + async fn patched_package_json_recomputes_dep_submaps() { + let lock = r#"# yarn lockfile v1 + +left-pad@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== + dependencies: + old-dep "^1.0.0" +"#; + let mut fx = fixture_with_lock(lock).await; + + // The patch rewrites package.json: new dependency + an optional one. + let before: &[u8] = br#"{"name":"left-pad","version":"1.3.0"}"#; + let after: &[u8] = br#"{"name":"left-pad","version":"1.3.0","dependencies":{"wow":"^1.0.0"},"optionalDependencies":{"@scope/opt":"^2.0.0"}}"#; + let after_hash = compute_git_sha256_from_bytes(after); + tokio::fs::write(fx.root().join(".socket/blobs").join(&after_hash), after) + .await + .unwrap(); + fx.record.files.insert( + "package/package.json".to_string(), + PatchFileInfo { before_hash: compute_git_sha256_from_bytes(before), after_hash }, + ); + + let (result, _, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!( + warnings.iter().any(|w| w.code == "vendor_dep_manifest_rewritten"), + "{warnings:?}" + ); + + let text = fx.lock_text().await; + assert!(!text.contains("old-dep"), "stale sub-map dropped: {text}"); + let want = " dependencies:\n wow \"^1.0.0\"\n optionalDependencies:\n \"@scope/opt\" \"^2.0.0\"\n"; + assert!(text.contains(want), "recomputed sub-maps (scoped key quoted): {text}"); + } + + #[tokio::test] + async fn rerun_is_in_sync_and_byte_stable() { + let fx = fixture_with_lock(Y2_BEFORE).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + assert!(entry.is_some()); + let lock_after_first = tokio::fs::read(fx.lock_path()).await.unwrap(); + let tgz_first = tokio::fs::read(fx.tgz_path()).await.unwrap(); + + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success); + assert!(entry.is_none(), "in-sync re-run must not produce a new ledger entry"); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!( + result + .files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), + "{:?}", + result.files_verified + ); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_after_first, + "lock byte-stable across re-runs" + ); + assert_eq!( + tokio::fs::read(fx.tgz_path()).await.unwrap(), + tgz_first, + "tarball byte-identical across re-runs" + ); + } + + #[tokio::test] + async fn dry_run_writes_nothing() { + let fx = fixture_with_lock(Y2_BEFORE).await; + let (result, entry, _) = expect_done(fx.vendor(true).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none()); + assert!(result.files_patched.is_empty()); + + assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + assert!(!fx.root().join(".socket/vendor").exists()); + assert_eq!( + tokio::fs::read(fx.installed().join("index.js")).await.unwrap(), + ORIG_INDEX, + "vendor never patches the installed copy in place" + ); + } + + #[tokio::test] + async fn link_and_file_directory_blocks_are_skipped_with_warnings() { + let extra = r#" +"left-pad@link:../somewhere": + version "1.3.0" + +"left-pad@file:./local-left-pad": + version "1.3.0" +"#; + let lock = format!("{Y2_BEFORE}{extra}"); + let fx = fixture_with_lock(&lock).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert_eq!(entry.unwrap().wiring.len(), 1, "only the registry block rewritten"); + + let link_warnings: Vec<&VendorWarning> = + warnings.iter().filter(|w| w.code == "vendor_link_entry_skipped").collect(); + assert_eq!(link_warnings.len(), 2, "{warnings:?}"); + + // Skipped blocks byte-untouched. + let text = fx.lock_text().await; + assert!(text.contains("\"left-pad@link:../somewhere\":\n version \"1.3.0\"")); + assert!(text.contains("\"left-pad@file:./local-left-pad\":\n version \"1.3.0\"")); + } + + #[tokio::test] + async fn no_matching_block_is_refused_before_any_write() { + // The lock only knows a different version. + let lock = Y2_BEFORE.replace("1.3.0", "1.2.0"); + let fx = fixture_with_lock(&lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); + assert!(detail.contains("yarn install"), "actionable detail: {detail}"); + assert!(!fx.root().join(".socket/vendor").exists(), "refusal writes nothing"); + assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + } + + #[tokio::test] + async fn berry_lock_and_missing_lock_are_refused() { + let fx = fixture_with_lock("__metadata:\n version: 8\n cacheKey: 10c0\n").await; + expect_refused(fx.vendor(false).await, "vendor_lockfile_version_unsupported"); + + let fx = fixture_with_lock(Y2_BEFORE).await; + tokio::fs::remove_file(fx.lock_path()).await.unwrap(); + let detail = expect_refused(fx.vendor(false).await, "vendor_lockfile_missing"); + assert!(detail.contains("yarn install"), "{detail}"); + } + + #[tokio::test] + async fn revert_round_trips_the_lock_and_removes_the_artifact() { + let fx = fixture_with_lock(Y5_BEFORE).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + assert!(fx.tgz_path().exists()); + + // Dry-run revert: success, nothing restored or removed. + let outcome = revert_yarn_classic(&entry, fx.root(), true).await; + assert!(outcome.success); + assert!(fx.tgz_path().exists()); + assert_ne!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "lock restored byte-for-byte" + ); + assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + } + + #[tokio::test] + async fn revert_leaves_drifted_blocks_alone_with_warning() { + let fx = fixture_with_lock(Y5_BEFORE).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + // The user re-resolved the ALIAS block (first occurrence of our + // resolved line) behind our back. + let (sha1, _) = fx.packed_hashes().await; + let ours = format!( + " resolved \"file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz#{sha1}\"" + ); + let theirs = " resolved \"https://example.com/their-fork.tgz#0000000000000000000000000000000000000000\""; + let text = fx.lock_text().await.replacen(&ours, theirs, 1); + tokio::fs::write(fx.lock_path(), text).await.unwrap(); + + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + "{:?}", + outcome.warnings + ); + + let after = fx.lock_text().await; + assert!(after.contains("their-fork.tgz"), "drifted block left alone"); + assert!( + after.contains("left-pad@^1.3.0, left-pad@~1.3.0:\n version \"1.3.0\"\n resolved \"https://registry.yarnpkg.com/"), + "non-drifted block restored: {after}" + ); + assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + } + + #[tokio::test] + async fn revert_allowlist_fails_closed_on_foreign_files() { + let fx = fixture_with_lock(Y2_BEFORE).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + // A poisoned ledger names files outside the yarn.lock allowlist. + for evil in ["../x", "package.json"] { + entry.wiring.push(WiringRecord { + file: evil.to_string(), + kind: KIND_LOCK_BLOCK.to_string(), + action: WiringAction::Rewritten, + key: Some("whatever".to_string()), + original: Some(json!(["pwned:"])), + new: Some(json!(["pwned:"])), + }); + } + + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + let allow = outcome + .warnings + .iter() + .filter(|w| w.detail.contains("allowlist")) + .count(); + assert_eq!(allow, 2, "every foreign file warned: {:?}", outcome.warnings); + // The legitimate record still restored the lock; nothing was written + // to (or read from) the foreign paths. + assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + assert!(!fx.root().join("package.json").exists()); + assert!(!fx.root().parent().unwrap().join("x").exists()); + } + + #[tokio::test] + async fn revert_refuses_tampered_uuid_fail_closed() { + let fx = fixture_with_lock(Y2_BEFORE).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.uuid = "../../escape".to_string(); + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(!outcome.success, "tampered uuid must fail closed"); + } + + #[tokio::test] + async fn crlf_lock_is_preserved_and_round_trips() { + let crlf_before = Y2_BEFORE.replace('\n', "\r\n"); + let fx = fixture_with_lock(&crlf_before).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + + let (sha1, sri) = fx.packed_hashes().await; + let expected = spike_after(Y2_AFTER, &sha1, &sri).replace('\n', "\r\n"); + let text = fx.lock_text().await; + assert_eq!(text, expected, "every line (edited and untouched) stays CRLF"); + assert_eq!( + text.matches('\n').count(), + text.matches("\r\n").count(), + "no bare LF introduced" + ); + + let outcome = revert_yarn_classic(&entry.unwrap(), fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + crlf_before.as_bytes(), + "CRLF lock restored byte-for-byte" + ); + } + + #[test] + fn pattern_and_key_helpers() { + // Key splitting honors quotes and commas. + assert_eq!( + split_key_patterns("left-pad@^1.3.0, left-pad@~1.3.0"), + vec!["left-pad@^1.3.0", "left-pad@~1.3.0"] + ); + assert_eq!( + split_key_patterns("\"alias@npm:left-pad@^1.3.0\""), + vec!["alias@npm:left-pad@^1.3.0"] + ); + assert_eq!( + split_key_patterns("\"@scope/pkg@^1.0.0\", \"@scope/pkg@~1.0.0\""), + vec!["@scope/pkg@^1.0.0", "@scope/pkg@~1.0.0"] + ); + + // Real-name extraction, incl. the alias-range and scoped forms. + assert_eq!(pattern_real_name("left-pad@^1.3.0"), Some("left-pad")); + assert_eq!(pattern_real_name("@scope/pkg@^1.0.0"), Some("@scope/pkg")); + assert_eq!(pattern_real_name("alias@npm:left-pad@^1.3.0"), Some("left-pad")); + assert_eq!(pattern_real_name("alias@npm:@scope/pkg@^1.0.0"), Some("@scope/pkg")); + assert_eq!(pattern_real_name("alias@npm:left-pad"), Some("left-pad")); + assert_eq!(pattern_real_name("no-at-sign"), None); + + // yarn's key quoting rule. + assert_eq!(quote_yarn_key("left-pad"), "left-pad"); + assert_eq!(quote_yarn_key("@scope/x"), "\"@scope/x\""); + assert_eq!(quote_yarn_key("3d-lib"), "\"3d-lib\""); + assert_eq!(quote_yarn_key("true-lib"), "\"true-lib\""); + } + + #[test] + fn scan_blocks_grammar() { + let blocks = scan_blocks(Y5_BEFORE); + let keys: Vec<&str> = blocks.iter().map(|b| b.key.as_str()).collect(); + assert_eq!( + keys, + vec![ + "\"alias@npm:left-pad@^1.3.0\"", + "\"dep-a@file:./dep-a\"", + "left-pad@^1.3.0, left-pad@~1.3.0" + ] + ); + // The folder-dep block captured its 4-space sub-map lines. + assert_eq!( + blocks[1].lines, + vec![ + "\"dep-a@file:./dep-a\":", + " version \"1.0.0\"", + " dependencies:", + " left-pad \"~1.3.0\"" + ] + ); + // Byte ranges reproduce the source via splice with identical lines. + for b in &blocks { + assert_eq!(replace_block(Y5_BEFORE, b, &b.lines, "\n"), Y5_BEFORE); + } + // Field reads. + assert_eq!(classic_field(&blocks[0].lines, "version"), Some("1.3.0")); + assert!(classic_field(&blocks[1].lines, "resolved").is_none()); + } +} From 630d8fee5617f17cd7f86af05ef3c508d1a81c64 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 11:28:00 -0400 Subject: [PATCH 23/31] feat(vendor): wire v2 backends into the npm/pypi routers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - npm_flavor: NpmLockFlavor::YarnBerry variant; the __metadata sniff now routes berry (node-modules linker) to the backend instead of refusing (PnP still refused — different artifact pipeline); vendor_npm_any dispatches package-lock/yarn-classic/yarn-berry/pnpm/bun; revert_npm_any routes all five (unknown flavor still fails closed) - pypi: WiringPlan + MetaSlot gain Poetry/Pdm/Pipenv; vendor_pypi loads each project, runs check_target_guards (InSync -> synthesized AlreadyPatched via in_sync_outcome, entry None), wires LAST, routes the meta into the matching VendorEntry field; revert_pypi routes poetry/pdm/pipenv - updated the 3 npm_flavor tests that pinned the placeholder behavior (berry-refusal, native-pointer gate, yarn-classic-fails-closed-revert) to assert the new routing All six v2 backends now reachable end-to-end through the CLI. Suites: core 1371 / cli 272 / in_process_vendor 13 / cli_parse_vendor 31 green. Co-Authored-By: Claude Fable 5 --- .../src/patch/vendor/npm_flavor.rs | 182 ++++++++++-------- .../src/patch/vendor/pypi.rs | 171 +++++++++++++--- 2 files changed, 247 insertions(+), 106 deletions(-) diff --git a/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs b/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs index a497cde..67b7aeb 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs @@ -31,6 +31,8 @@ pub enum NpmLockFlavor { PackageLock, /// `yarn.lock` with the `# yarn lockfile v1` header (yarn classic). YarnClassic, + /// `yarn.lock` with a `__metadata:` key (yarn berry, node-modules linker). + YarnBerry, /// `pnpm-lock.yaml`, lockfileVersion 9.0 (pnpm >= 9). Pnpm, /// `bun.lock` (bun's text lockfile). @@ -43,6 +45,7 @@ impl NpmLockFlavor { match self { NpmLockFlavor::PackageLock => "package-lock", NpmLockFlavor::YarnClassic => "yarn-classic", + NpmLockFlavor::YarnBerry => "yarn-berry", NpmLockFlavor::Pnpm => "pnpm", NpmLockFlavor::Bun => "bun", } @@ -136,10 +139,9 @@ pub async fn detect_npm_lock_flavor( break 'flavor NpmLockFlavor::Pnpm; } - // 4. yarn: classic v1 vs berry, decided by content. + // 4. yarn: classic v1 vs berry (node-modules linker), decided by content. if exists("yarn.lock").await { - sniff_yarn_lock(project_root).await?; - break 'flavor NpmLockFlavor::YarnClassic; + break 'flavor sniff_yarn_lock(project_root).await?; } // 5. npm (npm_lock itself prefers the shrinkwrap when both exist). @@ -224,7 +226,7 @@ async fn sniff_pnpm_lock(project_root: &Path) -> Result<(), (&'static str, Strin /// `__metadata:` key; classic v1 locks carry the `# yarn lockfile v1` /// comment header. Berry wins the check — a berry lock must never be /// mistaken for classic. -async fn sniff_yarn_lock(project_root: &Path) -> Result<(), (&'static str, String)> { +async fn sniff_yarn_lock(project_root: &Path) -> Result { let text = tokio::fs::read_to_string(project_root.join("yarn.lock")) .await .map_err(|e| { @@ -234,18 +236,16 @@ async fn sniff_yarn_lock(project_root: &Path) -> Result<(), (&'static str, Strin ) })?; let head: Vec<&str> = text.lines().take(YARN_SNIFF_HEAD_LINES).collect(); + // Berry wins the check (it must never be mistaken for classic). The + // node-modules linker keeps packages on disk for staging, and berry's + // cache-zip checksum is reproducible from our tarball (berry_zip), so the + // backend can wire it; PnP (caught earlier by the `.pnp.*` markers) is the + // only berry layout vendor refuses. if head.iter().any(|l| l.starts_with("__metadata:")) { - return Err(( - "vendor_yarn_berry_unsupported", - "yarn.lock is a yarn berry (v2+) lockfile (top-level `__metadata:` key); even \ - with the node-modules linker, berry verifies installs against its cache zips' \ - checksums, so a rewired tarball would fail validation — use `yarn patch ` \ - instead" - .to_string(), - )); + return Ok(NpmLockFlavor::YarnBerry); } if head.iter().any(|l| l.trim() == "# yarn lockfile v1") { - return Ok(()); + return Ok(NpmLockFlavor::YarnClassic); } Err(( "vendor_lockfile_version_unsupported", @@ -255,10 +255,11 @@ async fn sniff_yarn_lock(project_root: &Path) -> Result<(), (&'static str, Strin )) } -/// Vendor one npm package through whichever flavor backend serves this -/// project. Probe refusals surface verbatim; flavors without a backend yet -/// refuse with the manager's native patch flow (behavior-equivalent to the -/// CLI's former layout gate). +/// Vendor one npm package through whichever lockfile-flavor backend serves +/// this project (package-lock / yarn classic / yarn berry node-modules / +/// pnpm / bun). Probe refusals (PnP, bun.lockb, unsupported lock versions) +/// surface verbatim; the detected flavor is stamped onto the ledger entry so +/// `revert_npm_any` routes back to the same backend. #[allow(clippy::too_many_arguments)] pub async fn vendor_npm_any( purl: &str, @@ -274,53 +275,54 @@ pub async fn vendor_npm_any( Ok(found) => found, Err((code, detail)) => return VendorOutcome::Refused { code, detail }, }; - match flavor { + let mut outcome = match flavor { NpmLockFlavor::PackageLock => { - let mut outcome = npm_lock::vendor_npm( - purl, - installed_dir, - project_root, - record, - sources, - vendored_at, - dry_run, - force, + npm_lock::vendor_npm( + purl, installed_dir, project_root, record, sources, vendored_at, dry_run, force, ) - .await; - if let VendorOutcome::Done { entry, warnings, .. } = &mut outcome { - // Probe warnings precede the backend's own (the probe ran - // first); the ledger records which flavor wired the entry so - // revert can route — and fail closed on builds without the - // matching backend. - let mut merged = probe_warnings; - merged.append(warnings); - *warnings = merged; - if let Some(entry) = entry { - entry.flavor = Some(flavor.as_str().to_string()); - } - } - outcome + .await } - NpmLockFlavor::YarnClassic | NpmLockFlavor::Pnpm | NpmLockFlavor::Bun => { - // No wiring backend yet: refuse pointing at the manager's native - // patch flow. These arms are the seams the yarn-classic / pnpm / - // bun backends will replace. - let native = match flavor { - NpmLockFlavor::YarnClassic => "yarn patch ", - NpmLockFlavor::Pnpm => "pnpm patch ", - NpmLockFlavor::Bun => "bun patch ", - NpmLockFlavor::PackageLock => unreachable!("handled above"), - }; - VendorOutcome::Refused { - code: "vendor_pkg_manager_unsupported", - detail: format!( - "this project's installs are driven by a {} lockfile; socket-patch \ - vendor only rewrites package-lock.json — use `{native}` instead", - flavor.as_str() - ), - } + NpmLockFlavor::YarnClassic => { + super::yarn_classic_lock::vendor_yarn_classic( + purl, installed_dir, project_root, record, sources, vendored_at, dry_run, force, + ) + .await + } + NpmLockFlavor::YarnBerry => { + super::yarn_berry_lock::vendor_yarn_berry( + purl, installed_dir, project_root, record, sources, vendored_at, dry_run, force, + ) + .await + } + NpmLockFlavor::Pnpm => { + super::pnpm_lock::vendor_pnpm( + purl, installed_dir, project_root, record, sources, vendored_at, dry_run, force, + ) + .await + } + NpmLockFlavor::Bun => { + super::bun_lock::vendor_bun( + purl, installed_dir, project_root, record, sources, vendored_at, dry_run, force, + ) + .await + } + }; + // Probe warnings (e.g. a sibling lockfile that will install UNPATCHED + // bytes) precede the backend's own; the ledger records which flavor wired + // the entry so revert routes — and fails closed on a build lacking the + // backend. Each backend already self-stamps `flavor`; we re-assert it from + // the probe for belt-and-braces (the values are identical). + if let VendorOutcome::Done { entry, warnings, .. } = &mut outcome { + if !probe_warnings.is_empty() { + let mut merged = probe_warnings; + merged.append(warnings); + *warnings = merged; + } + if let Some(entry) = entry { + entry.flavor = Some(flavor.as_str().to_string()); } } + outcome } /// Revert one recorded npm vendor entry through the flavor that wired it. @@ -334,6 +336,14 @@ pub async fn revert_npm_any( ) -> RevertOutcome { match entry.flavor.as_deref() { None | Some("package-lock") => npm_lock::revert_npm(entry, project_root, dry_run).await, + Some("yarn-classic") => { + super::yarn_classic_lock::revert_yarn_classic(entry, project_root, dry_run).await + } + Some("yarn-berry") => { + super::yarn_berry_lock::revert_yarn_berry(entry, project_root, dry_run).await + } + Some("pnpm") => super::pnpm_lock::revert_pnpm(entry, project_root, dry_run).await, + Some("bun") => super::bun_lock::revert_bun(entry, project_root, dry_run).await, Some(other) => RevertOutcome::failed(format!( "this socket-patch build cannot revert npm vendor flavor `{other}` — upgrade \ socket-patch and re-run" @@ -442,12 +452,13 @@ mod tests { let (flavor, _) = detect(tmp.path()).await.unwrap(); assert_eq!(flavor, NpmLockFlavor::YarnClassic); + // A berry (node-modules) lock now routes to the YarnBerry backend + // (cache-zip checksum is reproducible from our tarball — berry_zip). + // Only PnP (`.pnp.*` markers, caught earlier) stays refused. let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "yarn.lock", YARN_BERRY).await; - let (code, detail) = detect(tmp.path()).await.unwrap_err(); - assert_eq!(code, "vendor_yarn_berry_unsupported"); - assert!(detail.contains("yarn patch"), "{detail}"); - assert!(detail.contains("checksum"), "must explain the cache-zip checksum problem: {detail}"); + let (flavor, _) = detect(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnBerry); let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "yarn.lock", "garbage: true\n").await; @@ -599,21 +610,25 @@ mod tests { assert!(lock.contains(&format!("file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"))); } - /// The unwired-flavor arms refuse with the old CLI gate's stable code, - /// naming the manager's native patch flow, and write nothing. + /// A yarn.lock now ROUTES to the yarn-classic backend (no longer the old + /// `vendor_pkg_manager_unsupported` gate). With a header-only lock that + /// has no matching block, the backend's own `vendor_lock_entry_not_found` + /// proves the dispatch reached it — and nothing is written. #[tokio::test] - async fn unwired_flavor_arm_refuses_with_native_pointer() { + async fn yarn_lock_routes_to_the_backend_not_the_old_gate() { let (tmp, record) = npm_project().await; tokio::fs::remove_file(tmp.path().join("package-lock.json")).await.unwrap(); touch(tmp.path(), "yarn.lock", YARN_V1).await; let outcome = vendor_any(tmp.path(), &record).await; - let VendorOutcome::Refused { code, detail } = outcome else { - panic!("expected Refused, got {outcome:?}"); + let VendorOutcome::Refused { code, .. } = outcome else { + panic!("expected the backend's Refused, got {outcome:?}"); }; - assert_eq!(code, "vendor_pkg_manager_unsupported"); - assert!(detail.contains("yarn-classic"), "{detail}"); - assert!(detail.contains("yarn patch "), "{detail}"); + assert_eq!( + code, "vendor_lock_entry_not_found", + "yarn.lock must reach the yarn-classic backend, not the removed gate" + ); + assert_ne!(code, "vendor_pkg_manager_unsupported"); assert!(!tmp.path().join(".socket/vendor").exists(), "refusal writes nothing"); } @@ -633,7 +648,7 @@ mod tests { wiring: Vec::new(), lock: None, took_over_go_patches: false, - flavor: Some("yarn-classic".into()), + flavor: Some("future-pm".into()), uv: None, pnpm: None, poetry: None, @@ -641,17 +656,24 @@ mod tests { pipenv: None, }; - // Unknown-to-this-build flavor: fail closed, name the flavor. + // A flavor this build has no backend for: fail closed, name it. let outcome = revert_npm_any(&entry, tmp.path(), false).await; assert!(!outcome.success); - assert!(outcome.error.as_deref().unwrap().contains("yarn-classic")); - - // None and "package-lock" both route to npm_lock::revert_npm (which - // succeeds trivially here: no wiring records, nothing on disk). - for flavor in [None, Some("package-lock".to_string())] { - entry.flavor = flavor; + assert!(outcome.error.as_deref().unwrap().contains("future-pm")); + + // Every known flavor routes to its backend; with no wiring records and + // nothing on disk each reverts trivially (None = a pre-flavor ledger). + for flavor in [ + None, + Some("package-lock".to_string()), + Some("yarn-classic".to_string()), + Some("yarn-berry".to_string()), + Some("pnpm".to_string()), + Some("bun".to_string()), + ] { + entry.flavor = flavor.clone(); let outcome = revert_npm_any(&entry, tmp.path(), false).await; - assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.success, "flavor {flavor:?}: {:?}", outcome.error); } } } diff --git a/crates/socket-patch-core/src/patch/vendor/pypi.rs b/crates/socket-patch-core/src/patch/vendor/pypi.rs index c7275c7..823a751 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi.rs @@ -10,17 +10,23 @@ use std::path::Path; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::manifest::schema::PatchRecord; -use crate::patch::apply::PatchSources; +use crate::patch::apply::{ApplyResult, PatchSources, VerifyResult, VerifyStatus}; use crate::utils::purl::{parse_pypi_purl, strip_purl_qualifiers}; use super::path::vendor_uuid_dir_rel; +use super::pypi_pdm::{PdmProject, PdmTarget}; +use super::pypi_pipenv::{PipenvProject, PipenvTarget}; +use super::pypi_poetry::{PoetryProject, PoetryTarget}; use super::pypi_requirements::{preflight_requirements, revert_requirements, wire_requirements}; use super::pypi_uv::{ check_target_guards, classify_dependency, load_uv_project, revert_uv, wire_uv, UvDepClass, UvProject, }; use super::pypi_wheel::{build_patched_wheel, locate_installed_dist, wheel_file_name}; -use super::state::{write_marker, VendorArtifact, VendorEntry, VendorMarker}; +use super::state::{ + write_marker, PdmMeta, PipenvMeta, PoetryMeta, UvMeta, VendorArtifact, VendorEntry, + VendorMarker, +}; use super::{RevertOutcome, VendorOutcome, VendorWarning}; /// Which wiring backend serves this project. @@ -207,10 +213,60 @@ fn has_table(content: &str, prefix: &str) -> bool { }) } -/// Per-flavor pre-flight result carried into the wiring step. +/// Per-flavor pre-flight result carried into the wiring step (the loaded +/// project is reused so the lock is parsed once). enum WiringPlan { Uv(Box, UvDepClass), Requirements, + Poetry(Box), + Pdm(Box), + Pipenv(Box), +} + +/// Which `VendorEntry` meta slot a flavor's wiring produced. +enum MetaSlot { + Uv(Option), + Poetry(PoetryMeta), + Pdm(PdmMeta), + Pipenv(PipenvMeta), + None, +} + +/// Build the synthesized AlreadyPatched outcome for an in-sync re-run: the +/// artifact + lockfile already point at THIS patch uuid, so nothing is built +/// or recorded (the first run's ledger entry holds the only copy of the +/// originals). Mirrors the npm flavors' in-sync hot path. +fn in_sync_outcome( + base_purl: &str, + record: &PatchRecord, + warnings: Vec, +) -> VendorOutcome { + let files_verified = record + .files + .keys() + .map(|f| VerifyResult { + file: f.clone(), + status: VerifyStatus::AlreadyPatched, + message: None, + current_hash: None, + expected_hash: None, + target_hash: None, + }) + .collect(); + VendorOutcome::Done { + result: ApplyResult { + package_key: base_purl.to_string(), + package_path: String::new(), + success: true, + files_verified, + files_patched: Vec::new(), + applied_via: std::collections::HashMap::new(), + error: None, + sidecar: None, + }, + entry: None, + warnings, + } } /// Vendor one pypi package: route the flavor, pre-flight every guard, build @@ -282,31 +338,50 @@ pub async fn vendor_pypi( } WiringPlan::Requirements } - // Detected but not yet wired: the backends land behind these arms - // (spike-verified GO — see spikes/PHASE0-V2-FINDINGS.txt). PypiFlavor::Poetry => { - return VendorOutcome::Refused { - code: "pypi_poetry_unsupported", - detail: format!( - "Poetry projects are not supported by this build yet; {SETUP_ALTERNATIVE}" - ), + let project = match super::pypi_poetry::load_poetry_project(project_root).await { + Ok(p) => p, + Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + }; + match super::pypi_poetry::check_target_guards( + &project, + &canon_name, + version, + &record.uuid, + ) { + Ok(PoetryTarget::Fresh) => {} + Ok(PoetryTarget::InSync) => return in_sync_outcome(base, record, warnings), + Err((code, detail)) => return VendorOutcome::Refused { code, detail }, } + warnings.extend(project.warnings.iter().cloned()); + WiringPlan::Poetry(Box::new(project)) } PypiFlavor::Pdm => { - return VendorOutcome::Refused { - code: "pypi_pdm_unsupported", - detail: format!( - "PDM projects are not supported by this build yet; {SETUP_ALTERNATIVE}" - ), + let project = match super::pypi_pdm::load_pdm_project(project_root).await { + Ok(p) => p, + Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + }; + match super::pypi_pdm::check_target_guards(&project, &canon_name, version, &record.uuid) + { + Ok(PdmTarget::Fresh) => {} + Ok(PdmTarget::InSync) => return in_sync_outcome(base, record, warnings), + Err((code, detail)) => return VendorOutcome::Refused { code, detail }, } + warnings.extend(project.warnings.iter().cloned()); + WiringPlan::Pdm(Box::new(project)) } PypiFlavor::Pipenv => { - return VendorOutcome::Refused { - code: "pypi_pipenv_unsupported", - detail: format!( - "Pipenv projects are not supported by this build yet; {SETUP_ALTERNATIVE}" - ), + let project = match super::pypi_pipenv::load_pipenv_project(project_root).await { + Ok(p) => p, + Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + }; + match super::pypi_pipenv::check_target_guards(&project, &canon_name, &record.uuid) { + Ok(PipenvTarget::Fresh) => {} + Ok(PipenvTarget::InSync) => return in_sync_outcome(base, record, warnings), + Err((code, detail)) => return VendorOutcome::Refused { code, detail }, } + warnings.extend(project.warnings.iter().cloned()); + WiringPlan::Pipenv(Box::new(project)) } }; @@ -409,7 +484,7 @@ pub async fn vendor_pypi( // Wiring LAST. On failure the wheel artifact is swept back out so a // failed vendor leaves no committed residue. - let wired = match plan { + let wired: Result<(Vec<_>, MetaSlot), (&'static str, String)> = match plan { WiringPlan::Uv(project, class) => wire_uv( &project, project_root, @@ -421,7 +496,7 @@ pub async fn vendor_pypi( class, ) .await - .map(|(wiring, meta)| (wiring, Some(meta))), + .map(|(wiring, meta)| (wiring, MetaSlot::Uv(Some(meta)))), WiringPlan::Requirements => wire_requirements( project_root, &canon_name, @@ -430,9 +505,43 @@ pub async fn vendor_pypi( &artifact.sha256_hex, ) .await - .map(|wiring| (wiring, None)), + .map(|wiring| (wiring, MetaSlot::None)), + WiringPlan::Poetry(project) => super::pypi_poetry::wire_poetry( + &project, + project_root, + &canon_name, + version, + &rel_wheel, + &wheel_name, + &artifact.sha256_hex, + &record.uuid, + ) + .await + .map(|(wiring, meta)| (wiring, MetaSlot::Poetry(meta))), + WiringPlan::Pdm(project) => super::pypi_pdm::wire_pdm( + &project, + project_root, + &canon_name, + version, + &rel_wheel, + &wheel_name, + &artifact.sha256_hex, + &record.uuid, + ) + .await + .map(|(wiring, meta)| (wiring, MetaSlot::Pdm(meta))), + WiringPlan::Pipenv(project) => super::pypi_pipenv::wire_pipenv( + &project, + project_root, + &canon_name, + &rel_wheel, + &artifact.sha256_hex, + &record.uuid, + ) + .await + .map(|(wiring, meta)| (wiring, MetaSlot::Pipenv(meta))), }; - let (wiring, uv_meta) = match wired { + let (wiring, meta) = match wired { Ok(pair) => pair, Err((code, detail)) => { let _ = tokio::fs::remove_dir_all(project_root.join(&uuid_dir_rel)).await; @@ -447,7 +556,7 @@ pub async fn vendor_pypi( } }; - let entry = VendorEntry { + let mut entry = VendorEntry { ecosystem: "pypi".to_string(), base_purl: base.to_string(), uuid: record.uuid.clone(), @@ -461,12 +570,19 @@ pub async fn vendor_pypi( lock: None, took_over_go_patches: false, flavor: Some(flavor.as_str().to_string()), - uv: uv_meta, + uv: None, pnpm: None, poetry: None, pdm: None, pipenv: None, }; + match meta { + MetaSlot::Uv(m) => entry.uv = m, + MetaSlot::Poetry(m) => entry.poetry = Some(m), + MetaSlot::Pdm(m) => entry.pdm = Some(m), + MetaSlot::Pipenv(m) => entry.pipenv = Some(m), + MetaSlot::None => {} + } VendorOutcome::Done { result, entry: Some(entry), @@ -481,6 +597,9 @@ pub async fn revert_pypi(entry: &VendorEntry, project_root: &Path, dry_run: bool let mut outcome = match entry.flavor.as_deref() { Some("uv") => revert_uv(entry, project_root, dry_run).await, Some("requirements") => revert_requirements(entry, project_root, dry_run).await, + Some("poetry") => super::pypi_poetry::revert_poetry(entry, project_root, dry_run).await, + Some("pdm") => super::pypi_pdm::revert_pdm(entry, project_root, dry_run).await, + Some("pipenv") => super::pypi_pipenv::revert_pipenv(entry, project_root, dry_run).await, other => { return RevertOutcome::failed(format!( "unknown pypi vendor flavor {other:?}; cannot revert" From f381a6e7706c94aa97d233144f5607ef7fa5f6e5 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 11:32:15 -0400 Subject: [PATCH 24/31] =?UTF-8?q?docs(vendor):=20v2=20contract=20=E2=80=94?= =?UTF-8?q?=20flavor=20matrix,=20checksum=20table,=20reason=20codes,=20CHA?= =?UTF-8?q?NGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI_CONTRACT: five npm + six pypi flavor rows with strictest-install proofs; checksum-coverage rows for yarn classic/berry, pnpm, bun, poetry, pdm, pipenv, gem CHECKSUMS; new stable reason codes (vendor_yarn_berry_*, vendor_bun_lockb_*, vendor_override_conflict, vendor_integrity_unverified, *_no_lockfile, *_multiple_lockfiles, vendor_stale_lock_checksum). CHANGELOG v2 entry. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 19 ++++++++++ crates/socket-patch-cli/CLI_CONTRACT.md | 47 ++++++++++++++++++++----- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8b868..dce46d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,25 @@ in this file — see `.github/workflows/release.yml` (`version` job). ### Added +- **`vendor` now supports every major npm and pypi package manager.** The npm + ecosystem gained four lockfile flavors beyond `package-lock.json` — yarn + classic (`yarn.lock` v1), yarn berry with the node-modules linker + (`resolutions` + a cache-zip `10c0` checksum reproduced offline from the + vendored tarball), pnpm (`pnpm.overrides` + `pnpm-lock.yaml` surgery, pnpm 9 + & 10), and bun (`bun.lock`) — all sharing the one vendored tarball and + selected by a content-sniffing probe (yarn-berry PnP and bun's binary + `bun.lockb` are refused with pointers to the native flow). The pypi + ecosystem gained poetry, pdm, and pipenv (lock-only `[[package]]` / entry + splices, like the existing uv/requirements flavors). Every lockfile + checksum/reference field for a vendored package is now recomputed + coherently (the v2 "update checksums and references" directive); the gem + backend handles bundler ≥ 2.6's optional `CHECKSUMS` section; composer's + `dist.reference` carries the patch UUID into `installed.json`. Each flavor + has a real-package-manager build-proof capstone (fresh-checkout, cold-cache, + strictest-install — `--frozen`/`--immutable`/`--deploy`/`--locked` — with + byte-identical revert). `vendor --force`/`--revert` accept empty env vars + (`SOCKET_FORCE=`) as false, matching the global-flag contract. + - **New `vendor` subcommand: committable vendoring of patched dependencies.** Where `apply` patches installed packages in place (machine-local state), `socket-patch vendor` ejects each patched package into a committed diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 336636c..0e1feb0 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -336,20 +336,36 @@ lockfile; never a trust input. ### Per-ecosystem wiring matrix -| eco | vendored artifact | committed wiring | consumption proof | +The npm ecosystem has **five lockfile flavors** — all sharing one vendored +tarball at `.socket/vendor/npm//[@scope/]-.tgz`; a +content-sniffing probe (`npm_flavor`) picks the flavor and the ledger records +it so `--revert` routes back. The pypi ecosystem similarly routes by lockfile +to **six flavors**. + +| eco / flavor | vendored artifact | committed wiring | consumption proof | |---|---|---|---| -| npm | deterministic patched tarball `[@scope/]-.tgz` | `package-lock.json` only (`npm-shrinkwrap.json` wins when present): every entry matching name+version gets `resolved: "file:…"` + recomputed `integrity`. `package.json` untouched | `npm ci` (integrity-verified). Plain `npm install` preserves the entry; `npm update ` re-resolves and drops it | +| npm (package-lock) | deterministic patched tarball `[@scope/]-.tgz` | `package-lock.json` only (`npm-shrinkwrap.json` wins when present): every entry matching name+version gets `resolved: "file:…"` + recomputed `integrity`. `package.json` untouched | `npm ci` (integrity-verified). Plain `npm install` preserves the entry; `npm update ` re-resolves and drops it | +| npm / yarn classic | (same tarball) | `yarn.lock` only: matching blocks get `resolved "file:./…#"` + `integrity` (both checksums recomputed; merged-key & `npm:`-alias blocks covered) | `yarn install --frozen-lockfile --offline` (sha1 fragment + sha512 SRI both enforced; byte-stable lock) | +| npm / yarn berry (node-modules linker) | (same tarball) | root `package.json` `resolutions` + `yarn.lock` entry with `checksum: 10c0/` of the berry cache-zip (reproduced from the tarball offline). **PnP is refused** (`.pnp.*` → different artifact pipeline) | `yarn install --immutable --check-cache`, cold cache. Refused if `__metadata.cacheKey ≠ 10c0` or a non-default `compressionLevel` | +| npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) | `pnpm install --frozen-lockfile --offline`, cold store (integrity-verified; byte-stable on pnpm 9 & 10). lockfileVersion ≠ 9 refused | +| npm / bun (`bun.lock`) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`. `bun.lockb` (binary) refused with a `--save-text-lockfile` pointer | `bun install --frozen-lockfile`, cold cache (integrity-enforced) | | cargo | crate dir `-/` (no `.cargo-checksum.json`) | `.cargo/config.toml` `[patch.crates-io]` path entry **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed) | `cargo build --locked --offline` on a fresh checkout. Requires cargo ≥ 1.56 (`[patch]` in config files). Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | | composer | package dir `/@/` | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: null}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). `composer update ` reverts it | | gem | gem dir `-/` + gemspec materialized from `specifications/` | **Gemfile + Gemfile.lock pair**: the `gem` line gains `path:` (or a managed block for transitive deps); the lock's spec block moves GEM→PATH and the DEPENDENCIES entry becomes ` (= )!`, in bundler's exact canonical form | `bundle install` (normal **and** `BUNDLE_FROZEN=true`), byte-stable lock. Lock-only edits are a silent unpatch — hence the mandatory pair | -| pypi | rebuilt wheel (canonical PEP 427 filename; RECORD regenerated correctly) | **uv projects** (uv.lock present): `[tool.uv.sources] = {path}` in pyproject + surgical uv.lock rewrite; transitive deps via `[tool.uv] override-dependencies`. **requirements.txt** (pip / `uv pip`): pin line → `./ --hash=sha256:` (markers carried over; transitive deps appended) | `uv sync --locked` / `--frozen --offline` (hash-verified, byte-stable lock); `pip install -r` / `uv pip install -r` **run from the project root** (both resolve bare paths against the CWD) | +| pypi / uv (uv.lock) | rebuilt wheel (canonical PEP 427 filename; RECORD regenerated) | `[tool.uv.sources] = {path}` in pyproject + surgical uv.lock rewrite; transitive deps via `[tool.uv] override-dependencies` | `uv sync --locked` / `--frozen --offline` (hash-verified, byte-stable lock) | +| pypi / poetry (poetry.lock 2.0/2.1) | (rebuilt wheel) | lock-only: the target `[[package]]` gets `[package.source] type="file"` + `files = [{file, hash: sha256-of-our-wheel}]`. pyproject + `metadata.content-hash` untouched | `poetry check --lock && poetry sync`, cold cache (hash fail-closed; byte-stable lock) | +| pypi / pdm (pdm.lock) | (rebuilt wheel) | lock-only: the `[[package]]` gains the local-file `path` + `files[]` hash. pyproject + `content_hash` untouched. Non-fixture `[metadata] strategy` / hash-less locks refused | `pdm sync` (+ `pdm install --check`), cold cache | +| pypi / pipenv (Pipfile.lock) | (rebuilt wheel) | lock-only: the `default`/`develop` entry → `{file, hashes:[sha256-of-our-wheel]}`. Pipfile + `_meta.hash` untouched. Emits `vendor_integrity_unverified` — pipenv does not hash-check file entries; the committed wheel bytes are the protection | `pipenv install --deploy` (+ `pipenv verify`), cold cache | +| pypi / requirements.txt (pip / `uv pip`) | (rebuilt wheel) | pin line → `./ --hash=sha256:` (markers carried over; transitive deps appended) | `pip install -r` / `uv pip install -r` **run from the project root** (both resolve bare paths against the CWD) | Ecosystems with no vendor backend that this build still *recognizes* (maven/nuget/jsr when their -features are compiled in), plus poetry/pdm/pipenv pyproject flavors and yarn/pnpm/bun npm layouts, -refuse per-purl with stable reason codes pointing at the native alternative (`yarn|pnpm|bun patch`, -the `.pth` setup hook, …). PURLs of **compiled-out** ecosystems are invisible to `vendor` exactly -as they are to `apply` (the binary cannot parse them). +features are compiled in) refuse per-purl with `vendor_unsupported_ecosystem`. yarn-berry **PnP** +(`.pnp.*`) and bun's binary `bun.lockb` are refused with stable codes pointing at the native +alternative / a text-lockfile migration; a lock-less tool marker (a `[tool.uv]`/`[tool.poetry]`/ +`[tool.pdm]` table or a `Pipfile` without its lock) refuses `_no_lockfile` unless a +`requirements.txt` fallback exists. PURLs of **compiled-out** ecosystems are invisible to `vendor` +exactly as they are to `apply` (the binary cannot parse them). ### Checksum coverage @@ -363,8 +379,15 @@ worse, lets a warm cache silently serve unpatched bytes): | cargo | `[[package]].source` + `checksum`; `.cargo-checksum.json` in the copy | both lock keys removed (the canonical path-dep form); checksum sidecar excluded from the copy; originals kept verbatim in the ledger for `--revert` | | golang | `go.sum` | untouched **by design** — directory `replace` targets are never sum-verified. Caveat: a user `go mod tidy` may prune the replaced module's go.sum lines; revert does not restore them (the next online build re-adds them) | | composer | `dist.{url,reference,shasum}`, `source.reference`, `content-hash` | `dist` → `{type: path, url, reference: ""}` (the uuid is preserved verbatim into `installed.json` — in-tree traceability); `source` removed; `content-hash` untouched (covers composer.json only) | -| gem | `CHECKSUMS` section (bundler ≥ 2.6 opt-in) | the vendored gem's entry rewritten to bundler's own path-gem form so re-locks stay byte-stable; original line in the ledger | +| npm / yarn classic | `resolved "…#"` fragment + `integrity` SRI | both recomputed from the packed tarball (sha1 fragment + sha512 SRI); integrity line added when the registry block lacked one — yarn then enforces both | +| npm / yarn berry | `checksum: 10c0/` (over berry's cache zip) | recomputed by rebuilding berry's deterministic cache-zip from the tarball and hashing it (byte-identical to yarn's own); refused if the lock's `cacheKey`/`compressionLevel` would change the zip | +| npm / pnpm | `packages[].resolution.integrity` (sha512) | recomputed from the tarball; the versioned `pnpm.overrides` selector pins exactly the patched version | +| npm / bun | the packages-entry trailing `sha512-…` | recomputed from the tarball; tamper fails the frozen install | +| gem | `CHECKSUMS` section (bundler ≥ 2.6 opt-in) | the vendored gem's entry rewritten to bundler's own path-gem form (bare `name (ver)`, sha256 token stripped) so re-locks stay byte-stable; original line in the ledger | | pypi / uv | `wheels[].hash`, `sdist.hash`, requires-dist specifiers | single `{filename, hash: sha256-of-our-wheel}`; sdist dropped; dropped specifiers ledgered for revert | +| pypi / poetry | `files = [{file, hash}]` | replaced with a single `{file, hash: sha256-of-our-wheel}` (poetry verifies the artifact against one listed hash; stale registry hashes removed) | +| pypi / pdm | `[[package]].files[]` hashes | replaced with our wheel's sha256; hash-less locks refused (`pypi_pdm_lock_no_hashes`) | +| pypi / pipenv | per-entry `hashes[]` | replaced with `["sha256:"]` — but pipenv does **not** enforce hashes on file entries (`vendor_integrity_unverified` warning); the committed wheel bytes are the actual protection | | pypi / requirements | `--hash=sha256:` | fresh hash of the rebuilt wheel always emitted (turns on pip's hash-checking for the line) | ### Ownership, state, and reversal @@ -529,9 +552,15 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendored` | `skipped` | apply: the package is managed by `socket-patch vendor`; apply yields ownership. | | `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (maven/nuget/jsr, or compiled out). | | `already_vendored` | `skipped` | vendor: artifact + wiring already in sync for this patch uuid. | -| `vendor_pkg_manager_unsupported` | `failed` | vendor (npm): project uses yarn/pnpm/bun — use the manager's native patch flow. | | `unsafe_coordinates` | `failed` | vendor: purl/uuid would escape `.socket/vendor/` (tampered manifest/state); refused before any write. | | `revert_failed` | `failed` | vendor --revert: a recorded entry could not be reverted. | +| `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager will still install UNPATCHED bytes; names the wired winner + the ignored locks. | +| `vendor_yarn_berry_unsupported` / `vendor_bun_lockb_unsupported` | `failed` | vendor (npm): yarn-berry PnP / bun binary lockfile — pointer to `yarn patch` / `bun install --save-text-lockfile`. | +| `vendor_yarn_berry_cache_unsupported` | `failed` | vendor (yarn berry): lock `cacheKey ≠ 10c0` or non-default `.yarnrc.yml` `compressionLevel` — the cache-zip checksum is not reproducible. | +| `vendor_override_conflict` | `failed` | vendor (pnpm/yarn-berry): a user-authored override/resolution for the package already exists. | +| `vendor_integrity_unverified` | `skipped` (warning) | vendor (pipenv): the lockfile format does not hash-check file entries; the committed wheel bytes are the protection. | +| `vendor_lock_checksums_unsupported` / `vendor_stale_lock_checksum` | `failed` | vendor (gem): an ambiguous/platform CHECKSUMS entry, or a v1-wired lock whose stale token blocks the hot path (run `vendor --revert` + re-vendor). | +| `pypi_{poetry,pdm,pipenv}_no_lockfile` | `failed` | vendor (pypi): a lock-less tool marker with no `requirements.txt` fallback — run ` lock`. | | `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | ### Top-level `EnvelopeError` codes From 68c627aa5a8d8bbaf9723dafa7fbc3f73e0bee7f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 11:41:18 -0400 Subject: [PATCH 25/31] test(vendor): npm-family build-proof capstones (yarn classic/berry, pnpm, bun) Real-package-manager fresh-checkout proofs (skip-guarded local capstones): - yarn classic: yarn install --frozen-lockfile --offline, cold cache - yarn berry (node-modules): yarn install --immutable --check-cache, cold cache, 10c0 cache-zip checksum reproduced + verified - pnpm: pnpm install --frozen-lockfile --offline on BOTH pnpm 9 and 10 - bun: bun install --frozen-lockfile, cold cache Each: vendor -> assert lock rewiring + tarball -> fresh copy of committed files -> strictest cold-cache install -> patched bytes -> revert byte-identical -> re-vendor. All green on this machine with real assertions (no skips). Co-Authored-By: Claude Fable 5 --- .../tests/docker_e2e_vendor_pypi_pm.rs | 614 ++++++++++++++++++ .../tests/e2e_vendor_bun_build.rs | 344 ++++++++++ .../tests/e2e_vendor_pnpm_build.rs | 376 +++++++++++ .../tests/e2e_vendor_yarn_berry_build.rs | 391 +++++++++++ .../tests/e2e_vendor_yarn_classic_build.rs | 378 +++++++++++ 5 files changed, 2103 insertions(+) create mode 100644 crates/socket-patch-cli/tests/docker_e2e_vendor_pypi_pm.rs create mode 100644 crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs create mode 100644 crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs create mode 100644 crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs create mode 100644 crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_pypi_pm.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_pypi_pm.rs new file mode 100644 index 0000000..8e3e190 --- /dev/null +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_pypi_pm.rs @@ -0,0 +1,614 @@ +//! Docker build-proof capstones for `socket-patch vendor` — pypi +//! package-manager v2 flavors (poetry, pdm, pipenv). +//! +//! Each test proves the CLI_CONTRACT "Vendor command contract" pypi row end +//! to end for one Python tool against the REAL tool baked into +//! `socket-patch-test-pypi:latest` (Poetry 2.x, PDM 2.27, pipenv 2026.x; +//! Python 3.11), with state carried across containers via a bind-mounted host +//! tempdir (see `docker_vendor_common/mod.rs`): +//! +//! stage 1 (networked): create a real single-dep project on `six==1.16.0` +//! (poetry: `poetry add`; pdm: `pdm add`; pipenv: `pipenv install`) with +//! an IN-PROJECT venv so the crawler finds the installed `six.py` → +//! hand-stage a marker patch on `six.py` (manifest + blob; git-blob +//! sha256 from the ACTUAL installed bytes) → `socket-patch vendor --json +//! --offline` (the binary baked into the image) → assert: the wheel +//! artifact at `.socket/vendor/pypi//` (files[] hash == +//! wheel sha256), the LOCK-ONLY rewiring per flavor, `state.json`, and +//! that the tool MANIFEST (pyproject/Pipfile) was left byte-untouched. +//! stage 2 (`--network none`, cold cache dir): ONLY the committable files +//! (lock + pyproject/Pipfile + .socket/) are copied to a fresh dir; the +//! tool's STRICTEST install runs cold+offline and a Python import probe +//! proves `six.py` is the PATCHED bytes. +//! stage 3 (`--network none`): re-vendor is idempotent (already_vendored, +//! lock byte-stable) → `vendor --revert` restores the lock byte-identical +//! to the pre-vendor snapshot and removes `.socket/vendor` → re-vendor +//! succeeds again. +//! +//! Anti-vacuity: every stage echoes `=== VERIFIED===` markers behind +//! its asserts (gated by `assert_stage_markers`), and stage 2 additionally +//! RED-PROBES — it first deletes `.socket/vendor` from the fresh copy and +//! requires the strictest install to FAIL, proving the install genuinely +//! depends on the vendored artifact, then restores it and requires green. +//! +//! pipenv caveat (spike V4, lock-only NOT hash-enforced): pipenv installs +//! file-ref lock entries through a pip phase with no `--require-hashes`, so a +//! tampered wheel installs silently. The committable proof here is therefore +//! "the patched bytes get imported", not "tamper fails"; the suite also +//! asserts the `vendor_integrity_unverified` warning surfaces in the vendor +//! `--json` envelope (a `skipped` event carrying that `errorCode`). + +#![cfg(feature = "docker-e2e")] + +#[path = "docker_vendor_common/mod.rs"] +mod docker_vendor_common; + +use docker_vendor_common::{ + assert_stage_markers, bash_prelude, json_assert_fns, run_in_image, run_in_image_network_none, + skip_if_no_image, stage_patch_fn, +}; + +const IMAGE: &str = "socket-patch-test-pypi:latest"; + +/// Glue the shared bash helpers onto a stage body and pin the uuid. +fn render(stage_body: &str, uuid: &str) -> String { + format!( + "{}{}{}{}", + bash_prelude(), + stage_patch_fn(), + json_assert_fns(), + stage_body + ) + .replace("__UUID__", uuid) +} + +// Distinct lowercase uuids per flavor so a stray cross-suite artifact dir +// can't satisfy another suite's path assert. +const UUID_POETRY: &str = "41414141-4141-4141-8141-414141414141"; +const UUID_PDM: &str = "42424242-4242-4242-8242-424242424242"; +const UUID_PIPENV: &str = "43434343-4343-4343-8343-434343434343"; + +/// Shared bash that stages the six.py marker patch from the installed bytes. +/// `$ORIG` must already point at the in-project venv's `six.py`. Defines +/// `$PURL`, `$WHEEL`-independent snapshots in /workspace/snap, and runs the +/// offline vendor producing /tmp/vendor.json. Caller asserts wiring after. +const STAGE1_VENDOR_COMMON: &str = r#" +[ -f "$ORIG" ] || fail "$ORIG missing after the fixture install" +# Pristine pre-check: without this the post-vendor marker asserts are circular. +grep -q 'SOCKET-PATCH-VENDOR-E2E-MARKER' "$ORIG" \ + && fail "marker already in $ORIG BEFORE patching — fixture not pristine" + +# Marker patch = the ACTUAL installed six.py + a trailing marker comment +# (still valid python). before/after git-blob hashes computed in-container. +cp "$ORIG" /tmp/patched.py +printf '\n# SOCKET-PATCH-VENDOR-E2E-MARKER patch=__UUID__\nSOCKET_PATCH_VENDOR_E2E = "__UUID__"\n' >> /tmp/patched.py +PURL="pkg:pypi/six@1.16.0" +stage_patch "$PURL" "__UUID__" "six.py" "$ORIG" /tmp/patched.py + +# Pre-vendor snapshots: consumed by stage 2/3 byte-identity asserts. +mkdir -p /workspace/snap +sha256sum /tmp/patched.py | cut -d' ' -f1 > /workspace/snap/patched.sha + +# Vendor (fully offline: the blob is staged locally). +socket-patch vendor --json --offline > /tmp/vendor.json 2>/tmp/vendor.err +RC=$?; cat /tmp/vendor.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/vendor.json >&2; fail "vendor exited $RC (expected 0)"; } +assert_json_field /tmp/vendor.json '"status": "success"' +assert_json_field /tmp/vendor.json '"action": "applied"' +assert_json_field /tmp/vendor.json "$PURL" +assert_summary /tmp/vendor.json applied 1 +assert_summary /tmp/vendor.json failed 0 +echo "===VENDOR RUN VERIFIED===" + +# Artifact: wheel under the stable path convention, files[] hash == wheel +# sha256 (the same hash the lock entry carries), plus marker + ledger. +WHEEL=$(ls ".socket/vendor/pypi/__UUID__"/*.whl 2>/dev/null | head -1) +[ -n "$WHEEL" ] || { ls -R .socket/vendor >&2 || true; fail "no wheel under .socket/vendor/pypi/__UUID__/"; } +WHEEL_NAME=$(basename "$WHEEL") +WHEEL_SHA=$(sha256sum "$WHEEL" | cut -d' ' -f1) +echo "$WHEEL_NAME" > /workspace/snap/wheel-name +echo "$WHEEL_SHA" > /workspace/snap/wheel-sha +# six is pure-python → a portable py2.py3-none-any wheel name. +case "$WHEEL_NAME" in six-1.16.0-py2.py3-none-any.whl) ;; *) fail "unexpected wheel name $WHEEL_NAME" ;; esac +[ -f ".socket/vendor/pypi/__UUID__/socket-patch.vendor.json" ] \ + || fail "informational socket-patch.vendor.json marker missing" +[ -f ".socket/vendor/state.json" ] || fail "vendor ledger (.socket/vendor/state.json) missing" +echo "===ARTIFACT VERIFIED===" +"#; + +// ── poetry ──────────────────────────────────────────────────────────────── + +/// Poetry stage 1: `poetry add six==1.16.0` (in-project venv) + marker patch +/// + offline vendor + the lock-only `[[package]]` splice asserts + fresh +/// staging. Poetry's wiring (spike P1/P2): files[] reduced to the single +/// patched-wheel `{file, hash}`, a `[package.source] type="file"` table +/// appended; pyproject.toml + content-hash untouched. +const POETRY_STAGE1: &str = r#" +mkdir -p /workspace/proj && cd /workspace/proj +export SOCKET_OFFLINE=1 +# In-project venv so the crawler finds .venv/lib/pythonX/site-packages/six.py. +export POETRY_VIRTUALENVS_IN_PROJECT=true +export POETRY_CACHE_DIR=/tmp/poetry-cache-warm + +# REAL fixture: poetry add resolves + installs six from pypi into .venv. +poetry init -n --name socket-vendor-capstone >/dev/null 2>&1 || fail "poetry init" +poetry add six==1.16.0 > /tmp/add.log 2>&1 || { cat /tmp/add.log >&2; fail "poetry add six failed"; } +[ -d .venv ] || { ls -la >&2; fail "no in-project .venv after poetry add"; } +ORIG=$(ls .venv/lib/python*/site-packages/six.py 2>/dev/null | head -1) +[ -n "$ORIG" ] || fail "six.py not found in the in-project venv" + +mkdir -p /workspace/snap +cp pyproject.toml /workspace/snap/pyproject.prevendor +cp poetry.lock /workspace/snap/poetry.lock.prevendor + +__VENDOR_COMMON__ + +# Lock wiring (poetry row): the six [[package]] unit now carries the single +# patched-wheel files[] entry whose hash == WHEEL_SHA, plus a +# [package.source] type="file" url pointing at the vendored wheel. +URL=".socket/vendor/pypi/__UUID__/$WHEEL_NAME" +grep -qF "hash = \"sha256:$WHEEL_SHA\"" poetry.lock \ + || { cat poetry.lock >&2; fail "poetry.lock files[] hash != vendored wheel sha256"; } +grep -qF 'type = "file"' poetry.lock || { cat poetry.lock >&2; fail "no [package.source] type=file in poetry.lock"; } +grep -qF "url = \"$URL\"" poetry.lock || { cat poetry.lock >&2; fail "poetry.lock source url is not the vendored wheel"; } +# Single files[] entry for six (the tar.gz + registry wheel were dropped): +N=$(awk '/^name = "six"$/{f=1} f&&/^files = \[/{infiles=1;next} infiles&&/^\]/{infiles=0;f=0} infiles&&/file = /{c++} END{print c+0}' poetry.lock) +[ "$N" = "1" ] || { cat poetry.lock >&2; fail "six files[] has $N entries, expected exactly 1"; } +# pyproject + content-hash are NEVER touched by the poetry lock-only splice. +cmp -s pyproject.toml /workspace/snap/pyproject.prevendor \ + || { diff /workspace/snap/pyproject.prevendor pyproject.toml >&2 || true; fail "vendor must NOT touch pyproject.toml"; } +echo "===LOCK WIRING VERIFIED===" + +# Fresh-checkout staging: ONLY the committable files. +rm -rf /workspace/fresh && mkdir -p /workspace/fresh +cp pyproject.toml poetry.lock /workspace/fresh/ +cp -R .socket /workspace/fresh/.socket +echo "===STAGE1 VERIFIED===" +exit 0 +"#; + +/// Poetry stage 2 (`--network none`): strictest install proof + RED probe. +/// Strictest (spike P2/P7): `poetry check --lock && poetry sync` with a fresh +/// `POETRY_CACHE_DIR` and in-project venv. The RED probe deletes +/// `.socket/vendor` first and requires `poetry sync` to FAIL. +const POETRY_STAGE2: &str = r#" +cd /workspace/fresh +export POETRY_VIRTUALENVS_IN_PROJECT=true + +[ ! -e .venv ] || fail "fresh checkout already has .venv (test bug: uncommittable file copied)" + +# RED PROBE: with the vendored artifact removed, the strictest install MUST +# fail (the relative file:// source resolves to a now-missing wheel). +mv .socket/vendor /tmp/vendor-stash +export POETRY_CACHE_DIR=/tmp/poetry-cache-red +poetry sync --no-root --no-interaction > /tmp/red.log 2>&1 +RED_RC=$? +rm -rf .venv +[ "$RED_RC" -ne 0 ] || { cat /tmp/red.log >&2; fail "RED PROBE VACUOUS: poetry sync SUCCEEDED with .socket/vendor removed"; } +mv /tmp/vendor-stash .socket/vendor +echo "===RED PROBE VERIFIED===" + +# GREEN: cold cache, network cut, the vendored wheel is the only six source. +# `--no-root` because `poetry init` makes a packaged project with no source +# layout; we only care about the dependency (six) install, not the root. +export POETRY_CACHE_DIR=/tmp/poetry-cache-cold +poetry check --lock > /tmp/check.log 2>&1 || { cat /tmp/check.log >&2; fail "poetry check --lock failed"; } +poetry sync --no-root --no-interaction > /tmp/sync.log 2>&1 || { cat /tmp/sync.log >&2; fail "cold-cache offline poetry sync failed"; } +cat /tmp/sync.log >&2 +echo "===FRESH INSTALL VERIFIED===" + +# Runtime proof: six.py installed into the venv is the PATCHED bytes. +SIX=$(ls .venv/lib/python*/site-packages/six.py 2>/dev/null | head -1) +[ -n "$SIX" ] || fail "six.py not installed into the venv" +grep -q 'SOCKET-PATCH-VENDOR-E2E-MARKER' "$SIX" || { head -3 "$SIX" >&2; fail "installed six.py is not patched"; } +[ "$(sha256sum "$SIX" | cut -d' ' -f1)" = "$(cat /workspace/snap/patched.sha)" ] \ + || fail "installed six.py not byte-identical to the patched blob" +OUT=$(poetry run python -c 'import six; print(six.SOCKET_PATCH_VENDOR_E2E)' 2>&1) \ + || { echo "$OUT" >&2; fail "import six probe failed"; } +echo "$OUT" | grep -qF "__UUID__" || { echo "$OUT" >&2; fail "import six did not carry the patch uuid"; } +echo "===RUNTIME MARKER VERIFIED===" +exit 0 +"#; + +/// Poetry stage 3 (`--network none`): idempotent re-vendor → revert +/// (byte-identical lock restore + full `.socket/vendor` removal) → re-vendor. +const POETRY_STAGE3: &str = r#" +cd /workspace/proj +export SOCKET_OFFLINE=1 + +LOCK_SHA_BEFORE=$(sha256sum poetry.lock | cut -d' ' -f1) +socket-patch vendor --json --offline > /tmp/revendor.json 2>/tmp/revendor.err +RC=$?; cat /tmp/revendor.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revendor.json >&2; fail "re-vendor exited $RC"; } +assert_summary /tmp/revendor.json failed 0 +assert_json_field /tmp/revendor.json '"already_vendored"' +[ "$LOCK_SHA_BEFORE" = "$(sha256sum poetry.lock | cut -d' ' -f1)" ] || fail "re-vendor churned poetry.lock" +echo "===IDEMPOTENT VERIFIED===" + +socket-patch vendor --revert --json --offline > /tmp/revert.json 2>/tmp/revert.err +RC=$?; cat /tmp/revert.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revert.json >&2; fail "revert exited $RC"; } +assert_json_field /tmp/revert.json '"status": "success"' +assert_summary /tmp/revert.json removed 1 +cmp -s poetry.lock /workspace/snap/poetry.lock.prevendor \ + || { diff /workspace/snap/poetry.lock.prevendor poetry.lock >&2 || true; fail "revert did not byte-restore poetry.lock"; } +[ ! -e .socket/vendor ] || fail ".socket/vendor must be fully removed after revert" +echo "===REVERT VERIFIED===" + +socket-patch vendor --json --offline > /tmp/revendor2.json 2>/tmp/revendor2.err +RC=$?; cat /tmp/revendor2.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revendor2.json >&2; fail "post-revert re-vendor exited $RC"; } +assert_summary /tmp/revendor2.json applied 1 +assert_summary /tmp/revendor2.json failed 0 +[ -d ".socket/vendor/pypi/__UUID__" ] || fail "re-vendor did not recreate the artifact dir" +grep -qF 'type = "file"' poetry.lock || fail "re-vendor did not rewire poetry.lock" +echo "===REVENDOR VERIFIED===" +exit 0 +"#; + +// ── pdm ─────────────────────────────────────────────────────────────────── + +/// PDM stage 1: `pdm init -n` + `pdm add six==1.16.0` (in-project venv) + +/// marker patch + offline vendor + the lock-only `[[package]]` splice asserts +/// + fresh staging. PDM's wiring (spike D1): a relative `path = "./…"` key +/// inserted after `requires_python`, files[] reduced to the single +/// patched-wheel hash; pyproject.toml + content_hash untouched. +const PDM_STAGE1: &str = r#" +mkdir -p /workspace/proj && cd /workspace/proj +export SOCKET_OFFLINE=1 +export PDM_CACHE_DIR=/tmp/pdm-cache-warm +# In-project venv so the crawler finds .venv/.../site-packages/six.py. +pdm config python.use_venv true >/dev/null 2>&1 + +pdm init -n > /tmp/init.log 2>&1 || { cat /tmp/init.log >&2; fail "pdm init failed"; } +pdm add six==1.16.0 > /tmp/add.log 2>&1 || { cat /tmp/add.log >&2; fail "pdm add six failed"; } +[ -d .venv ] || { ls -la >&2; fail "no in-project .venv after pdm add"; } +ORIG=$(ls .venv/lib/python*/site-packages/six.py 2>/dev/null | head -1) +[ -n "$ORIG" ] || fail "six.py not found in the in-project venv" + +mkdir -p /workspace/snap +cp pyproject.toml /workspace/snap/pyproject.prevendor +cp pdm.lock /workspace/snap/pdm.lock.prevendor + +__VENDOR_COMMON__ + +# Lock wiring (pdm row): a relative path key on six pointing at the vendored +# wheel, and files[] reduced to the single patched-wheel hash == WHEEL_SHA. +grep -qF "path = \"./.socket/vendor/pypi/__UUID__/$WHEEL_NAME\"" pdm.lock \ + || { cat pdm.lock >&2; fail "pdm.lock six entry has no relative path= to the vendored wheel"; } +grep -qF "hash = \"sha256:$WHEEL_SHA\"" pdm.lock \ + || { cat pdm.lock >&2; fail "pdm.lock files[] hash != vendored wheel sha256"; } +N=$(awk '/^name = "six"$/{f=1} f&&/^files = \[/{infiles=1;next} infiles&&/^\]/{infiles=0;f=0} infiles&&/file = /{c++} END{print c+0}' pdm.lock) +[ "$N" = "1" ] || { cat pdm.lock >&2; fail "six files[] has $N entries, expected exactly 1"; } +# pyproject + content_hash are NEVER touched by the pdm lock-only splice. +cmp -s pyproject.toml /workspace/snap/pyproject.prevendor \ + || { diff /workspace/snap/pyproject.prevendor pyproject.toml >&2 || true; fail "vendor must NOT touch pyproject.toml"; } +echo "===LOCK WIRING VERIFIED===" + +rm -rf /workspace/fresh && mkdir -p /workspace/fresh +cp pyproject.toml pdm.lock /workspace/fresh/ +cp -R .socket /workspace/fresh/.socket +echo "===STAGE1 VERIFIED===" +exit 0 +"#; + +/// PDM stage 2 (`--network none`): strictest install proof + RED probe. +/// Strictest (spike D2): `pdm install --check && pdm sync` with a fresh +/// `PDM_CACHE_DIR` and in-project venv. The `.pdm-python` venv pointer is +/// gitignored in real checkouts and not copied here, so the fresh dir +/// re-creates its own venv. RED probe deletes `.socket/vendor` first. +const PDM_STAGE2: &str = r#" +cd /workspace/fresh +pdm config python.use_venv true >/dev/null 2>&1 + +[ ! -e .venv ] || fail "fresh checkout already has .venv (test bug: uncommittable file copied)" +[ ! -e .pdm-python ] || fail "fresh checkout carried a .pdm-python venv pointer (gitignored; should not be committed)" + +# RED PROBE: with the vendored wheel removed, sync MUST fail (path source gone). +mv .socket/vendor /tmp/vendor-stash +export PDM_CACHE_DIR=/tmp/pdm-cache-red +pdm sync > /tmp/red.log 2>&1 +RED_RC=$? +rm -rf .venv .pdm-python +[ "$RED_RC" -ne 0 ] || { cat /tmp/red.log >&2; fail "RED PROBE VACUOUS: pdm sync SUCCEEDED with .socket/vendor removed"; } +mv /tmp/vendor-stash .socket/vendor +echo "===RED PROBE VERIFIED===" + +# GREEN: cold cache, network cut, the vendored wheel is the only six source. +export PDM_CACHE_DIR=/tmp/pdm-cache-cold +pdm install --check > /tmp/check.log 2>&1 || { cat /tmp/check.log >&2; fail "pdm install --check failed"; } +pdm sync > /tmp/sync.log 2>&1 || { cat /tmp/sync.log >&2; fail "cold-cache offline pdm sync failed"; } +cat /tmp/sync.log >&2 +echo "===FRESH INSTALL VERIFIED===" + +SIX=$(ls .venv/lib/python*/site-packages/six.py 2>/dev/null | head -1) +[ -n "$SIX" ] || fail "six.py not installed into the venv" +grep -q 'SOCKET-PATCH-VENDOR-E2E-MARKER' "$SIX" || { head -3 "$SIX" >&2; fail "installed six.py is not patched"; } +[ "$(sha256sum "$SIX" | cut -d' ' -f1)" = "$(cat /workspace/snap/patched.sha)" ] \ + || fail "installed six.py not byte-identical to the patched blob" +OUT=$(pdm run python -c 'import six; print(six.SOCKET_PATCH_VENDOR_E2E)' 2>&1) \ + || { echo "$OUT" >&2; fail "import six probe failed"; } +echo "$OUT" | grep -qF "__UUID__" || { echo "$OUT" >&2; fail "import six did not carry the patch uuid"; } +echo "===RUNTIME MARKER VERIFIED===" +exit 0 +"#; + +/// PDM stage 3 (`--network none`): idempotent → revert → re-vendor. +const PDM_STAGE3: &str = r#" +cd /workspace/proj +export SOCKET_OFFLINE=1 + +LOCK_SHA_BEFORE=$(sha256sum pdm.lock | cut -d' ' -f1) +socket-patch vendor --json --offline > /tmp/revendor.json 2>/tmp/revendor.err +RC=$?; cat /tmp/revendor.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revendor.json >&2; fail "re-vendor exited $RC"; } +assert_summary /tmp/revendor.json failed 0 +assert_json_field /tmp/revendor.json '"already_vendored"' +[ "$LOCK_SHA_BEFORE" = "$(sha256sum pdm.lock | cut -d' ' -f1)" ] || fail "re-vendor churned pdm.lock" +echo "===IDEMPOTENT VERIFIED===" + +socket-patch vendor --revert --json --offline > /tmp/revert.json 2>/tmp/revert.err +RC=$?; cat /tmp/revert.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revert.json >&2; fail "revert exited $RC"; } +assert_json_field /tmp/revert.json '"status": "success"' +assert_summary /tmp/revert.json removed 1 +cmp -s pdm.lock /workspace/snap/pdm.lock.prevendor \ + || { diff /workspace/snap/pdm.lock.prevendor pdm.lock >&2 || true; fail "revert did not byte-restore pdm.lock"; } +[ ! -e .socket/vendor ] || fail ".socket/vendor must be fully removed after revert" +echo "===REVERT VERIFIED===" + +socket-patch vendor --json --offline > /tmp/revendor2.json 2>/tmp/revendor2.err +RC=$?; cat /tmp/revendor2.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revendor2.json >&2; fail "post-revert re-vendor exited $RC"; } +assert_summary /tmp/revendor2.json applied 1 +assert_summary /tmp/revendor2.json failed 0 +[ -d ".socket/vendor/pypi/__UUID__" ] || fail "re-vendor did not recreate the artifact dir" +grep -qF "path = \"./.socket/vendor/pypi/__UUID__/" pdm.lock || fail "re-vendor did not rewire pdm.lock" +echo "===REVENDOR VERIFIED===" +exit 0 +"#; + +// ── pipenv ────────────────────────────────────────────────────────────────── + +/// pipenv stage 1: `pipenv install six==1.16.0` (in-project venv) + marker +/// patch + offline vendor + the lock-only entry rewrite asserts + fresh +/// staging. pipenv's wiring (spike V1/V2): `default.six` becomes +/// `{file: "./", hashes: [sha256:], markers}` (index + +/// version dropped); Pipfile untouched. Also asserts the +/// `vendor_integrity_unverified` warning surfaces in the vendor envelope. +const PIPENV_STAGE1: &str = r#" +mkdir -p /workspace/proj && cd /workspace/proj +export SOCKET_OFFLINE=1 +export PIPENV_VENV_IN_PROJECT=1 +export PIPENV_CACHE_DIR=/tmp/pipenv-cache-warm +export PIP_CACHE_DIR=/tmp/pip-cache-warm + +# REAL fixture: pipenv install resolves + installs six from pypi into .venv. +pipenv install six==1.16.0 > /tmp/install.log 2>&1 || { cat /tmp/install.log >&2; fail "pipenv install six failed"; } +[ -d .venv ] || { ls -la >&2; fail "no in-project .venv after pipenv install"; } +ORIG=$(ls .venv/lib/python*/site-packages/six.py 2>/dev/null | head -1) +[ -n "$ORIG" ] || fail "six.py not found in the in-project venv" + +mkdir -p /workspace/snap +cp Pipfile /workspace/snap/Pipfile.prevendor +cp Pipfile.lock /workspace/snap/Pipfile.lock.prevendor + +__VENDOR_COMMON__ + +# pipenv has NO hash enforcement on file entries (spike V4) — the vendor run +# MUST surface the documented warning as a skipped event in the envelope. +assert_json_field /tmp/vendor.json '"errorCode": "vendor_integrity_unverified"' +echo "===INTEGRITY WARNING VERIFIED===" + +# Lock wiring (pipenv row): default.six is now {file, hashes:[patched], markers} +# with index + version dropped; the recorded hash is WHEEL_SHA; Pipfile is +# untouched. +python3 - "$WHEEL_SHA" "$WHEEL_NAME" <<'PYEOF' || { cat Pipfile.lock >&2; fail "Pipfile.lock six entry wiring wrong"; } +import json, sys +sha, wheel = sys.argv[1], sys.argv[2] +d = json.load(open("Pipfile.lock")) +e = d["default"]["six"] +assert e.get("file") == f"./.socket/vendor/pypi/__UUID__/{wheel}", e +assert e.get("hashes") == [f"sha256:{sha}"], e +assert "index" not in e, e +assert "version" not in e, e +assert "markers" in e, "markers must be preserved" +PYEOF +cmp -s Pipfile /workspace/snap/Pipfile.prevendor \ + || { diff /workspace/snap/Pipfile.prevendor Pipfile >&2 || true; fail "vendor must NOT touch Pipfile"; } +echo "===LOCK WIRING VERIFIED===" + +rm -rf /workspace/fresh && mkdir -p /workspace/fresh +cp Pipfile Pipfile.lock /workspace/fresh/ +cp -R .socket /workspace/fresh/.socket +echo "===STAGE1 VERIFIED===" +exit 0 +"#; + +/// pipenv stage 2 (`--network none`): strictest install proof + RED probe. +/// Strictest (spike V2): `pipenv install --deploy && pipenv verify` with a +/// fresh cache + `PIPENV_VENV_IN_PROJECT=1`. pipenv does NOT hash-verify file +/// entries (spike V4), so the committable proof is "the patched bytes get +/// imported"; the RED probe (delete .socket/vendor) still fails because the +/// referenced wheel is gone (a missing path is a hard pip error, distinct +/// from the hash gap). +const PIPENV_STAGE2: &str = r#" +cd /workspace/fresh +export PIPENV_VENV_IN_PROJECT=1 + +[ ! -e .venv ] || fail "fresh checkout already has .venv (test bug: uncommittable file copied)" + +# RED PROBE: with the vendored wheel removed, --deploy MUST fail (the file ref +# resolves to a missing wheel — a pip "file does not exist" error). +mv .socket/vendor /tmp/vendor-stash +export PIPENV_CACHE_DIR=/tmp/pipenv-cache-red +export PIP_CACHE_DIR=/tmp/pip-cache-red +pipenv install --deploy > /tmp/red.log 2>&1 +RED_RC=$? +rm -rf .venv +[ "$RED_RC" -ne 0 ] || { cat /tmp/red.log >&2; fail "RED PROBE VACUOUS: pipenv install --deploy SUCCEEDED with .socket/vendor removed"; } +mv /tmp/vendor-stash .socket/vendor +echo "===RED PROBE VERIFIED===" + +# GREEN: cold cache, network cut, the vendored wheel is the only six source. +export PIPENV_CACHE_DIR=/tmp/pipenv-cache-cold +export PIP_CACHE_DIR=/tmp/pip-cache-cold +pipenv install --deploy > /tmp/deploy.log 2>&1 || { cat /tmp/deploy.log >&2; fail "cold-cache offline pipenv install --deploy failed"; } +cat /tmp/deploy.log >&2 +pipenv verify > /tmp/verify.log 2>&1 || { cat /tmp/verify.log >&2; fail "pipenv verify failed"; } +echo "===FRESH INSTALL VERIFIED===" + +# Runtime proof: pipenv does NOT enforce the recorded hash, so the proof is +# that the imported six IS the patched bytes (marker present). +SIX=$(ls .venv/lib/python*/site-packages/six.py 2>/dev/null | head -1) +[ -n "$SIX" ] || fail "six.py not installed into the venv" +grep -q 'SOCKET-PATCH-VENDOR-E2E-MARKER' "$SIX" || { head -3 "$SIX" >&2; fail "installed six.py is not patched"; } +[ "$(sha256sum "$SIX" | cut -d' ' -f1)" = "$(cat /workspace/snap/patched.sha)" ] \ + || fail "installed six.py not byte-identical to the patched blob" +OUT=$(pipenv run python -c 'import six; print(six.SOCKET_PATCH_VENDOR_E2E)' 2>&1) \ + || { echo "$OUT" >&2; fail "import six probe failed"; } +echo "$OUT" | grep -qF "__UUID__" || { echo "$OUT" >&2; fail "import six did not carry the patch uuid"; } +echo "===RUNTIME MARKER VERIFIED===" +exit 0 +"#; + +/// pipenv stage 3 (`--network none`): idempotent → revert → re-vendor. +const PIPENV_STAGE3: &str = r#" +cd /workspace/proj +export SOCKET_OFFLINE=1 + +LOCK_SHA_BEFORE=$(sha256sum Pipfile.lock | cut -d' ' -f1) +socket-patch vendor --json --offline > /tmp/revendor.json 2>/tmp/revendor.err +RC=$?; cat /tmp/revendor.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revendor.json >&2; fail "re-vendor exited $RC"; } +assert_summary /tmp/revendor.json failed 0 +assert_json_field /tmp/revendor.json '"already_vendored"' +[ "$LOCK_SHA_BEFORE" = "$(sha256sum Pipfile.lock | cut -d' ' -f1)" ] || fail "re-vendor churned Pipfile.lock" +echo "===IDEMPOTENT VERIFIED===" + +socket-patch vendor --revert --json --offline > /tmp/revert.json 2>/tmp/revert.err +RC=$?; cat /tmp/revert.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revert.json >&2; fail "revert exited $RC"; } +assert_json_field /tmp/revert.json '"status": "success"' +assert_summary /tmp/revert.json removed 1 +cmp -s Pipfile.lock /workspace/snap/Pipfile.lock.prevendor \ + || { diff /workspace/snap/Pipfile.lock.prevendor Pipfile.lock >&2 || true; fail "revert did not byte-restore Pipfile.lock"; } +[ ! -e .socket/vendor ] || fail ".socket/vendor must be fully removed after revert" +echo "===REVERT VERIFIED===" + +socket-patch vendor --json --offline > /tmp/revendor2.json 2>/tmp/revendor2.err +RC=$?; cat /tmp/revendor2.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revendor2.json >&2; fail "post-revert re-vendor exited $RC"; } +assert_summary /tmp/revendor2.json applied 1 +assert_summary /tmp/revendor2.json failed 0 +[ -d ".socket/vendor/pypi/__UUID__" ] || fail "re-vendor did not recreate the artifact dir" +grep -qF '.socket/vendor/pypi/__UUID__/' Pipfile.lock || fail "re-vendor did not rewire Pipfile.lock" +echo "===REVENDOR VERIFIED===" +exit 0 +"#; + +/// Splice the shared vendor body into a flavor stage-1 template, then render. +fn render_stage1(template: &str, uuid: &str) -> String { + render(&template.replace("__VENDOR_COMMON__", STAGE1_VENDOR_COMMON), uuid) +} + +fn host_dir() -> (tempfile::TempDir, std::path::PathBuf) { + let tmp = tempfile::tempdir().expect("tempdir"); + // Canonicalize so the macOS `/var` → `/private/var` symlink doesn't + // confuse Docker Desktop's file-sharing allowlist. + let dir = tmp.path().canonicalize().expect("canonicalize tempdir"); + (tmp, dir) +} + +#[test] +fn poetry_vendor_fresh_checkout_install_and_revert() { + if skip_if_no_image(IMAGE) { + return; + } + let (_tmp, host) = host_dir(); + + let out = run_in_image(IMAGE, &host, &render_stage1(POETRY_STAGE1, UUID_POETRY)); + assert_stage_markers( + "poetry stage 1 (install+vendor)", + &out, + &["VENDOR RUN", "ARTIFACT", "LOCK WIRING", "STAGE1"], + ); + + let out = run_in_image_network_none(IMAGE, &host, &render(POETRY_STAGE2, UUID_POETRY)); + assert_stage_markers( + "poetry stage 2 (fresh checkout, --network none)", + &out, + &["RED PROBE", "FRESH INSTALL", "RUNTIME MARKER"], + ); + + let out = run_in_image_network_none(IMAGE, &host, &render(POETRY_STAGE3, UUID_POETRY)); + assert_stage_markers( + "poetry stage 3 (idempotent+revert+re-vendor)", + &out, + &["IDEMPOTENT", "REVERT", "REVENDOR"], + ); +} + +#[test] +fn pdm_vendor_fresh_checkout_install_and_revert() { + if skip_if_no_image(IMAGE) { + return; + } + let (_tmp, host) = host_dir(); + + let out = run_in_image(IMAGE, &host, &render_stage1(PDM_STAGE1, UUID_PDM)); + assert_stage_markers( + "pdm stage 1 (install+vendor)", + &out, + &["VENDOR RUN", "ARTIFACT", "LOCK WIRING", "STAGE1"], + ); + + let out = run_in_image_network_none(IMAGE, &host, &render(PDM_STAGE2, UUID_PDM)); + assert_stage_markers( + "pdm stage 2 (fresh checkout, --network none)", + &out, + &["RED PROBE", "FRESH INSTALL", "RUNTIME MARKER"], + ); + + let out = run_in_image_network_none(IMAGE, &host, &render(PDM_STAGE3, UUID_PDM)); + assert_stage_markers( + "pdm stage 3 (idempotent+revert+re-vendor)", + &out, + &["IDEMPOTENT", "REVERT", "REVENDOR"], + ); +} + +#[test] +fn pipenv_vendor_fresh_checkout_install_and_revert() { + if skip_if_no_image(IMAGE) { + return; + } + let (_tmp, host) = host_dir(); + + let out = run_in_image(IMAGE, &host, &render_stage1(PIPENV_STAGE1, UUID_PIPENV)); + assert_stage_markers( + "pipenv stage 1 (install+vendor)", + &out, + &[ + "VENDOR RUN", + "ARTIFACT", + "INTEGRITY WARNING", + "LOCK WIRING", + "STAGE1", + ], + ); + + let out = run_in_image_network_none(IMAGE, &host, &render(PIPENV_STAGE2, UUID_PIPENV)); + assert_stage_markers( + "pipenv stage 2 (fresh checkout, --network none)", + &out, + &["RED PROBE", "FRESH INSTALL", "RUNTIME MARKER"], + ); + + let out = run_in_image_network_none(IMAGE, &host, &render(PIPENV_STAGE3, UUID_PIPENV)); + assert_stage_markers( + "pipenv stage 3 (idempotent+revert+re-vendor)", + &out, + &["IDEMPOTENT", "REVERT", "REVENDOR"], + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs new file mode 100644 index 0000000..3210a51 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs @@ -0,0 +1,344 @@ +//! Real-bun capstone e2e for `socket-patch vendor` — the committability +//! proof for the bun (text `bun.lock`) flavor. +//! +//! Drives the REAL `bun` (network used for fixture setup only): +//! 1. `bun install` of left-pad@1.3.0 into a tempdir (private +//! `BUN_INSTALL_CACHE_DIR`). bun 1.3.x writes the text `bun.lock` by +//! default; `--save-text-lockfile` is passed as a belt-and-braces guard +//! against a future binary-lockfile default. +//! 2. Hand-stage a `.socket/` manifest + blob from the ACTUAL installed +//! bytes (a marker comment prepended to `index.js`). +//! 3. `socket-patch vendor --json --offline` — assert the deterministic +//! tarball lands at `.socket/vendor/npm//…` and the bun.lock +//! `packages` entry is rewritten from the registry 4-tuple to the +//! local-tarball 3-tuple `["@", {deps}, "sha512-"]` +//! (spike BN1/BN3). package.json is left UNTOUCHED. +//! 4. **Fresh-checkout proof**: copy ONLY the committable files +//! (package.json + bun.lock + .socket/) to a new dir, an EMPTY +//! `BUN_INSTALL_CACHE_DIR`, and run the spike's strictest invocation +//! `bun install --frozen-lockfile` — the patched bytes MUST be what bun +//! installs (BN7). +//! 5. Idempotency: re-running vendor leaves bun.lock byte-identical. +//! 6. **Revert proof**: `vendor --revert` restores bun.lock byte-for-byte +//! and removes `.socket/vendor/` entirely. +//! +//! LOCAL capstone (not behind docker-e2e): skips with a `println` + return +//! when `bun` is unavailable or the fixture install cannot reach the +//! registry; every assertion after that is HARD. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use sha2::{Digest, Sha256}; + +const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; +const MARKER: &str = "/* SOCKET-PATCHED */\n"; +const DEP: &str = "left-pad"; +const DEP_VERSION: &str = "1.3.0"; + +// ── self-contained helpers ──────────────────────────────────────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +fn has_command(cmd: &str) -> bool { + Command::new(cmd) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Run `bun ` in `cwd` with the given private cache dir and every +/// `SOCKET_*` var scrubbed. +fn bun(cwd: &Path, args: &[&str], cache_dir: &Path) -> Output { + let mut cmd = Command::new("bun"); + cmd.args(args) + .current_dir(cwd) + .env("BUN_INSTALL_CACHE_DIR", cache_dir); + scrub_socket_env(&mut cmd); + cmd.output().expect("failed to run bun") +} + +/// Remove ambient `SOCKET_*` vars and the bun cache env the harness controls +/// (always passed explicitly). +fn scrub_socket_env(cmd: &mut Command) { + for (k, _) in std::env::vars_os() { + let k = k.to_string_lossy(); + if k.starts_with("SOCKET_") { + cmd.env_remove(k.as_ref()); + } + } + cmd.env_remove("VIRTUAL_ENV"); + cmd.env_remove("BUN_INSTALL_CACHE_DIR"); +} + +fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + scrub_socket_env(&mut cmd); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +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 stage_patch(proj: &Path, purl: &str, file_key: &str, before: &[u8], after: &[u8]) { + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = serde_json::json!({ + "patches": { purl: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { file_key: { + "beforeHash": git_sha256(before), + "afterHash": git_sha256(after), + }}, + "vulnerabilities": {}, + "description": "capstone marker patch", + "license": "MIT", + "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(after)), after).unwrap(); +} + +fn parse_envelope(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout) + .unwrap_or_else(|e| panic!("vendor --json output is not JSON: {e}\nstdout:\n{stdout}")) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +// ── the capstone ────────────────────────────────────────────────────── + +#[test] +fn bun_vendor_fresh_checkout_frozen_install_and_revert() { + if !has_command("bun") { + println!("SKIP e2e_vendor_bun_build: `bun` not installed"); + return; + } + + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("package.json"), + format!( + r#"{{"name":"bun-capstone","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# + ), + ) + .unwrap(); + + // 1. REAL fixture: bun install (network allowed here, private cache). + // `--save-text-lockfile` guarantees the text bun.lock vendor wires + // (bun 1.3.x already defaults to it; the flag future-proofs the test). + let cache = tmp.path().join("bun-cache"); + let install = bun( + &proj, + &["install", "--save-text-lockfile"], + &cache, + ); + if !install.status.success() { + println!( + "SKIP e2e_vendor_bun_build: fixture `bun install` failed (registry \ + unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return; + } + let lock_path = proj.join("bun.lock"); + if !lock_path.is_file() { + println!( + "SKIP e2e_vendor_bun_build: bun produced no text bun.lock (binary lockfile?) — \ + this bun version's default lockfile is not the wirable text form" + ); + return; + } + + let installed_index = proj.join("node_modules").join(DEP).join("index.js"); + let orig = std::fs::read(&installed_index).expect("installed index.js"); + assert!( + !orig.starts_with(MARKER.as_bytes()), + "pristine install must not carry the marker" + ); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); + + stage_patch(&proj, &purl, "package/index.js", &orig, &patched); + + let pkg_path = proj.join("package.json"); + let lock_before = std::fs::read(&lock_path).expect("bun.lock after bun install"); + let pkg_before = std::fs::read(&pkg_path).expect("package.json"); + let lock_before_str = String::from_utf8(lock_before.clone()).unwrap(); + assert!( + lock_before_str.contains("\"lockfileVersion\": 1"), + "fixture must be a bun text lockfileVersion 1:\n{lock_before_str}" + ); + // Pre-vendor: the registry 4-tuple `["left-pad@1.3.0", "", {}, "sha512-…"]`. + assert!( + lock_before_str.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), + "pre-vendor packages entry must be the registry 4-tuple:\n{lock_before_str}" + ); + + // 3. Vendor (offline). + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + let applied = env["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["action"] == "applied" && e["purl"] == purl.as_str()) + .unwrap_or_else(|| panic!("expected an applied event for {purl}: {env}")); + assert!(applied.get("errorCode").is_none(), "clean apply event: {applied}"); + + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); + assert!(proj.join(&tgz_rel).is_file(), "vendored tarball missing at {tgz_rel}"); + assert!( + proj.join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) + .is_file(), + "informational vendor marker missing" + ); + assert!( + proj.join(".socket/vendor/state.json").is_file(), + "vendor ledger missing" + ); + + // bun.lock packages entry rewritten to the local-tarball 3-tuple: + // element 0 = `@` (no `file:`/`./`), the deps object + // shifts to index 1, integrity is the recomputed sha512 of OUR tarball. + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + assert!( + lock_after.contains(&format!("\"{DEP}@{tgz_rel}\", {{}}, \"sha512-")), + "bun.lock packages entry must be the local-tarball 3-tuple; got:\n{lock_after}" + ); + assert!( + !lock_after.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), + "the registry 4-tuple must be gone after the rewrite:\n{lock_after}" + ); + assert!( + !lock_after.contains( + "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==" + ), + "the inherited registry integrity must NOT survive the rewrite:\n{lock_after}" + ); + // package.json is left untouched by the lock-only bun wiring. + assert_eq!( + std::fs::read(&pkg_path).unwrap(), + pkg_before, + "bun vendoring is lock-only; package.json must stay byte-identical" + ); + eprintln!("VENDOR OK"); + + // 4. FRESH-CHECKOUT PROOF: committable files only, EMPTY cache, + // spike-proven `--frozen-lockfile`. + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(&pkg_path, fresh.join("package.json")).unwrap(); + std::fs::copy(&lock_path, fresh.join("bun.lock")).unwrap(); + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + + let fresh_cache = tmp.path().join("fresh-bun-cache"); + let ci = bun(&fresh, &["install", "--frozen-lockfile"], &fresh_cache); + assert!( + ci.status.success(), + "fresh-checkout `bun install --frozen-lockfile` must succeed from the vendored \ + tarball.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let fresh_installed = + std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert!( + fresh_installed.starts_with(MARKER.as_bytes()), + "bun must install the PATCHED bytes from the vendored tarball; got:\n{}", + String::from_utf8_lossy(&fresh_installed[..fresh_installed.len().min(120)]) + ); + assert_eq!( + fresh_installed, patched, + "fresh install must be byte-identical to the patched content" + ); + // --frozen-lockfile would have errored if the lock drifted; prove it + // left the committed lock byte-stable. + assert_eq!( + std::fs::read(fresh.join("bun.lock")).unwrap(), + std::fs::read(&lock_path).unwrap(), + "--frozen-lockfile install must leave bun.lock byte-identical" + ); + eprintln!("FRESH INSTALL OK"); + + // 5. Idempotency: a re-run exits 0 and leaves bun.lock byte-stable. + let lock_wired = std::fs::read(&lock_path).unwrap(); + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env2 = parse_envelope(&stdout); + assert_eq!(env2["summary"]["failed"], 0, "re-run must not fail: {env2}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_wired, + "re-vendor must leave bun.lock byte-identical" + ); + + // 6. REVERT PROOF: bun.lock restored byte-for-byte, artifacts gone. + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--revert", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "revert envelope: {renv}"); + assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_before, + "revert must restore bun.lock byte-identical to the pre-vendor snapshot" + ); + assert_eq!( + std::fs::read(&pkg_path).unwrap(), + pkg_before, + "revert must leave package.json byte-identical" + ); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); + eprintln!("REVERT OK"); +} diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs new file mode 100644 index 0000000..88bdb23 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -0,0 +1,376 @@ +//! Real-pnpm capstone e2e for `socket-patch vendor` — the committability +//! proof for the pnpm (lockfileVersion 9.0) flavor. +//! +//! Drives the REAL `corepack pnpm@10` (and pnpm@9 when fetchable — both emit +//! byte-identical 9.0 locks, spike P1/P2): +//! 1. `pnpm install` of left-pad@1.3.0 into a tempdir (private `--store-dir`). +//! 2. Hand-stage a `.socket/` manifest + blob from the ACTUAL installed +//! bytes (a marker comment prepended to `index.js`). +//! 3. `socket-patch vendor --json --offline` — assert the deterministic +//! tarball lands at `.socket/vendor/npm//…`, the root package.json +//! gains `pnpm.overrides`, and pnpm-lock.yaml carries the file: +//! resolution (spike P1: importer specifier+version rewritten, packages +//! entry rekeyed with the recomputed integrity). +//! 4. **Fresh-checkout proof**: copy ONLY the committable files +//! (package.json + pnpm-lock.yaml + .socket/) to a new dir, an EMPTY +//! `--store-dir`, and run the spike's strictest invocation +//! `pnpm install --frozen-lockfile --offline` — the patched bytes MUST +//! be what pnpm installs (P4). +//! 5. Idempotency: re-running vendor leaves both files byte-identical. +//! 6. **Revert proof**: `vendor --revert` restores package.json AND +//! pnpm-lock.yaml byte-for-byte and removes `.socket/vendor/`. +//! +//! LOCAL capstone (not behind docker-e2e): skips with a `println` + return +//! when `corepack pnpm@10` is unavailable or the fixture install cannot reach +//! the registry; every assertion after that is HARD. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use sha2::{Digest, Sha256}; + +const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; +const MARKER: &str = "/* SOCKET-PATCHED */\n"; +const DEP: &str = "left-pad"; +const DEP_VERSION: &str = "1.3.0"; +/// Pinned pnpm majors via corepack — @10 is required, @9 is run too when +/// fetchable (the spike proved both emit byte-identical 9.0 locks). +const PNPM_PRIMARY: &str = "pnpm@10"; +const PNPM_SECONDARY: &str = "pnpm@9"; + +// ── self-contained helpers ──────────────────────────────────────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +fn has_corepack_pm(pm: &str) -> bool { + Command::new("corepack") + .args([pm, "--version"]) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn corepack(cwd: &Path, pm: &str, args: &[&str]) -> Output { + let mut cmd = Command::new("corepack"); + cmd.arg(pm) + .args(args) + .current_dir(cwd) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + scrub_socket_env(&mut cmd); + cmd.output().expect("failed to run corepack") +} + +/// Remove ambient `SOCKET_*` vars and the pnpm store env the harness controls +/// (the `--store-dir` flag is always passed explicitly). +fn scrub_socket_env(cmd: &mut Command) { + for (k, _) in std::env::vars_os() { + let k = k.to_string_lossy(); + if k.starts_with("SOCKET_") { + cmd.env_remove(k.as_ref()); + } + } + cmd.env_remove("VIRTUAL_ENV"); + cmd.env_remove("PNPM_HOME"); + cmd.env_remove("npm_config_store_dir"); +} + +fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + scrub_socket_env(&mut cmd); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +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 stage_patch(proj: &Path, purl: &str, file_key: &str, before: &[u8], after: &[u8]) { + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = serde_json::json!({ + "patches": { purl: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { file_key: { + "beforeHash": git_sha256(before), + "afterHash": git_sha256(after), + }}, + "vulnerabilities": {}, + "description": "capstone marker patch", + "license": "MIT", + "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(after)), after).unwrap(); +} + +fn parse_envelope(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout) + .unwrap_or_else(|e| panic!("vendor --json output is not JSON: {e}\nstdout:\n{stdout}")) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +// ── the capstone ────────────────────────────────────────────────────── + +#[test] +fn pnpm_vendor_fresh_checkout_frozen_offline_install_and_revert() { + if !has_corepack_pm(PNPM_PRIMARY) { + println!( + "SKIP e2e_vendor_pnpm_build: `corepack {PNPM_PRIMARY}` unavailable \ + (corepack not installed or pnpm not fetchable)" + ); + return; + } + run_pnpm_capstone(PNPM_PRIMARY); + + // Cheap bonus coverage: pnpm 9 emits a byte-identical 9.0 lock (spike P1), + // so run the whole lifecycle again on it when it is fetchable. Never a + // skip-failure — @10 already carried the hard assertions. + if has_corepack_pm(PNPM_SECONDARY) { + eprintln!("--- also exercising {PNPM_SECONDARY} ---"); + run_pnpm_capstone(PNPM_SECONDARY); + } else { + eprintln!("note: {PNPM_SECONDARY} not fetchable; ran {PNPM_PRIMARY} only"); + } +} + +fn run_pnpm_capstone(pm: &str) { + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + // Author package.json in the SAME shape pnpm's vendor edit reserializes + // (serde_json pretty, 2-space, trailing newline) so the vendor→revert + // round trip is byte-identical (pnpm — unlike yarn berry — does not + // rewrite package.json on install). + let pkg_doc = serde_json::json!({ + "name": "pnpm-capstone", + "version": "0.0.0", + "private": true, + "dependencies": { DEP: DEP_VERSION }, + }); + std::fs::write( + proj.join("package.json"), + format!("{}\n", serde_json::to_string_pretty(&pkg_doc).unwrap()), + ) + .unwrap(); + + // 1. REAL fixture: pnpm install (network allowed here, private store). + let store = tmp.path().join("pnpm-store"); + let install = corepack( + &proj, + pm, + &["install", "--store-dir", store.to_str().unwrap()], + ); + if !install.status.success() { + println!( + "SKIP e2e_vendor_pnpm_build ({pm}): fixture `pnpm install` failed (registry \ + unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return; + } + + let installed_index = proj.join("node_modules").join(DEP).join("index.js"); + let orig = std::fs::read(&installed_index).expect("installed index.js"); + assert!( + !orig.starts_with(MARKER.as_bytes()), + "pristine install must not carry the marker" + ); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); + + stage_patch(&proj, &purl, "package/index.js", &orig, &patched); + + let lock_path = proj.join("pnpm-lock.yaml"); + let pkg_path = proj.join("package.json"); + let lock_before = std::fs::read(&lock_path).expect("pnpm-lock.yaml after pnpm install"); + let pkg_before = std::fs::read(&pkg_path).expect("package.json"); + let lock_before_str = String::from_utf8(lock_before.clone()).unwrap(); + assert!( + lock_before_str.contains("lockfileVersion: '9.0'"), + "fixture must be a lockfileVersion 9.0 lock:\n{lock_before_str}" + ); + + // 3. Vendor (offline). + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "vendor failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + let applied = env["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["action"] == "applied" && e["purl"] == purl.as_str()) + .unwrap_or_else(|| panic!("expected an applied event for {purl}: {env}")); + assert!(applied.get("errorCode").is_none(), "clean apply event: {applied}"); + + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); + assert!(proj.join(&tgz_rel).is_file(), "vendored tarball missing at {tgz_rel}"); + assert!( + proj.join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) + .is_file(), + "informational vendor marker missing" + ); + assert!( + proj.join(".socket/vendor/state.json").is_file(), + "vendor ledger missing" + ); + + // package.json gained `pnpm.overrides` with a VERSIONED selector pointing + // at the vendored tarball (spike P1; pnpm spells the target `file:` with no `./`). + let pkg_json: serde_json::Value = + serde_json::from_slice(&std::fs::read(&pkg_path).unwrap()).unwrap(); + assert_eq!( + pkg_json["pnpm"]["overrides"][format!("{DEP}@{DEP_VERSION}")].as_str(), + Some(format!("file:{tgz_rel}").as_str()), + "package.json must gain pnpm.overrides: {pkg_json}" + ); + + // pnpm-lock.yaml carries the file: resolution (overrides section + + // rekeyed packages entry). + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + assert!( + lock_after.contains(&format!("{DEP}@{DEP_VERSION}: file:{tgz_rel}")), + "lock `overrides:` must point at the vendored tarball; got:\n{lock_after}" + ); + assert!( + lock_after.contains(&format!("{DEP}@file:{tgz_rel}:")), + "lock packages entry must be rekeyed to the file: tarball; got:\n{lock_after}" + ); + assert!( + lock_after.contains(&format!("tarball: file:{tgz_rel}")), + "lock resolution must carry the file: tarball key; got:\n{lock_after}" + ); + // The recomputed integrity is OUR tarball's sha512, never the inherited + // registry one. + assert!( + !lock_after.contains( + "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==" + ), + "the inherited registry integrity must NOT survive the rewrite:\n{lock_after}" + ); + eprintln!("VENDOR OK ({pm})"); + + // 4. FRESH-CHECKOUT PROOF: committable files only, EMPTY store, + // spike-proven `--frozen-lockfile --offline`. + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(&pkg_path, fresh.join("package.json")).unwrap(); + std::fs::copy(&lock_path, fresh.join("pnpm-lock.yaml")).unwrap(); + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + + let fresh_store = tmp.path().join("fresh-pnpm-store"); + let ci = corepack( + &fresh, + pm, + &[ + "install", + "--frozen-lockfile", + "--offline", + "--store-dir", + fresh_store.to_str().unwrap(), + ], + ); + assert!( + ci.status.success(), + "fresh-checkout `pnpm install --frozen-lockfile --offline` must succeed from the \ + vendored tarball ({pm}).\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let fresh_installed = + std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert!( + fresh_installed.starts_with(MARKER.as_bytes()), + "pnpm must install the PATCHED bytes from the vendored tarball; got:\n{}", + String::from_utf8_lossy(&fresh_installed[..fresh_installed.len().min(120)]) + ); + assert_eq!( + fresh_installed, patched, + "fresh install must be byte-identical to the patched content" + ); + eprintln!("FRESH INSTALL OK ({pm})"); + + // 5. Idempotency: a re-run exits 0 and leaves BOTH files byte-stable. + let lock_wired = std::fs::read(&lock_path).unwrap(); + let pkg_wired = std::fs::read(&pkg_path).unwrap(); + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "re-vendor failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env2 = parse_envelope(&stdout); + assert_eq!(env2["summary"]["failed"], 0, "re-run must not fail: {env2}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_wired, + "re-vendor must leave pnpm-lock.yaml byte-identical" + ); + assert_eq!( + std::fs::read(&pkg_path).unwrap(), + pkg_wired, + "re-vendor must leave package.json byte-identical" + ); + + // 6. REVERT PROOF: package.json AND pnpm-lock.yaml restored byte-for-byte. + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--revert", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "revert failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "revert envelope: {renv}"); + assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_before, + "revert must restore pnpm-lock.yaml byte-identical to the pre-vendor snapshot" + ); + assert_eq!( + std::fs::read(&pkg_path).unwrap(), + pkg_before, + "revert must restore package.json byte-identical to the pre-vendor snapshot" + ); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); + eprintln!("REVERT OK ({pm})"); +} diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs new file mode 100644 index 0000000..6ef1a7e --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs @@ -0,0 +1,391 @@ +//! Real-yarn-berry capstone e2e for `socket-patch vendor` — the +//! committability proof for the yarn berry 4.x (node-modules linker) flavor. +//! +//! Drives the REAL `corepack yarn@4.x` (network used for fixture setup only): +//! 1. `yarn install` of left-pad@1.3.0 into a tempdir whose `.yarnrc.yml` +//! pins `nodeLinker: node-modules` + `enableGlobalCache: false` (the +//! cacheKey-10c0 / compressionLevel-0 default the spike B2/B4 proved is +//! offline-reproducible). +//! 2. Hand-stage a `.socket/` manifest + blob from the ACTUAL installed +//! bytes (a marker comment prepended to `index.js`). +//! 3. `socket-patch vendor --json --offline` — assert the deterministic +//! tarball lands at `.socket/vendor/npm//…`, the root package.json +//! gains a `resolutions` entry, and yarn.lock has the `file:` resolution +//! entry with a `checksum: 10c0/` (spike B3 — the checksum is the +//! sha512 of the reproduced cache zip). +//! 4. **Fresh-checkout proof**: copy ONLY the committable files +//! (package.json + yarn.lock + .yarnrc.yml + .socket/) to a new dir, an +//! EMPTY global cache, and run the spike's strictest invocation +//! `corepack yarn install --immutable --check-cache` — the patched bytes +//! MUST be what yarn installs (B5). +//! 5. Idempotency: re-running vendor leaves both files byte-identical. +//! 6. **Revert proof**: `vendor --revert` restores package.json AND +//! yarn.lock byte-for-byte and removes `.socket/vendor/` entirely. +//! +//! LOCAL capstone (not behind docker-e2e): skips with a `println` + return +//! when `corepack` (yarn berry) is unavailable or the fixture install cannot +//! reach the registry; every assertion after that is HARD. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use sha2::{Digest, Sha256}; + +const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; +const MARKER: &str = "/* SOCKET-PATCHED */\n"; +const DEP: &str = "left-pad"; +const DEP_VERSION: &str = "1.3.0"; +/// Pinned yarn berry via corepack (matches the spike's 4.x). +const YARN_BERRY: &str = "yarn@4.12.0"; + +// ── self-contained helpers ──────────────────────────────────────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +fn has_corepack_pm(pm: &str) -> bool { + Command::new("corepack") + .args([pm, "--version"]) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> Output { + let mut cmd = Command::new("corepack"); + cmd.arg(pm) + .args(args) + .current_dir(cwd) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + for (k, v) in extra_env { + cmd.env(k, v); + } + scrub_socket_env(&mut cmd); + cmd.output().expect("failed to run corepack") +} + +/// Remove ambient `SOCKET_*` vars and the yarn cache/global env the harness +/// controls (so a developer's settings can't leak into the child). +fn scrub_socket_env(cmd: &mut Command) { + for (k, _) in std::env::vars_os() { + let k = k.to_string_lossy(); + if k.starts_with("SOCKET_") { + cmd.env_remove(k.as_ref()); + } + } + cmd.env_remove("VIRTUAL_ENV"); + for v in [ + "YARN_CACHE_FOLDER", + "YARN_GLOBAL_FOLDER", + "YARN_ENABLE_GLOBAL_CACHE", + ] { + cmd.env_remove(v); + } +} + +fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + scrub_socket_env(&mut cmd); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +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 stage_patch(proj: &Path, purl: &str, file_key: &str, before: &[u8], after: &[u8]) { + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = serde_json::json!({ + "patches": { purl: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { file_key: { + "beforeHash": git_sha256(before), + "afterHash": git_sha256(after), + }}, + "vulnerabilities": {}, + "description": "capstone marker patch", + "license": "MIT", + "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(after)), after).unwrap(); +} + +fn parse_envelope(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout) + .unwrap_or_else(|e| panic!("vendor --json output is not JSON: {e}\nstdout:\n{stdout}")) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +// ── the capstone ────────────────────────────────────────────────────── + +#[test] +fn yarn_berry_vendor_fresh_checkout_immutable_check_cache_and_revert() { + if !has_corepack_pm(YARN_BERRY) { + println!( + "SKIP e2e_vendor_yarn_berry_build: `corepack {YARN_BERRY}` unavailable \ + (corepack not installed or yarn berry not fetchable)" + ); + return; + } + + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("package.json"), + format!( + r#"{{"name":"yarn-berry-capstone","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# + ), + ) + .unwrap(); + // node-modules linker + the cacheKey-10c0 / compressionLevel-0 default + // (the only checksum recipe vendor reproduces offline — spike B4). + std::fs::write( + proj.join(".yarnrc.yml"), + "nodeLinker: node-modules\nenableGlobalCache: false\n", + ) + .unwrap(); + + // 1. REAL fixture: yarn berry install (network allowed here, private + // global cache). + let global = tmp.path().join("yarn-global"); + let install = corepack( + &proj, + YARN_BERRY, + &["install"], + &[("YARN_GLOBAL_FOLDER", global.to_str().unwrap())], + ); + if !install.status.success() { + println!( + "SKIP e2e_vendor_yarn_berry_build: fixture `yarn install` failed (registry \ + unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return; + } + + let installed_index = proj.join("node_modules").join(DEP).join("index.js"); + let orig = std::fs::read(&installed_index).expect("installed index.js"); + assert!( + !orig.starts_with(MARKER.as_bytes()), + "pristine install must not carry the marker" + ); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); + + stage_patch(&proj, &purl, "package/index.js", &orig, &patched); + + // Snapshot the COMMITTABLE files exactly as they sit post-install. Note + // berry rewrites package.json (compact → pretty) during install, so the + // pre-vendor truth is the on-disk bytes, not what we authored. + let lock_path = proj.join("yarn.lock"); + let pkg_path = proj.join("package.json"); + let lock_before = std::fs::read(&lock_path).expect("yarn.lock after yarn install"); + let pkg_before = std::fs::read(&pkg_path).expect("package.json after yarn install"); + let lock_before_str = String::from_utf8(lock_before.clone()).unwrap(); + assert!( + lock_before_str.contains("__metadata:") && lock_before_str.contains("cacheKey: 10c0"), + "fixture must be a berry cacheKey-10c0 lock:\n{lock_before_str}" + ); + assert!( + lock_before_str.contains("\"left-pad@npm:1.3.0\""), + "pre-vendor lock must carry the registry `npm:` resolution" + ); + + // 3. Vendor (offline). + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + let applied = env["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["action"] == "applied" && e["purl"] == purl.as_str()) + .unwrap_or_else(|| panic!("expected an applied event for {purl}: {env}")); + assert!(applied.get("errorCode").is_none(), "clean apply event: {applied}"); + + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); + assert!( + proj.join(&tgz_rel).is_file(), + "vendored tarball missing at {tgz_rel}" + ); + assert!( + proj.join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) + .is_file(), + "informational vendor marker missing" + ); + assert!( + proj.join(".socket/vendor/state.json").is_file(), + "vendor ledger missing" + ); + + // package.json gained a `resolutions` entry pointing at the vendored + // tarball (the dependency range is left untouched — spike B3). + let pkg_json: serde_json::Value = + serde_json::from_slice(&std::fs::read(&pkg_path).unwrap()).unwrap(); + assert_eq!( + pkg_json["resolutions"][DEP].as_str(), + Some(format!("file:./{tgz_rel}").as_str()), + "package.json must gain the resolutions entry: {pkg_json}" + ); + assert_eq!( + pkg_json["dependencies"][DEP].as_str(), + Some(DEP_VERSION), + "the dependency range must stay registry-form" + ); + + // yarn.lock has the file: resolution entry with a `checksum: 10c0/` + // (the reproduced cache-zip sha512) and the registry `npm:` entry gone. + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + assert!( + lock_after.contains(&format!("left-pad@file:./{tgz_rel}::locator=")), + "yarn.lock must carry the file: locator entry; got:\n{lock_after}" + ); + let checksum_line = lock_after + .lines() + .map(str::trim) + .find(|l| l.starts_with("checksum: 10c0/")) + .unwrap_or_else(|| panic!("yarn.lock must carry a `checksum: 10c0/` line:\n{lock_after}")); + let checksum_hex = checksum_line.trim_start_matches("checksum: 10c0/"); + assert_eq!(checksum_hex.len(), 128, "sha512 hex is 128 chars: {checksum_line}"); + assert!( + checksum_hex.bytes().all(|b| b.is_ascii_hexdigit()), + "checksum body must be hex: {checksum_line}" + ); + assert!( + !lock_after.contains("\"left-pad@npm:1.3.0\""), + "the registry `npm:` resolution must be replaced by the file: entry:\n{lock_after}" + ); + eprintln!("VENDOR OK"); + + // 4. FRESH-CHECKOUT PROOF: only the committable files, EMPTY global cache, + // spike-proven strictest invocation `--immutable --check-cache`. + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(&pkg_path, fresh.join("package.json")).unwrap(); + std::fs::copy(&lock_path, fresh.join("yarn.lock")).unwrap(); + std::fs::copy(proj.join(".yarnrc.yml"), fresh.join(".yarnrc.yml")).unwrap(); + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + + let fresh_global = tmp.path().join("fresh-yarn-global"); + let ci = corepack( + &fresh, + YARN_BERRY, + &["install", "--immutable", "--check-cache"], + &[ + ("YARN_GLOBAL_FOLDER", fresh_global.to_str().unwrap()), + ("YARN_ENABLE_GLOBAL_CACHE", "false"), + ], + ); + assert!( + ci.status.success(), + "fresh-checkout `yarn install --immutable --check-cache` must succeed from the \ + vendored tarball.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let fresh_installed = + std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert!( + fresh_installed.starts_with(MARKER.as_bytes()), + "yarn must install the PATCHED bytes from the vendored tarball; got:\n{}", + String::from_utf8_lossy(&fresh_installed[..fresh_installed.len().min(120)]) + ); + assert_eq!( + fresh_installed, patched, + "fresh install must be byte-identical to the patched content" + ); + // --immutable would have errored if our checksum diverged from the + // reproduced cache zip; prove it left the committed lock byte-stable. + assert_eq!( + std::fs::read(fresh.join("yarn.lock")).unwrap(), + std::fs::read(&lock_path).unwrap(), + "--immutable install must leave yarn.lock byte-identical" + ); + eprintln!("FRESH INSTALL OK"); + + // 5. Idempotency: a re-run exits 0 and leaves BOTH files byte-stable. + let lock_wired = std::fs::read(&lock_path).unwrap(); + let pkg_wired = std::fs::read(&pkg_path).unwrap(); + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env2 = parse_envelope(&stdout); + assert_eq!(env2["summary"]["failed"], 0, "re-run must not fail: {env2}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_wired, + "re-vendor must leave yarn.lock byte-identical" + ); + assert_eq!( + std::fs::read(&pkg_path).unwrap(), + pkg_wired, + "re-vendor must leave package.json byte-identical" + ); + + // 6. REVERT PROOF: package.json AND yarn.lock restored byte-for-byte. + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--revert", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "revert envelope: {renv}"); + assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_before, + "revert must restore yarn.lock byte-identical to the pre-vendor snapshot" + ); + assert_eq!( + std::fs::read(&pkg_path).unwrap(), + pkg_before, + "revert must restore package.json byte-identical to the pre-vendor snapshot" + ); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); + eprintln!("REVERT OK"); +} diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs new file mode 100644 index 0000000..d74bab9 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs @@ -0,0 +1,378 @@ +//! Real-yarn-classic capstone e2e for `socket-patch vendor` — the +//! committability proof for the yarn classic (v1 lockfile) flavor. +//! +//! Drives the REAL `corepack yarn@1.22.22` (network used for fixture setup +//! only): +//! 1. `yarn install` of a single dep (left-pad@1.3.0) into a tempdir. +//! 2. Hand-stage a `.socket/` manifest + blob whose before/after Git-blob +//! hashes are computed from the ACTUAL installed bytes (a marker comment +//! prepended to `index.js`). +//! 3. `socket-patch vendor --json --offline` (the real binary) — assert the +//! deterministic tarball lands at `.socket/vendor/npm//…` and the +//! `yarn.lock` block is rewired to +//! `resolved "file:./.socket/vendor/npm//left-pad-1.3.0.tgz#"` +//! plus a recomputed `integrity sha512-…` line (spike Y2/Y6). +//! 4. **Fresh-checkout proof**: copy ONLY the committable files +//! (package.json + yarn.lock + .socket/) to a new dir, point +//! `YARN_CACHE_FOLDER` at an EMPTY dir, and run +//! `corepack yarn install --frozen-lockfile --offline` — the patched +//! bytes MUST be what yarn installs. +//! 5. Idempotency: re-running vendor leaves yarn.lock byte-identical. +//! 6. **Revert proof**: `vendor --revert` restores yarn.lock byte-for-byte +//! to the pre-vendor snapshot and removes `.socket/vendor/` entirely. +//! +//! LOCAL capstone (not behind docker-e2e): skips with a `println` + return +//! when `corepack` (yarn classic) is unavailable or the fixture install +//! cannot reach the registry; every assertion after that is HARD. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use sha2::{Digest, Sha256}; + +/// Canonical lowercase patch uuid (a dedicated path level under +/// `.socket/vendor/npm/`). +const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; +/// Marker prepended to the dep's entry point by the synthetic patch. +const MARKER: &str = "/* SOCKET-PATCHED */\n"; +const DEP: &str = "left-pad"; +const DEP_VERSION: &str = "1.3.0"; +/// Pinned yarn classic via corepack (matches the spike). +const YARN_CLASSIC: &str = "yarn@1.22.22"; + +// ── self-contained helpers ──────────────────────────────────────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +/// `corepack --version` succeeds — the only liveness probe that +/// distinguishes "corepack present" from "this yarn flavor is fetchable". +fn has_corepack_pm(pm: &str) -> bool { + Command::new("corepack") + .args([pm, "--version"]) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Run `corepack ` in `cwd` with the given extra env, the download +/// prompt disabled, and every `SOCKET_*` var scrubbed. +fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> Output { + let mut cmd = Command::new("corepack"); + cmd.arg(pm) + .args(args) + .current_dir(cwd) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + for (k, v) in extra_env { + cmd.env(k, v); + } + scrub_socket_env(&mut cmd); + cmd.output().expect("failed to run corepack") +} + +/// Remove every ambient `SOCKET_*` var (so a developer's `SOCKET_DRY_RUN=1` +/// etc. can't flip behavior) and the PM cache var the harness controls. +fn scrub_socket_env(cmd: &mut Command) { + for (k, _) in std::env::vars_os() { + let k = k.to_string_lossy(); + if k.starts_with("SOCKET_") { + cmd.env_remove(k.as_ref()); + } + } + cmd.env_remove("VIRTUAL_ENV"); + cmd.env_remove("YARN_CACHE_FOLDER"); +} + +/// Run the socket-patch binary with a scrubbed environment. +fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + scrub_socket_env(&mut cmd); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// Git-blob SHA-256 (`sha256("blob \0" ++ 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()) +} + +/// Write `.socket/manifest.json` + the after-hash blob so vendor runs fully +/// offline. +fn stage_patch(proj: &Path, purl: &str, file_key: &str, before: &[u8], after: &[u8]) { + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = serde_json::json!({ + "patches": { purl: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { file_key: { + "beforeHash": git_sha256(before), + "afterHash": git_sha256(after), + }}, + "vulnerabilities": {}, + "description": "capstone marker patch", + "license": "MIT", + "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(after)), after).unwrap(); +} + +fn parse_envelope(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout) + .unwrap_or_else(|e| panic!("vendor --json output is not JSON: {e}\nstdout:\n{stdout}")) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +// ── the capstone ────────────────────────────────────────────────────── + +#[test] +fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() { + if !has_corepack_pm(YARN_CLASSIC) { + println!( + "SKIP e2e_vendor_yarn_classic_build: `corepack {YARN_CLASSIC}` unavailable \ + (corepack not installed or yarn classic not fetchable)" + ); + return; + } + + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + // A registry dependency spec — vendoring leaves package.json untouched + // and rewires only the lock block (spike Y2). + std::fs::write( + proj.join("package.json"), + format!( + r#"{{"name":"yarn-classic-capstone","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# + ), + ) + .unwrap(); + + // 1. REAL fixture: yarn classic install (network allowed here, private + // cache via YARN_CACHE_FOLDER). + let cache = tmp.path().join("yarn-cache"); + let install = corepack( + &proj, + YARN_CLASSIC, + &["install", "--no-progress"], + &[("YARN_CACHE_FOLDER", cache.to_str().unwrap())], + ); + if !install.status.success() { + println!( + "SKIP e2e_vendor_yarn_classic_build: fixture `yarn install` failed (registry \ + unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return; + } + + let installed_index = proj.join("node_modules").join(DEP).join("index.js"); + let orig = std::fs::read(&installed_index).expect("installed index.js"); + assert!( + !orig.starts_with(MARKER.as_bytes()), + "pristine install must not carry the marker" + ); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); + + // 2. Manifest + blob from the ACTUAL installed bytes (npm-family file + // keys carry the `package/` prefix). + stage_patch(&proj, &purl, "package/index.js", &orig, &patched); + + let lock_path = proj.join("yarn.lock"); + let lock_before = std::fs::read(&lock_path).expect("yarn.lock after yarn install"); + let lock_before_str = String::from_utf8(lock_before.clone()).unwrap(); + assert!( + lock_before_str.contains("# yarn lockfile v1"), + "fixture must be a yarn classic v1 lock:\n{lock_before_str}" + ); + assert!( + lock_before_str.contains("https://registry.yarnpkg.com/"), + "pre-vendor block must resolve to the registry" + ); + + // 3. Vendor (offline: blob staged locally → zero network). + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + let applied = env["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["action"] == "applied" && e["purl"] == purl.as_str()) + .unwrap_or_else(|| panic!("expected an applied event for {purl}: {env}")); + assert!(applied.get("errorCode").is_none(), "clean apply event: {applied}"); + + // Artifact: deterministic tarball + informational marker in the uuid dir. + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); + assert!( + proj.join(&tgz_rel).is_file(), + "vendored tarball missing at {tgz_rel}" + ); + assert!( + proj.join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) + .is_file(), + "informational vendor marker missing" + ); + assert!( + proj.join(".socket/vendor/state.json").is_file(), + "vendor ledger missing" + ); + + // Lock rewiring: `resolved "file:./#"` + a recomputed + // `integrity sha512-…` line (spike Y2: the `file:./` prefix and BOTH + // hashes are load-bearing; a bare path 404s and the integrity is never + // the inherited registry one). + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + let expected_resolved = format!(" resolved \"file:./{tgz_rel}#"); + assert!( + lock_after.contains(&expected_resolved), + "yarn.lock must resolve to the vendored tarball with a `file:./` prefix and #sha1 \ + fragment; got:\n{lock_after}" + ); + assert!( + !lock_after.contains("https://registry.yarnpkg.com/"), + "the registry resolution must be gone from the rewired block:\n{lock_after}" + ); + // The integrity line is the recomputed sha512 of OUR tarball — verify it + // matches the bytes on disk (never inherited from the registry). + let tgz_bytes = std::fs::read(proj.join(&tgz_rel)).unwrap(); + let our_sha512 = format!("sha512-{}", sha512_sri_b64(&tgz_bytes)); + assert!( + lock_after.contains(&format!("integrity {our_sha512}")), + "integrity must be the recomputed sha512 of the vendored tarball ({our_sha512}); \ + got:\n{lock_after}" + ); + assert!( + !lock_after.contains( + "integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==" + ), + "the inherited registry integrity must NOT survive the rewrite" + ); + // package.json is never touched by the lock-only yarn-classic wiring. + let pkg_json: serde_json::Value = + serde_json::from_slice(&std::fs::read(proj.join("package.json")).unwrap()).unwrap(); + assert_eq!( + pkg_json["dependencies"][DEP].as_str(), + Some(DEP_VERSION), + "package.json dependency spec must stay registry-form" + ); + eprintln!("VENDOR OK"); + + // 4. FRESH-CHECKOUT PROOF: only the committable files, EMPTY yarn cache, + // spike-proven strictest invocation `--frozen-lockfile --offline`. + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(proj.join("package.json"), fresh.join("package.json")).unwrap(); + std::fs::copy(&lock_path, fresh.join("yarn.lock")).unwrap(); + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + + let fresh_cache = tmp.path().join("fresh-yarn-cache"); + let ci = corepack( + &fresh, + YARN_CLASSIC, + &["install", "--frozen-lockfile", "--offline", "--no-progress"], + &[("YARN_CACHE_FOLDER", fresh_cache.to_str().unwrap())], + ); + assert!( + ci.status.success(), + "fresh-checkout `yarn install --frozen-lockfile --offline` must succeed from the \ + vendored tarball.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let fresh_installed = + std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert!( + fresh_installed.starts_with(MARKER.as_bytes()), + "yarn must install the PATCHED bytes from the vendored tarball; got:\n{}", + String::from_utf8_lossy(&fresh_installed[..fresh_installed.len().min(120)]) + ); + assert_eq!( + fresh_installed, patched, + "fresh install must be byte-identical to the patched content" + ); + eprintln!("FRESH INSTALL OK"); + + // 5. Idempotency: a re-run exits 0 and leaves the lock byte-stable. + let lock_wired = std::fs::read(&lock_path).unwrap(); + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env2 = parse_envelope(&stdout); + assert_eq!(env2["summary"]["failed"], 0, "re-run must not fail: {env2}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_wired, + "re-vendor must leave yarn.lock byte-identical" + ); + + // 6. REVERT PROOF: lock restored byte-for-byte, artifacts gone. + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--revert", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "revert envelope: {renv}"); + assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_before, + "revert must restore yarn.lock byte-identical to the pre-vendor snapshot" + ); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); + eprintln!("REVERT OK"); +} + +// ── tiny crypto shim (kept local so the file stays self-contained) ───── + +/// Standard-base64-encoded sha512 of `bytes` — the body of the npm-family +/// `sha512-…` SRI integrity string. +fn sha512_sri_b64(bytes: &[u8]) -> String { + use base64::Engine as _; + use sha2::Sha512; + let digest = Sha512::digest(bytes); + base64::engine::general_purpose::STANDARD.encode(digest) +} From 611de948126d429f804b8a43b8ca18cca3e1862d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 12:00:20 -0400 Subject: [PATCH 26/31] test(vendor): docker build-proof capstones for poetry/pdm/pipenv Two-stage harness (networked fixture+vendor+revert; --network none strictest install): poetry check --lock && poetry sync; pdm install --check && pdm sync; pipenv install --deploy && pipenv verify. Each asserts the lock-only wiring (poetry [package.source] type=file + files[] our-sha256; pdm path + files[]; pipenv default. file+hashes), the wheel artifact, byte-identical revert, and re-vendor; pipenv also asserts the vendor_integrity_unverified warning. Red-probe verified (deleting .socket/vendor fails the cold-cache install). All 3 green against the rebuilt socket-patch-test-pypi image. Co-Authored-By: Claude Fable 5 --- .../tests/docker_e2e_vendor_pypi_pm.rs | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_pypi_pm.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_pypi_pm.rs index 8e3e190..ef54f15 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_vendor_pypi_pm.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_pypi_pm.rs @@ -118,11 +118,11 @@ echo "===ARTIFACT VERIFIED===" // ── poetry ──────────────────────────────────────────────────────────────── -/// Poetry stage 1: `poetry add six==1.16.0` (in-project venv) + marker patch -/// + offline vendor + the lock-only `[[package]]` splice asserts + fresh -/// staging. Poetry's wiring (spike P1/P2): files[] reduced to the single -/// patched-wheel `{file, hash}`, a `[package.source] type="file"` table -/// appended; pyproject.toml + content-hash untouched. +/// Poetry stage 1 (in-project venv): `poetry add six==1.16.0`, marker patch, +/// offline vendor, the lock-only splice asserts, then fresh staging. Poetry's +/// wiring (spike P1/P2) reduces the `files` array to the single patched-wheel +/// `{file, hash}` element and appends a `package.source` table +/// (`type = "file"`); pyproject and content-hash stay untouched. const POETRY_STAGE1: &str = r#" mkdir -p /workspace/proj && cd /workspace/proj export SOCKET_OFFLINE=1 @@ -248,11 +248,11 @@ exit 0 // ── pdm ─────────────────────────────────────────────────────────────────── -/// PDM stage 1: `pdm init -n` + `pdm add six==1.16.0` (in-project venv) + -/// marker patch + offline vendor + the lock-only `[[package]]` splice asserts -/// + fresh staging. PDM's wiring (spike D1): a relative `path = "./…"` key -/// inserted after `requires_python`, files[] reduced to the single -/// patched-wheel hash; pyproject.toml + content_hash untouched. +/// PDM stage 1 (in-project venv): `pdm init -n`, `pdm add six==1.16.0`, +/// marker patch, offline vendor, the lock-only splice asserts, then fresh +/// staging. PDM's wiring (spike D1) inserts a relative `path = "./…"` key +/// after `requires_python` and reduces the `files` array to the single +/// patched-wheel hash; pyproject and content_hash stay untouched. const PDM_STAGE1: &str = r#" mkdir -p /workspace/proj && cd /workspace/proj export SOCKET_OFFLINE=1 @@ -370,11 +370,11 @@ exit 0 // ── pipenv ────────────────────────────────────────────────────────────────── -/// pipenv stage 1: `pipenv install six==1.16.0` (in-project venv) + marker -/// patch + offline vendor + the lock-only entry rewrite asserts + fresh -/// staging. pipenv's wiring (spike V1/V2): `default.six` becomes -/// `{file: "./", hashes: [sha256:], markers}` (index + -/// version dropped); Pipfile untouched. Also asserts the +/// pipenv stage 1 (in-project venv): `pipenv install six==1.16.0`, marker +/// patch, offline vendor, the lock-only entry-rewrite asserts, then fresh +/// staging. pipenv's wiring (spike V1/V2) rewrites `default.six` to +/// `{file: "./", hashes: [sha256:], markers}` (dropping +/// index and version); Pipfile stays untouched. The suite also asserts the /// `vendor_integrity_unverified` warning surfaces in the vendor envelope. const PIPENV_STAGE1: &str = r#" mkdir -p /workspace/proj && cd /workspace/proj @@ -509,7 +509,10 @@ exit 0 /// Splice the shared vendor body into a flavor stage-1 template, then render. fn render_stage1(template: &str, uuid: &str) -> String { - render(&template.replace("__VENDOR_COMMON__", STAGE1_VENDOR_COMMON), uuid) + render( + &template.replace("__VENDOR_COMMON__", STAGE1_VENDOR_COMMON), + uuid, + ) } fn host_dir() -> (tempfile::TempDir, std::path::PathBuf) { From 0287e750f72346c1100ad59bdd272157d5f183a2 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 12:00:20 -0400 Subject: [PATCH 27/31] style: cargo fmt --all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tree-wide formatter pass (a hook reflowed files this session's work left non-fmt). AST-preserving — core lib 1371 (composer)/1114 (no-default), cli lib 272, vendor capstones + in_process_vendor + cli_parse_vendor all green post-fmt; cargo fmt --all --check clean. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/args.rs | 36 +- crates/socket-patch-cli/src/commands/apply.rs | 157 +++--- .../src/commands/fetch_stage.rs | 3 +- crates/socket-patch-cli/src/commands/get.rs | 106 ++-- crates/socket-patch-cli/src/commands/list.rs | 20 +- .../socket-patch-cli/src/commands/lock_cli.rs | 42 +- .../socket-patch-cli/src/commands/remove.rs | 104 ++-- .../socket-patch-cli/src/commands/repair.rs | 53 +- .../socket-patch-cli/src/commands/rollback.rs | 207 +++++--- crates/socket-patch-cli/src/commands/scan.rs | 100 ++-- crates/socket-patch-cli/src/commands/setup.rs | 218 ++++++-- .../socket-patch-cli/src/commands/unlock.rs | 21 +- .../socket-patch-cli/src/commands/vendor.rs | 41 +- crates/socket-patch-cli/src/commands/vex.rs | 79 +-- .../src/ecosystem_dispatch.rs | 57 +- crates/socket-patch-cli/src/json_envelope.rs | 56 +- crates/socket-patch-cli/src/lib.rs | 4 +- .../tests/api_client_errors_e2e.rs | 22 +- .../tests/apply_invariants.rs | 14 +- .../socket-patch-cli/tests/apply_network.rs | 116 +++-- .../tests/cli_dry_run_paths_e2e.rs | 107 +++- .../tests/cli_env_deprecation.rs | 20 +- .../socket-patch-cli/tests/cli_global_args.rs | 41 +- .../socket-patch-cli/tests/cli_parse_apply.rs | 87 +++- .../socket-patch-cli/tests/cli_parse_list.rs | 123 +++-- .../socket-patch-cli/tests/cli_parse_main.rs | 3 +- .../tests/cli_parse_remove.rs | 56 +- .../tests/cli_parse_repair.rs | 7 +- .../tests/cli_parse_rollback.rs | 6 +- .../socket-patch-cli/tests/cli_parse_scan.rs | 23 +- .../socket-patch-cli/tests/cli_parse_setup.rs | 15 +- .../tests/cli_parse_vendor.rs | 24 +- crates/socket-patch-cli/tests/common/mod.rs | 47 +- .../tests/docker_e2e_cargo.rs | 11 +- .../tests/docker_e2e_composer.rs | 7 +- .../socket-patch-cli/tests/docker_e2e_deno.rs | 7 +- .../socket-patch-cli/tests/docker_e2e_gem.rs | 7 +- .../tests/docker_e2e_golang.rs | 10 +- .../tests/docker_e2e_maven.rs | 7 +- .../socket-patch-cli/tests/docker_e2e_npm.rs | 10 +- .../tests/docker_e2e_nuget.rs | 7 +- .../socket-patch-cli/tests/docker_e2e_pypi.rs | 7 +- .../tests/e2e_embedded_vex.rs | 19 +- crates/socket-patch-cli/tests/e2e_gem.rs | 25 +- crates/socket-patch-cli/tests/e2e_golang.rs | 6 +- .../tests/e2e_golang_build.rs | 54 +- .../tests/e2e_golang_redirect.rs | 91 +++- crates/socket-patch-cli/tests/e2e_maven.rs | 15 +- crates/socket-patch-cli/tests/e2e_npm.rs | 78 ++- crates/socket-patch-cli/tests/e2e_pypi.rs | 52 +- .../tests/e2e_safety_advisories.rs | 32 +- .../tests/e2e_safety_cargo_build.rs | 87 ++-- .../socket-patch-cli/tests/e2e_safety_cow.rs | 10 +- .../tests/e2e_safety_internals.rs | 49 +- .../socket-patch-cli/tests/e2e_safety_lock.rs | 5 +- .../socket-patch-cli/tests/e2e_safety_pnpm.rs | 6 +- .../tests/e2e_safety_unlock.rs | 14 +- .../tests/e2e_safety_yarn_pnp.rs | 10 +- crates/socket-patch-cli/tests/e2e_scan.rs | 63 ++- .../tests/e2e_vendor_bun_build.rs | 62 ++- .../tests/e2e_vendor_cargo_build.rs | 78 ++- .../tests/e2e_vendor_golang_build.rs | 149 ++++-- .../tests/e2e_vendor_npm_build.rs | 54 +- .../tests/e2e_vendor_pnpm_build.rs | 56 +- .../tests/e2e_vendor_pypi_build.rs | 83 ++- .../tests/e2e_vendor_yarn_berry_build.rs | 61 ++- .../tests/e2e_vendor_yarn_classic_build.rs | 51 +- crates/socket-patch-cli/tests/e2e_vex.rs | 30 +- .../socket-patch-cli/tests/e2e_vex_vendor.rs | 29 +- .../tests/ecosystem_dispatch_e2e.rs | 18 +- .../tests/get_batch_paths_e2e.rs | 52 +- .../tests/get_edge_cases_e2e.rs | 63 ++- .../socket-patch-cli/tests/get_invariants.rs | 40 +- .../tests/global_packages_e2e.rs | 51 +- .../tests/in_process_alternate_installers.rs | 4 +- .../tests/in_process_cargo_apply.rs | 20 +- .../tests/in_process_edge_cases.rs | 39 +- .../tests/in_process_gem_apply.rs | 26 +- .../tests/in_process_gem_multi_platform.rs | 18 +- .../socket-patch-cli/tests/in_process_get.rs | 34 +- .../tests/in_process_get_update_count.rs | 15 +- .../tests/in_process_pypi_apply.rs | 30 +- .../tests/in_process_pypi_multi_release.rs | 4 +- .../tests/in_process_python_envs.rs | 20 +- .../in_process_remote_ecosystems_apply.rs | 59 ++- .../in_process_remove_repair_lifecycle.rs | 29 +- .../in_process_rollback_all_ecosystems.rs | 7 +- .../socket-patch-cli/tests/in_process_scan.rs | 60 ++- .../tests/in_process_variant_apply_failure.rs | 6 +- .../tests/in_process_vendor.rs | 50 +- .../tests/interactive_prompts_e2e.rs | 45 +- .../tests/output_helpers_e2e.rs | 10 +- .../tests/output_modes_e2e.rs | 75 ++- .../tests/remove_invariants.rs | 29 +- .../socket-patch-cli/tests/remove_network.rs | 4 +- .../tests/repair_invariants.rs | 31 +- .../tests/rollback_invariants.rs | 52 +- .../socket-patch-cli/tests/scan_invariants.rs | 55 +- .../socket-patch-cli/tests/scan_sync_e2e.rs | 67 ++- .../tests/setup_contract_gaps.rs | 16 +- .../tests/setup_invariants.rs | 130 ++++- .../tests/setup_matrix_common/mod.rs | 72 ++- .../tests/setup_matrix_composer.rs | 78 ++- .../tests/setup_matrix_deno.rs | 24 +- .../tests/setup_matrix_gem.rs | 69 ++- .../tests/setup_matrix_maven.rs | 25 +- .../tests/setup_matrix_monorepo.rs | 10 +- .../tests/setup_matrix_npm.rs | 15 +- .../tests/setup_matrix_nuget.rs | 18 +- .../tests/setup_matrix_pypi.rs | 37 +- .../tests/setup_pth_invariants.rs | 52 +- .../socket-patch-cli/tests/telemetry_e2e.rs | 111 ++-- .../socket-patch-core/src/api/blob_fetcher.rs | 16 +- crates/socket-patch-core/src/api/client.rs | 17 +- .../src/composer_setup/mod.rs | 70 ++- .../src/crawlers/cargo_crawler.rs | 4 +- .../src/crawlers/go_crawler.rs | 6 +- .../src/crawlers/npm_crawler.rs | 11 +- .../src/crawlers/nuget_crawler.rs | 11 +- .../src/crawlers/python_crawler.rs | 7 +- .../src/crawlers/ruby_crawler.rs | 28 +- crates/socket-patch-core/src/gem_setup/mod.rs | 39 +- .../socket-patch-core/src/gem_setup/update.rs | 19 +- .../src/manifest/operations.rs | 5 +- .../socket-patch-core/src/manifest/schema.rs | 10 +- .../src/package_json/detect.rs | 14 +- .../src/package_json/find.rs | 22 +- .../src/package_json/update.rs | 3 +- crates/socket-patch-core/src/patch/apply.rs | 28 +- .../socket-patch-core/src/patch/apply_lock.rs | 2 +- .../socket-patch-core/src/patch/copy_tree.rs | 34 +- crates/socket-patch-core/src/patch/diff.rs | 3 +- .../src/patch/go_mod_edit.rs | 216 ++++++-- .../src/patch/go_redirect.rs | 464 ++++++++++++++--- .../src/patch/path_safety.rs | 3 +- .../src/patch/sidecars/cargo.rs | 4 +- .../src/patch/vendor/berry_zip.rs | 58 ++- .../src/patch/vendor/bun_lock.rs | 253 ++++++--- .../src/patch/vendor/cargo.rs | 240 +++++++-- .../src/patch/vendor/cargo_config.rs | 25 +- .../src/patch/vendor/cargo_lock.rs | 76 ++- .../src/patch/vendor/composer_lock.rs | 182 +++++-- .../socket-patch-core/src/patch/vendor/gem.rs | 453 ++++++++++++---- .../src/patch/vendor/golang.rs | 141 +++-- .../socket-patch-core/src/patch/vendor/mod.rs | 7 +- .../src/patch/vendor/npm_common.rs | 27 +- .../src/patch/vendor/npm_flavor.rs | 145 +++++- .../src/patch/vendor/npm_lock.rs | 273 ++++++++-- .../src/patch/vendor/npm_pack.rs | 76 ++- .../src/patch/vendor/path.rs | 38 +- .../src/patch/vendor/pnpm_lock.rs | 490 +++++++++++++----- .../src/patch/vendor/pypi.rs | 46 +- .../src/patch/vendor/pypi_pdm.rs | 169 ++++-- .../src/patch/vendor/pypi_pipenv.rs | 73 ++- .../src/patch/vendor/pypi_poetry.rs | 151 ++++-- .../src/patch/vendor/pypi_requirements.rs | 112 +++- .../src/patch/vendor/pypi_uv.rs | 285 +++++++--- .../src/patch/vendor/pypi_wheel.rs | 84 +-- .../src/patch/vendor/state.rs | 41 +- .../src/patch/vendor/toml_surgery.rs | 33 +- .../src/patch/vendor/verify.rs | 52 +- .../src/patch/vendor/yarn_berry_lock.rs | 234 +++++++-- .../src/patch/vendor/yarn_classic_lock.rs | 233 +++++++-- .../socket-patch-core/src/pth_hook/detect.rs | 23 +- crates/socket-patch-core/src/pth_hook/edit.rs | 60 ++- .../src/utils/cleanup_blobs.rs | 5 +- .../socket-patch-core/src/utils/telemetry.rs | 5 +- crates/socket-patch-core/src/vex/build.rs | 158 +++--- .../src/vex/conformance_tests.rs | 26 +- crates/socket-patch-core/src/vex/product.rs | 11 +- crates/socket-patch-core/src/vex/schema.rs | 5 +- crates/socket-patch-core/src/vex/verify.rs | 74 ++- .../binary_fetch_error_classification_e2e.rs | 10 +- .../tests/blob_fetcher_edges_e2e.rs | 109 +++- .../tests/crawler_cargo_e2e.rs | 4 +- .../tests/crawler_maven_e2e.rs | 7 +- .../tests/crawler_monorepo_gaps.rs | 9 +- .../tests/crawler_npm_e2e.rs | 26 +- .../tests/crawler_nuget_e2e.rs | 9 +- .../tests/crawler_python_e2e.rs | 11 +- .../tests/crawler_ruby_e2e.rs | 5 +- .../tests/crawlers_empty_paths_e2e.rs | 6 +- crates/socket-patch-core/tests/diff_e2e.rs | 9 +- .../tests/fuzzy_match_e2e.rs | 22 +- crates/socket-patch-core/tests/package_e2e.rs | 10 +- .../tests/telemetry_helpers_e2e.rs | 10 +- 186 files changed, 7595 insertions(+), 3079 deletions(-) diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index e26a2e2..4d2dbc5 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -130,7 +130,7 @@ pub struct GlobalArgs { #[arg( long = "download-mode", env = "SOCKET_DOWNLOAD_MODE", - default_value = "diff", + default_value = "diff" )] pub download_mode: String, @@ -429,7 +429,12 @@ mod tests { #[serial_test::serial] fn empty_bool_env_var_parses_as_false_not_crash() { with_clean_socket_env(|| { - for var in ["SOCKET_OFFLINE", "SOCKET_JSON", "SOCKET_VERBOSE", "SOCKET_GLOBAL"] { + for var in [ + "SOCKET_OFFLINE", + "SOCKET_JSON", + "SOCKET_VERBOSE", + "SOCKET_GLOBAL", + ] { std::env::set_var(var, ""); } let cli = TestCli::try_parse_from(["socket-patch"]) @@ -438,7 +443,12 @@ mod tests { assert!(!cli.common.json); assert!(!cli.common.verbose); assert!(!cli.common.global); - for var in ["SOCKET_OFFLINE", "SOCKET_JSON", "SOCKET_VERBOSE", "SOCKET_GLOBAL"] { + for var in [ + "SOCKET_OFFLINE", + "SOCKET_JSON", + "SOCKET_VERBOSE", + "SOCKET_GLOBAL", + ] { std::env::remove_var(var); } }); @@ -527,7 +537,10 @@ mod tests { fn api_client_overrides_default_is_all_none() { let o = GlobalArgs::default().api_client_overrides(); assert!(o.api_url.is_none(), "empty api_url must not be forwarded"); - assert!(o.proxy_url.is_none(), "empty proxy_url must not be forwarded"); + assert!( + o.proxy_url.is_none(), + "empty proxy_url must not be forwarded" + ); assert!(o.api_token.is_none()); assert!(o.org_slug.is_none()); } @@ -599,7 +612,10 @@ mod tests { for bad in ["bogus", "NPM", "py-pi", ""] { let err = parse_supported_ecosystem(bad) .expect_err("unsupported ecosystem name must be rejected"); - assert!(err.contains(bad), "error should echo the bad token: {err:?}"); + assert!( + err.contains(bad), + "error should echo the bad token: {err:?}" + ); assert!( err.contains("supported:"), "error should list the supported set: {err:?}", @@ -634,9 +650,8 @@ mod tests { fn cli_arg_overrides_env_var() { with_clean_socket_env(|| { std::env::set_var("SOCKET_MANIFEST_PATH", "from-env.json"); - let cli = - TestCli::try_parse_from(["socket-patch", "--manifest-path", "from-cli.json"]) - .unwrap(); + let cli = TestCli::try_parse_from(["socket-patch", "--manifest-path", "from-cli.json"]) + .unwrap(); assert_eq!(cli.common.manifest_path, "from-cli.json"); std::env::remove_var("SOCKET_MANIFEST_PATH"); }); @@ -684,7 +699,10 @@ mod tests { }; apply_env_toggles(&args); assert_eq!(std::env::var("SOCKET_DEBUG").as_deref(), Ok("1")); - assert_eq!(std::env::var("SOCKET_TELEMETRY_DISABLED").as_deref(), Ok("1")); + assert_eq!( + std::env::var("SOCKET_TELEMETRY_DISABLED").as_deref(), + Ok("1") + ); match saved_debug { Some(v) => std::env::set_var("SOCKET_DEBUG", v), diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 6f4a68d..308c77d 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -171,7 +171,11 @@ async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet .collect(); let removed = reconcile_go_redirects(&common.cwd, &desired, common.dry_run).await; if !removed.is_empty() && !common.silent && !common.json { - let verb = if common.dry_run { "Would remove" } else { "Removed" }; + let verb = if common.dry_run { + "Would remove" + } else { + "Removed" + }; println!("{verb} {} stale go patch redirect(s):", removed.len()); for purl in &removed { println!(" {purl}"); @@ -341,9 +345,7 @@ fn all_files_already_patched(result: &ApplyResult) -> bool { pub(crate) fn variant_matches_installed(first_file_status: Option<&VerifyStatus>) -> bool { match first_file_status { None => true, - Some(status) => { - *status == VerifyStatus::Ready || *status == VerifyStatus::AlreadyPatched - } + Some(status) => *status == VerifyStatus::Ready || *status == VerifyStatus::AlreadyPatched, } } @@ -391,9 +393,7 @@ pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent let files = result .files_verified .iter() - .filter(|f| { - f.status == VerifyStatus::Ready || f.status == VerifyStatus::AlreadyPatched - }) + .filter(|f| f.status == VerifyStatus::Ready || f.status == VerifyStatus::AlreadyPatched) .map(|f| PatchEventFile { path: f.file.clone(), verified: true, @@ -537,10 +537,7 @@ pub async fn run(args: ApplyArgs) -> i32 { // fail-the-command contract). `None` => not requested. let vex_result = if success && args.vex.vex.is_some() { let params = args.vex.to_build_params(); - Some( - generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path) - .await, - ) + Some(generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await) } else { None }; @@ -616,11 +613,8 @@ pub async fn run(args: ApplyArgs) -> i32 { // package: if everything came from the same // source, show just that tag; otherwise list // distinct sources. - let mut tags: Vec<&'static str> = result - .applied_via - .values() - .map(|v| v.as_tag()) - .collect(); + let mut tags: Vec<&'static str> = + result.applied_via.values().map(|v| v.as_tag()).collect(); tags.sort_unstable(); tags.dedup(); let suffix = if tags.is_empty() { @@ -686,9 +680,21 @@ pub async fn run(args: ApplyArgs) -> i32 { // Track telemetry if success { - track_patch_applied(patched_count, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_applied( + patched_count, + args.common.dry_run, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; } else { - track_patch_apply_failed("One or more patches failed to apply", args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_apply_failed( + "One or more patches failed to apply", + args.common.dry_run, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; } // A requested-but-failed VEX flips an otherwise-successful @@ -700,7 +706,13 @@ pub async fn run(args: ApplyArgs) -> i32 { } } Err(e) => { - track_patch_apply_failed(&e, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_apply_failed( + &e, + args.common.dry_run, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; if args.common.json { let mut env = Envelope::new(Command::Apply); env.dry_run = args.common.dry_run; @@ -744,8 +756,7 @@ async fn apply_patches_inner( // Partition manifest PURLs by ecosystem let manifest_purls: Vec = manifest.patches.keys().cloned().collect(); - let partitioned = - partition_purls(&manifest_purls, args.common.ecosystems.as_deref()); + let partitioned = partition_purls(&manifest_purls, args.common.ecosystems.as_deref()); let target_manifest_purls: HashSet = partitioned .values() @@ -766,8 +777,12 @@ async fn apply_patches_inner( batch_size: 100, }; - let all_packages = - find_packages_for_purls(&partitioned, &crawler_options, args.common.silent || args.common.json).await; + let all_packages = find_packages_for_purls( + &partitioned, + &crawler_options, + args.common.silent || args.common.json, + ) + .await; let has_any_purls = !partitioned.is_empty(); @@ -791,7 +806,9 @@ async fn apply_patches_inner( " {} targeted manifest patch(es) were in scope, but no matching packages were found on disk.", target_manifest_purls.len() ); - eprintln!(" Check that packages are installed and --cwd points to the right directory."); + eprintln!( + " Check that packages are installed and --cwd points to the right directory." + ); } let unmatched: Vec = target_manifest_purls.iter().cloned().collect(); return Ok((false, Vec::new(), unmatched)); @@ -855,9 +872,11 @@ async fn apply_patches_inner( // Mirrors `select_installed_variants`, used by rollback/get. if !args.force { let first_status = match patch.files.iter().next() { - Some((file_name, file_info)) => { - Some(verify_file_patch(pkg_path, file_name, file_info).await.status) - } + Some((file_name, file_info)) => Some( + verify_file_patch(pkg_path, file_name, file_info) + .await + .status, + ), None => None, }; if !variant_matches_installed(first_status.as_ref()) { @@ -955,30 +974,24 @@ async fn apply_patches_inner( // Everything else — npm/pypi/gem and cargo (vendored or registry // cache) — patches in place via `apply_package_patch`. Without the // `golang` feature `try_local_go_apply` is an inert `None`. - let result = match try_local_go_apply( - purl, - pkg_path, - patch, - &sources, - &args.common, - args.force, - ) - .await - { - Some(r) => r, - None => { - apply_package_patch( - purl, - pkg_path, - &patch.files, - &sources, - Some(&patch.uuid), - args.common.dry_run, - args.force, - ) + let result = + match try_local_go_apply(purl, pkg_path, patch, &sources, &args.common, args.force) .await - } - }; + { + Some(r) => r, + None => { + apply_package_patch( + purl, + pkg_path, + &patch.files, + &sources, + Some(&patch.uuid), + args.common.dry_run, + args.force, + ) + .await + } + }; if !result.success { has_errors = true; @@ -1003,13 +1016,19 @@ async fn apply_patches_inner( .collect(); if !unmatched.is_empty() && !args.common.silent && !args.common.json { - eprintln!("\nWarning: {} manifest patch(es) had no matching installed package:", unmatched.len()); + eprintln!( + "\nWarning: {} manifest patch(es) had no matching installed package:", + unmatched.len() + ); for purl in &unmatched { eprintln!(" - {}", purl); } } - if !target_manifest_purls.is_empty() && matched_manifest_purls.is_empty() && !all_packages.is_empty() { + if !target_manifest_purls.is_empty() + && matched_manifest_purls.is_empty() + && !all_packages.is_empty() + { if !args.common.silent && !args.common.json { eprintln!("Warning: None of the targeted manifest patches matched installed packages."); } @@ -1018,8 +1037,14 @@ async fn apply_patches_inner( // Post-apply summary if !args.common.silent && !args.common.json { - let applied_count = results.iter().filter(|r| r.success && !r.files_patched.is_empty()).count(); - let already_count = results.iter().filter(|r| all_files_already_patched(r)).count(); + let applied_count = results + .iter() + .filter(|r| r.success && !r.files_patched.is_empty()) + .count(); + let already_count = results + .iter() + .filter(|r| all_files_already_patched(r)) + .count(); println!( "\nSummary: {}/{} targeted patches applied, {} already patched, {} not found on disk", applied_count, @@ -1107,7 +1132,11 @@ mod tests { // Dry-run events list verified files but never an `appliedVia` // — nothing was actually written. assert_eq!(v["files"][0]["path"], "package/index.js"); - assert!(v["files"][0].as_object().unwrap().get("appliedVia").is_none()); + assert!(v["files"][0] + .as_object() + .unwrap() + .get("appliedVia") + .is_none()); } #[test] @@ -1191,19 +1220,13 @@ mod tests { #[test] fn all_files_already_patched_true_when_every_file_matches() { - let result = sample_verified(&[ - VerifyStatus::AlreadyPatched, - VerifyStatus::AlreadyPatched, - ]); + let result = sample_verified(&[VerifyStatus::AlreadyPatched, VerifyStatus::AlreadyPatched]); assert!(all_files_already_patched(&result)); } #[test] fn all_files_already_patched_false_when_any_file_differs() { - let result = sample_verified(&[ - VerifyStatus::AlreadyPatched, - VerifyStatus::Ready, - ]); + let result = sample_verified(&[VerifyStatus::AlreadyPatched, VerifyStatus::Ready]); assert!(!all_files_already_patched(&result)); } @@ -1236,11 +1259,15 @@ mod tests { // Installed distribution: first file applies cleanly, or is // already at afterHash → this variant is the one on disk. assert!(variant_matches_installed(Some(&VerifyStatus::Ready))); - assert!(variant_matches_installed(Some(&VerifyStatus::AlreadyPatched))); + assert!(variant_matches_installed(Some( + &VerifyStatus::AlreadyPatched + ))); // Not the installed distribution → must be skipped. The NotFound // case is the specific regression this guards. - assert!(!variant_matches_installed(Some(&VerifyStatus::HashMismatch))); + assert!(!variant_matches_installed(Some( + &VerifyStatus::HashMismatch + ))); assert!(!variant_matches_installed(Some(&VerifyStatus::NotFound))); // A variant with no files has nothing to disqualify it — match, diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index 3a76f0c..b1ffbb9 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -200,7 +200,8 @@ pub async fn stage_patch_sources( packages_path: Some(&stage_packages), diffs_path: Some(&stage_diffs), }; - let fetch_result = fetch_missing_sources(manifest, &sources, download_mode, &client, None).await; + let fetch_result = + fetch_missing_sources(manifest, &sources, download_mode, &client, None).await; if !common.silent && !common.json { println!("{}", format_fetch_result(&fetch_result)); diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e296341..cc9898a 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -20,7 +20,9 @@ use std::fmt; use std::path::{Path, PathBuf}; use crate::args::{apply_env_toggles, GlobalArgs}; -use crate::ecosystem_dispatch::{crawl_all_ecosystems, find_packages_for_rollback, partition_purls}; +use crate::ecosystem_dispatch::{ + crawl_all_ecosystems, find_packages_for_rollback, partition_purls, +}; use crate::output::{confirm, select_one, SelectError}; /// Best-effort ecosystem extractor for a `pkg:/...` PURL. Used as @@ -97,9 +99,7 @@ pub(crate) fn severity_rank(severity: &str) -> u8 { /// Return the highest-severity label from a vulnerabilities map. /// Returns `None` when the map is empty or every entry's severity is /// unrecognized. -pub(crate) fn max_vuln_severity( - vulns: &HashMap, -) -> Option { +pub(crate) fn max_vuln_severity(vulns: &HashMap) -> Option { vulns .values() .max_by_key(|v| severity_rank(&v.severity)) @@ -155,10 +155,7 @@ pub(crate) fn patch_event_metadata(patch: &PatchResponse) -> serde_json::Value { "license".into(), serde_json::Value::String(patch.license.clone()), ); - meta.insert( - "tier".into(), - serde_json::Value::String(patch.tier.clone()), - ); + meta.insert("tier".into(), serde_json::Value::String(patch.tier.clone())); meta.insert( "exportedAt".into(), serde_json::Value::String(patch.published_at.clone()), @@ -174,8 +171,7 @@ pub(crate) fn patch_event_metadata(patch: &PatchResponse) -> serde_json::Value { /// per-patch action record. Convenience wrapper that handles the /// unwrap of `Value::Object`. fn merge_metadata(record: &mut serde_json::Value, meta: serde_json::Value) { - if let (Some(record_obj), serde_json::Value::Object(meta_obj)) = - (record.as_object_mut(), meta) + if let (Some(record_obj), serde_json::Value::Object(meta_obj)) = (record.as_object_mut(), meta) { for (k, v) in meta_obj { record_obj.insert(k, v); @@ -262,8 +258,8 @@ async fn write_blob_entry( file_path: &str, label: &str, ) -> Result<(), String> { - let decoded = base64_decode(b64) - .map_err(|e| format!("Failed to decode {label} for {file_path}: {e}"))?; + let decoded = + base64_decode(b64).map_err(|e| format!("Failed to decode {label} for {file_path}: {e}"))?; tokio::fs::write(blobs_dir.join(hash), &decoded) .await .map_err(|e| format!("Failed to write {label} for {file_path}: {e}")) @@ -278,9 +274,7 @@ async fn write_all_patch_blobs( quiet: bool, ) -> Result<(), ()> { for (file_path, file_info) in &patch.files { - if let (Some(blob), Some(hash)) = - (&file_info.blob_content, &file_info.after_hash) - { + if let (Some(blob), Some(hash)) = (&file_info.blob_content, &file_info.after_hash) { if let Err(e) = write_blob_entry(blobs_dir, blob, hash, file_path, "blob").await { if !quiet { eprintln!(" [error] {e}"); @@ -288,11 +282,8 @@ async fn write_all_patch_blobs( return Err(()); } } - if let (Some(blob), Some(hash)) = - (&file_info.before_blob_content, &file_info.before_hash) - { - if let Err(e) = - write_blob_entry(blobs_dir, blob, hash, file_path, "before-blob").await + if let (Some(blob), Some(hash)) = (&file_info.before_blob_content, &file_info.before_hash) { + if let Err(e) = write_blob_entry(blobs_dir, blob, hash, file_path, "before-blob").await { if !quiet { eprintln!(" [error] {e}"); @@ -329,10 +320,7 @@ fn vulnerabilities_for_manifest( /// `patch`. `files` is the (purl-keyed) before/after-hash map the /// caller built — semantics for what counts as a "patchable file" differ /// between the get and download flows, so the caller owns that decision. -fn build_patch_record( - patch: &PatchResponse, - files: HashMap, -) -> PatchRecord { +fn build_patch_record(patch: &PatchResponse, files: HashMap) -> PatchRecord { PatchRecord { uuid: patch.uuid.clone(), exported_at: patch.published_at.clone(), @@ -369,7 +357,12 @@ pub struct GetArgs { pub package: bool, /// Download patch without applying it. - #[arg(long = "save-only", alias = "no-apply", env = "SOCKET_SAVE_ONLY", default_value_t = false)] + #[arg( + long = "save-only", + alias = "no-apply", + env = "SOCKET_SAVE_ONLY", + default_value_t = false + )] pub save_only: bool, /// Apply patch immediately without saving to .socket folder. @@ -413,7 +406,8 @@ impl fmt::Display for IdentifierType { } fn detect_identifier_type(identifier: &str) -> Option { - let uuid_re = Regex::new(r"(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$").unwrap(); + let uuid_re = + Regex::new(r"(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$").unwrap(); let cve_re = Regex::new(r"(?i)^CVE-\d{4}-\d+$").unwrap(); let ghsa_re = Regex::new(r"(?i)^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$").unwrap(); @@ -839,7 +833,10 @@ pub async fn download_and_apply_patches( } let quiet = params.json || params.silent; - if write_all_patch_blobs(&blobs_dir, &patch, quiet).await.is_err() { + if write_all_patch_blobs(&blobs_dir, &patch, quiet) + .await + .is_err() + { patches_failed += 1; downloaded_patches.push(serde_json::json!({ "purl": patch.purl, @@ -1303,16 +1300,16 @@ pub async fn run(args: GetArgs) -> i32 { if args.common.json { print_json(&empty_result_json("not_found")); } else { - println!( - "No patches found for {}: {}", - id_type, args.identifier - ); + println!("No patches found for {}: {}", id_type, args.identifier); } return 0; } if !args.common.json { - display_search_results(&search_response.patches, search_response.can_access_paid_patches); + display_search_results( + &search_response.patches, + search_response.can_access_paid_patches, + ); } // Filter accessible patches @@ -1442,8 +1439,7 @@ async fn save_and_apply_patch( _org_slug: Option<&str>, ) -> i32 { // For UUID mode, fetch and save - let (api_client, _) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; + let (api_client, _) = get_api_client_with_overrides(args.common.api_client_overrides()).await; let effective_org: Option<&str> = None; // org slug is already stored in the client let patch = match api_client.fetch_patch(effective_org, uuid).await { @@ -1467,7 +1463,10 @@ async fn save_and_apply_patch( let manifest_path = socket_dir.join("manifest.json"); if let Err(e) = tokio::fs::create_dir_all(&blobs_dir).await { - report_error(args.common.json, format!("Failed to create blobs directory: {e}")); + report_error( + args.common.json, + format!("Failed to create blobs directory: {e}"), + ); return 1; } @@ -1512,7 +1511,10 @@ async fn save_and_apply_patch( }], })); } else { - eprintln!("Error: Blob decode or write failed for patch {}", patch.purl); + eprintln!( + "Error: Blob decode or write failed for patch {}", + patch.purl + ); } return 1; } @@ -1589,13 +1591,17 @@ async fn save_and_apply_patch( // record means the consumer already saw the metadata last time. merge_metadata(&mut patch_record, patch_event_metadata(&patch)); } - println!("{}", serde_json::to_string_pretty(&serde_json::json!({ - "status": status, - "found": 1, - "downloaded": if added { 1 } else { 0 }, - "applied": if apply_succeeded { 1 } else { 0 }, - "patches": [patch_record], - })).unwrap()); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": status, + "found": 1, + "downloaded": if added { 1 } else { 0 }, + "applied": if apply_succeeded { 1 } else { 0 }, + "patches": [patch_record], + })) + .unwrap() + ); } exit_code @@ -1720,12 +1726,7 @@ mod tests { // --- select_patches --------------------------------------------------- - fn mk_patch( - uuid: &str, - purl: &str, - tier: &str, - published_at: &str, - ) -> PatchSearchResult { + fn mk_patch(uuid: &str, purl: &str, tier: &str, published_at: &str) -> PatchSearchResult { PatchSearchResult { uuid: uuid.into(), purl: purl.into(), @@ -2218,7 +2219,10 @@ mod tests { #[test] fn short_uuid_truncates_normal_uuid() { - assert_eq!(short_uuid("80630680-4da6-45f9-bba8-b888e0ffd58c"), "80630680"); + assert_eq!( + short_uuid("80630680-4da6-45f9-bba8-b888e0ffd58c"), + "80630680" + ); } #[test] @@ -2234,7 +2238,7 @@ mod tests { // char boundary here — but byte 7 would not be). Use a value whose // 8th byte splits a char to exercise the None fallback. let s = "ab€cd"; // '€' is 3 bytes: bytes are a b € c d -> len 7 - // get(..8) is out of range -> None -> whole string, no panic. + // get(..8) is out of range -> None -> whole string, no panic. assert_eq!(short_uuid(s), s); // A value where byte 8 splits the trailing multibyte char. let s2 = "abcdef€"; // 6 ascii + 3-byte '€' = 9 bytes; byte 8 mid-char diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 4d3d0e3..f1a0cdd 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -204,9 +204,7 @@ mod tests { //! Inline tests for `list` JSON output. Pin the new envelope shape //! so downstream consumers (PR bots, dashboards) can rely on it. use super::*; - use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchRecord, VulnerabilityInfo, - }; + use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord, VulnerabilityInfo}; use std::collections::HashMap; fn sample_manifest() -> PatchManifest { @@ -244,7 +242,10 @@ mod tests { }, ); - PatchManifest { patches, setup: None } + PatchManifest { + patches, + setup: None, + } } /// A manifest with several patches, each carrying multiple @@ -289,7 +290,11 @@ mod tests { let mut patches = HashMap::new(); patches.insert( "pkg:npm/zeta@1.0.0".to_string(), - record("uuid-z", &["GHSA-zzzz-2222-3333", "GHSA-aaaa-2222-3333"], &["z/b.js", "z/a.js"]), + record( + "uuid-z", + &["GHSA-zzzz-2222-3333", "GHSA-aaaa-2222-3333"], + &["z/b.js", "z/a.js"], + ), ); patches.insert( "pkg:npm/alpha@1.0.0".to_string(), @@ -299,7 +304,10 @@ mod tests { "pkg:npm/mid@1.0.0".to_string(), record("uuid-m", &["GHSA-cccc-2222-3333"], &["m/x.js"]), ); - PatchManifest { patches, setup: None } + PatchManifest { + patches, + setup: None, + } } #[test] diff --git a/crates/socket-patch-cli/src/commands/lock_cli.rs b/crates/socket-patch-cli/src/commands/lock_cli.rs index 77a7b67..d2d5b87 100644 --- a/crates/socket-patch-cli/src/commands/lock_cli.rs +++ b/crates/socket-patch-cli/src/commands/lock_cli.rs @@ -16,9 +16,7 @@ use std::time::Duration; use socket_patch_core::patch::apply_lock::{acquire, LockError, LockGuard}; -use crate::json_envelope::{ - Command, Envelope, EnvelopeError, PatchAction, PatchEvent, -}; +use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent}; /// Stable `errorCode` tag emitted as a `Skipped` warning event when /// `--break-lock` actually deletes a pre-existing lock file. Exposed @@ -112,12 +110,19 @@ pub fn acquire_or_emit( // `break_probe_held_message` takes no timeout precisely so // the wrong value can't be passed back in. let msg = break_probe_held_message(); - emit(command, json, silent, dry_run, "lock_held", &msg, Some(socket_dir)); + emit( + command, + json, + silent, + dry_run, + "lock_held", + &msg, + Some(socket_dir), + ); return Err(1); } Err(LockError::Io { path, source }) => { - let msg = - format!("failed to open lock file at {}: {}", path.display(), source); + let msg = format!("failed to open lock file at {}: {}", path.display(), source); emit(command, json, silent, dry_run, "lock_io", &msg, None); return Err(1); } @@ -149,7 +154,15 @@ pub fn acquire_or_emit( path.display(), source ); - emit(command, json, silent, dry_run, "lock_break_failed", &msg, None); + emit( + command, + json, + silent, + dry_run, + "lock_break_failed", + &msg, + None, + ); return Err(1); } } @@ -256,7 +269,10 @@ fn emit( hint_dir: Option<&Path>, ) { if json { - println!("{}", error_envelope(command, dry_run, code, message).to_pretty_json()); + println!( + "{}", + error_envelope(command, dry_run, code, message).to_pretty_json() + ); } else if !silent { eprintln!("Error: {message}."); if hint_dir.is_some() { @@ -440,7 +456,10 @@ mod tests { true, // break_lock ) .unwrap_err(); - assert_eq!(code, 1, "break-lock must refuse a live holder, not steal it"); + assert_eq!( + code, 1, + "break-lock must refuse a live holder, not steal it" + ); // The original holder's lock file is untouched. assert!(dir.path().join("apply.lock").is_file()); } @@ -506,7 +525,10 @@ mod tests { #[test] fn held_message_zero_timeout_omits_waited_clause() { let msg = held_message(Duration::ZERO); - assert!(!msg.contains("waited"), "zero budget should not claim a wait: {msg}"); + assert!( + !msg.contains("waited"), + "zero budget should not claim a wait: {msg}" + ); } /// Regression: the `--break-lock` pre-acquire probe is a non-blocking diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index d36ac29..192ac42 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -4,16 +4,14 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result}; use socket_patch_core::utils::purl::purl_matches_identifier; -use socket_patch_core::utils::telemetry::{track_patch_removed, track_patch_remove_failed}; +use socket_patch_core::utils::telemetry::{track_patch_remove_failed, track_patch_removed}; use std::path::Path; use std::time::Duration; use super::rollback::{all_files_already_original, rollback_patches}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event}; -use crate::json_envelope::{ - Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status, -}; +use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status}; use crate::output::confirm; /// Emit a `remove` error envelope and return. Used by the many error @@ -37,7 +35,11 @@ pub struct RemoveArgs { pub common: GlobalArgs, /// Skip rolling back files before removing (only update manifest). - #[arg(long = "skip-rollback", env = "SOCKET_SKIP_ROLLBACK", default_value_t = false)] + #[arg( + long = "skip-rollback", + env = "SOCKET_SKIP_ROLLBACK", + default_value_t = false + )] pub skip_rollback: bool, } @@ -84,7 +86,11 @@ pub async fn run(args: RemoveArgs) -> i32 { let manifest = match read_manifest(&manifest_path).await { Ok(Some(m)) => m, Ok(None) => { - emit_error_envelope(args.common.json, "manifest_invalid", "Invalid manifest".to_string()); + emit_error_envelope( + args.common.json, + "manifest_invalid", + "Invalid manifest".to_string(), + ); return 1; } Err(e) => { @@ -120,10 +126,7 @@ pub async fn run(args: RemoveArgs) -> i32 { env.error = Some(EnvelopeError::new("not_found", msg)); println!("{}", env.to_pretty_json()); } else { - eprintln!( - "No patch found matching identifier: {}", - args.identifier - ); + eprintln!("No patch found matching identifier: {}", args.identifier); } return 1; } @@ -151,15 +154,15 @@ pub async fn run(args: RemoveArgs) -> i32 { // tolerate UUIDs shorter than 8 chars — a malformed manifest // must not panic the whole command in the display path. let short_uuid = patch.uuid.get(..8).unwrap_or(patch.uuid.as_str()); - eprintln!(" - {} (UUID: {}, {} file(s))", purl, short_uuid, file_count); + eprintln!( + " - {} (UUID: {}, {} file(s))", + purl, short_uuid, file_count + ); } eprintln!(); } - let prompt = format!( - "Remove {} patch(es) and rollback files?", - matching.len() - ); + let prompt = format!("Remove {} patch(es) and rollback files?", matching.len()); if !confirm(&prompt, true, args.common.yes, args.common.json) { if !args.common.json { println!("Removal cancelled."); @@ -254,10 +257,7 @@ pub async fn run(args: RemoveArgs) -> i32 { env.error = Some(EnvelopeError::new("not_found", msg)); println!("{}", env.to_pretty_json()); } else { - eprintln!( - "No patch found matching identifier: {}", - args.identifier - ); + eprintln!("No patch found matching identifier: {}", args.identifier); } return 1; } @@ -306,12 +306,13 @@ pub async fn run(args: RemoveArgs) -> i32 { // to sweep an orphan blob. Consumers read the blob/rollback // totals from `details`, never from `summary.removed`. if blobs_removed > 0 || rollback_count > 0 { - env.events.push( - PatchEvent::artifact(PatchAction::Removed).with_details(serde_json::json!({ - "blobsRemoved": blobs_removed, - "rolledBack": rollback_count, - })), - ); + env.events + .push(PatchEvent::artifact(PatchAction::Removed).with_details( + serde_json::json!({ + "blobsRemoved": blobs_removed, + "rolledBack": rollback_count, + }), + )); } println!("{}", env.to_pretty_json()); } @@ -405,7 +406,10 @@ mod tests { make_record("uuid-cp312"), ); patches.insert("pkg:npm/foo@1.0".to_string(), make_record("uuid-foo")); - let manifest = PatchManifest { patches, setup: None }; + let manifest = PatchManifest { + patches, + setup: None, + }; write_manifest(&dir.join("manifest.json"), &manifest) .await .expect("write manifest"); @@ -417,10 +421,9 @@ mod tests { write_multi_variant(tmp.path()).await; let manifest_path = tmp.path().join("manifest.json"); - let (removed, manifest) = - remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path) - .await - .expect("remove ok"); + let (removed, manifest) = remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path) + .await + .expect("remove ok"); // All three release variants removed; the npm package untouched. assert_eq!(removed.len(), 3); @@ -435,12 +438,10 @@ mod tests { write_multi_variant(tmp.path()).await; let manifest_path = tmp.path().join("manifest.json"); - let (removed, manifest) = remove_patch_from_manifest( - "pkg:pypi/six@1.16.0?artifact_id=sdist", - &manifest_path, - ) - .await - .expect("remove ok"); + let (removed, manifest) = + remove_patch_from_manifest("pkg:pypi/six@1.16.0?artifact_id=sdist", &manifest_path) + .await + .expect("remove ok"); // Only the sdist variant removed; the two wheels + npm remain. assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=sdist"]); @@ -456,10 +457,9 @@ mod tests { write_multi_variant(tmp.path()).await; let manifest_path = tmp.path().join("manifest.json"); - let (removed, manifest) = - remove_patch_from_manifest("uuid-cp312", &manifest_path) - .await - .expect("remove ok"); + let (removed, manifest) = remove_patch_from_manifest("uuid-cp312", &manifest_path) + .await + .expect("remove ok"); assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=wheel-cp312"]); assert_eq!(manifest.patches.len(), 3); @@ -475,16 +475,18 @@ mod tests { let mut patches = HashMap::new(); patches.insert("pkg:npm/foo@1.0".to_string(), make_record("uuid-foo")); patches.insert("pkg:npm/foobar@1.0".to_string(), make_record("uuid-foobar")); - let manifest = PatchManifest { patches, setup: None }; + let manifest = PatchManifest { + patches, + setup: None, + }; let manifest_path = tmp.path().join("manifest.json"); write_manifest(&manifest_path, &manifest) .await .expect("write manifest"); - let (removed, manifest) = - remove_patch_from_manifest("pkg:npm/foo@1.0", &manifest_path) - .await - .expect("remove ok"); + let (removed, manifest) = remove_patch_from_manifest("pkg:npm/foo@1.0", &manifest_path) + .await + .expect("remove ok"); assert_eq!(removed, vec!["pkg:npm/foo@1.0"]); assert_eq!(manifest.patches.len(), 1); @@ -530,16 +532,18 @@ mod tests { "pkg:pypi/six@1.17.0?artifact_id=sdist".to_string(), make_record("uuid-17-sdist"), ); - let manifest = PatchManifest { patches, setup: None }; + let manifest = PatchManifest { + patches, + setup: None, + }; let manifest_path = tmp.path().join("manifest.json"); write_manifest(&manifest_path, &manifest) .await .expect("write manifest"); - let (removed, manifest) = - remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path) - .await - .expect("remove ok"); + let (removed, manifest) = remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path) + .await + .expect("remove ok"); assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=sdist"]); assert_eq!(manifest.patches.len(), 1); diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index b6b0306..329b573 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -24,7 +24,11 @@ pub struct RepairArgs { /// Only download missing artifacts; skip the cleanup phase. /// Incompatible with `--offline`. - #[arg(long = "download-only", env = "SOCKET_DOWNLOAD_ONLY", default_value_t = false)] + #[arg( + long = "download-only", + env = "SOCKET_DOWNLOAD_ONLY", + default_value_t = false + )] pub download_only: bool, } @@ -34,8 +38,7 @@ pub async fn run(args: RepairArgs) -> i32 { // --offline implies strict airgap: no network calls. `--download-only` // is the inverse (network-only). The two are now mutually exclusive. if args.common.offline && args.download_only { - let msg = - "--offline and --download-only are mutually exclusive".to_string(); + let msg = "--offline and --download-only are mutually exclusive".to_string(); if args.common.json { let mut env = Envelope::new(Command::Repair); env.dry_run = args.common.dry_run; @@ -164,7 +167,8 @@ pub(crate) async fn repair_inner( let diffs_path = socket_dir.join("diffs"); let packages_path = socket_dir.join("packages"); - let download_mode = DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?; + let download_mode = + DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?; // `--silent` ("suppress non-error output") must mute the human-readable // progress just like `--json` does — otherwise a silent repair still @@ -284,7 +288,10 @@ pub(crate) async fn repair_inner( cleanup_result.blobs_checked ); } else { - println!("{}", format_cleanup_result(&cleanup_result, args.common.dry_run)); + println!( + "{}", + format_cleanup_result(&cleanup_result, args.common.dry_run) + ); } } } @@ -370,12 +377,10 @@ pub(crate) async fn repair_inner( ); } if download_failed_count > 0 { - env.record( - PatchEvent::artifact(PatchAction::Failed).with_error( - "download_failed", - format!("{} artifact(s) failed to download", download_failed_count), - ), - ); + env.record(PatchEvent::artifact(PatchAction::Failed).with_error( + "download_failed", + format!("{} artifact(s) failed to download", download_failed_count), + )); env.mark_partial_failure(); } if blobs_cleaned > 0 { @@ -384,10 +389,12 @@ pub(crate) async fn repair_inner( } else { PatchAction::Removed }; - env.record(PatchEvent::artifact(cleanup_action).with_details(serde_json::json!({ - "count": blobs_cleaned, - "checked": blobs_checked, - }))); + env.record( + PatchEvent::artifact(cleanup_action).with_details(serde_json::json!({ + "count": blobs_cleaned, + "checked": blobs_checked, + })), + ); } Ok(( env, @@ -472,12 +479,9 @@ mod tests { /// True when `env` carries the download / would-download artifact event /// (identified by its `details.mode` field, unique to that event). fn has_download_event(env: &Envelope) -> bool { - env.events.iter().any(|e| { - e.details - .as_ref() - .and_then(|d| d.get("mode")) - .is_some() - }) + env.events + .iter() + .any(|e| e.details.as_ref().and_then(|d| d.get("mode")).is_some()) } /// Regression for the offline + dry-run leak: with `--offline` set, the @@ -603,7 +607,12 @@ mod tests { // Orphan archives (unknown UUIDs) must be swept. let orphan_diff = b"orphan diff archive bytes"; // 25 bytes let orphan_pkg = b"orphan package bytes!!"; // 22 bytes - write_archive(&socket, "diffs", "99999999-9999-4999-8999-999999999999", orphan_diff); + write_archive( + &socket, + "diffs", + "99999999-9999-4999-8999-999999999999", + orphan_diff, + ); write_archive( &socket, "packages", diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 308bbf9..438fa37 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -1,15 +1,15 @@ use clap::Args; -use socket_patch_core::api::blob_fetcher::{ - fetch_blobs_by_hash, format_fetch_result, -}; +use socket_patch_core::api::blob_fetcher::{fetch_blobs_by_hash, format_fetch_result}; use socket_patch_core::api::client::get_api_client_with_overrides; use socket_patch_core::crawlers::CrawlerOptions; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::select_installed_variants; -use socket_patch_core::patch::rollback::{rollback_package_patch, RollbackResult, VerifyRollbackStatus}; +use socket_patch_core::patch::rollback::{ + rollback_package_patch, RollbackResult, VerifyRollbackStatus, +}; use socket_patch_core::utils::purl::{purl_matches_identifier, strip_purl_qualifiers}; -use socket_patch_core::utils::telemetry::{track_patch_rolled_back, track_patch_rollback_failed}; +use socket_patch_core::utils::telemetry::{track_patch_rollback_failed, track_patch_rolled_back}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -183,10 +183,7 @@ fn get_before_hash_blobs(manifest: &PatchManifest) -> HashSet { blobs } -async fn get_missing_before_blobs( - manifest: &PatchManifest, - blobs_path: &Path, -) -> HashSet { +async fn get_missing_before_blobs(manifest: &PatchManifest, blobs_path: &Path) -> HashSet { let before_blobs = get_before_hash_blobs(manifest); let mut missing = HashSet::new(); for hash in before_blobs { @@ -271,10 +268,14 @@ pub async fn run(args: RollbackArgs) -> i32 { // Validate one-off requires identifier if args.one_off && args.identifier.is_none() { if args.common.json { - println!("{}", serde_json::to_string_pretty(&serde_json::json!({ - "status": "error", - "error": "--one-off requires an identifier (UUID or PURL)", - })).unwrap()); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "error", + "error": "--one-off requires an identifier (UUID or PURL)", + })) + .unwrap() + ); } else { eprintln!("Error: --one-off requires an identifier (UUID or PURL)"); } @@ -284,10 +285,14 @@ pub async fn run(args: RollbackArgs) -> i32 { // Handle one-off mode if args.one_off { if args.common.json { - println!("{}", serde_json::to_string_pretty(&serde_json::json!({ - "status": "error", - "error": "One-off rollback mode is not yet implemented", - })).unwrap()); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "error", + "error": "One-off rollback mode is not yet implemented", + })) + .unwrap() + ); } else { eprintln!("One-off rollback mode: fetching patch data..."); } @@ -298,11 +303,15 @@ pub async fn run(args: RollbackArgs) -> i32 { if tokio::fs::metadata(&manifest_path).await.is_err() { if args.common.json { - println!("{}", serde_json::to_string_pretty(&serde_json::json!({ - "status": "error", - "error": "Manifest not found", - "path": manifest_path.display().to_string(), - })).unwrap()); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "error", + "error": "Manifest not found", + "path": manifest_path.display().to_string(), + })) + .unwrap() + ); } else if !args.common.silent { eprintln!("Manifest not found at {}", manifest_path.display()); } @@ -356,15 +365,19 @@ pub async fn run(args: RollbackArgs) -> i32 { ), })); } - println!("{}", serde_json::to_string_pretty(&serde_json::json!({ - "status": if success { "success" } else { "partial_failure" }, - "rolledBack": rolled_back_count, - "alreadyOriginal": already_original_count, - "failed": failed_count, - "dryRun": args.common.dry_run, - "warnings": warnings, - "results": results.iter().map(result_to_json).collect::>(), - })).unwrap()); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": if success { "success" } else { "partial_failure" }, + "rolledBack": rolled_back_count, + "alreadyOriginal": already_original_count, + "failed": failed_count, + "dryRun": args.common.dry_run, + "warnings": warnings, + "results": results.iter().map(result_to_json).collect::>(), + })) + .unwrap() + ); } else if !args.common.silent && !results.is_empty() { let rolled_back: Vec<_> = results .iter() @@ -445,25 +458,43 @@ pub async fn run(args: RollbackArgs) -> i32 { } if success { - track_patch_rolled_back(rolled_back_count, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_rolled_back( + rolled_back_count, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; } else { - track_patch_rollback_failed("One or more rollbacks failed", api_token.as_deref(), org_slug.as_deref()).await; + track_patch_rollback_failed( + "One or more rollbacks failed", + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; } - if success { 0 } else { 1 } + if success { + 0 + } else { + 1 + } } Err(e) => { track_patch_rollback_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; if args.common.json { - println!("{}", serde_json::to_string_pretty(&serde_json::json!({ - "status": "error", - "error": e, - "rolledBack": 0, - "alreadyOriginal": 0, - "failed": 0, - "dryRun": args.common.dry_run, - "results": [], - })).unwrap()); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "error", + "error": e, + "rolledBack": 0, + "alreadyOriginal": 0, + "failed": 0, + "dryRun": args.common.dry_run, + "results": [], + })) + .unwrap() + ); } else if !args.common.silent { eprintln!("Error: {e}"); } @@ -487,8 +518,7 @@ async fn rollback_patches_inner( .await .map_err(|e| e.to_string())?; - let patches_to_rollback = - find_patches_to_rollback(&manifest, args.identifier.as_deref()); + let patches_to_rollback = find_patches_to_rollback(&manifest, args.identifier.as_deref()); if patches_to_rollback.is_empty() { if args.identifier.is_some() { @@ -535,8 +565,7 @@ async fn rollback_patches_inner( println!("Downloading {} missing blob(s)...", missing_blobs.len()); } - let (client, _) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; + let (client, _) = get_api_client_with_overrides(args.common.api_client_overrides()).await; let fetch_result = fetch_blobs_by_hash(&missing_blobs, &blobs_path, &client, None).await; if !args.common.silent && !args.common.json { @@ -562,8 +591,7 @@ async fn rollback_patches_inner( // Partition PURLs by ecosystem let rollback_purls: Vec = patches_to_rollback.iter().map(|p| p.purl.clone()).collect(); - let partitioned = - partition_purls(&rollback_purls, args.common.ecosystems.as_deref()); + let partitioned = partition_purls(&rollback_purls, args.common.ecosystems.as_deref()); let crawler_options = CrawlerOptions { cwd: args.common.cwd.clone(), @@ -572,8 +600,12 @@ async fn rollback_patches_inner( batch_size: 100, }; - let all_packages = - find_packages_for_rollback(&partitioned, &crawler_options, args.common.silent || args.common.json).await; + let all_packages = find_packages_for_rollback( + &partitioned, + &crawler_options, + args.common.silent || args.common.json, + ) + .await; if all_packages.is_empty() { if !args.common.silent && !args.common.json { @@ -626,8 +658,10 @@ async fn rollback_patches_inner( // mismatch rather than silently skipping the package. entries } else { - let winners: HashSet = - matched.iter().map(|&i| candidates[i].0.to_string()).collect(); + let winners: HashSet = matched + .iter() + .map(|&i| candidates[i].0.to_string()) + .collect(); entries .into_iter() .filter(|(p, _)| winners.contains(*p)) @@ -730,7 +764,10 @@ mod tests { patches.insert("pkg:npm/foo@1.0".to_string(), make_record("uuid-foo")); patches.insert("pkg:npm/bar@2.0".to_string(), make_record("uuid-bar")); patches.insert("pkg:pypi/baz@3.0".to_string(), make_record("uuid-baz")); - PatchManifest { patches, setup: None } + PatchManifest { + patches, + setup: None, + } } #[test] @@ -743,8 +780,7 @@ mod tests { #[test] fn test_find_patches_to_rollback_purl_match() { let manifest = make_manifest(); - let result = - find_patches_to_rollback(&manifest, Some("pkg:npm/foo@1.0")); + let result = find_patches_to_rollback(&manifest, Some("pkg:npm/foo@1.0")); assert_eq!(result.len(), 1); assert_eq!(result[0].purl, "pkg:npm/foo@1.0"); } @@ -752,8 +788,7 @@ mod tests { #[test] fn test_find_patches_to_rollback_purl_no_match() { let manifest = make_manifest(); - let result = - find_patches_to_rollback(&manifest, Some("pkg:npm/nonexistent@1")); + let result = find_patches_to_rollback(&manifest, Some("pkg:npm/nonexistent@1")); assert!(result.is_empty()); } @@ -769,8 +804,7 @@ mod tests { #[test] fn test_find_patches_to_rollback_uuid_no_match() { let manifest = make_manifest(); - let result = - find_patches_to_rollback(&manifest, Some("uuid-does-not-exist")); + let result = find_patches_to_rollback(&manifest, Some("uuid-does-not-exist")); assert!(result.is_empty()); } @@ -791,14 +825,16 @@ mod tests { make_record("uuid-sdist"), ); patches.insert("pkg:npm/foo@1.0".to_string(), make_record("uuid-foo")); - PatchManifest { patches, setup: None } + PatchManifest { + patches, + setup: None, + } } #[test] fn test_find_patches_to_rollback_base_purl_matches_all_variants() { let manifest = make_multi_variant_manifest(); - let result = - find_patches_to_rollback(&manifest, Some("pkg:pypi/six@1.16.0")); + let result = find_patches_to_rollback(&manifest, Some("pkg:pypi/six@1.16.0")); // Base PURL (no qualifier) expands to every release variant. assert_eq!(result.len(), 3); for p in &result { @@ -809,10 +845,8 @@ mod tests { #[test] fn test_find_patches_to_rollback_qualified_purl_matches_one_variant() { let manifest = make_multi_variant_manifest(); - let result = find_patches_to_rollback( - &manifest, - Some("pkg:pypi/six@1.16.0?artifact_id=sdist"), - ); + let result = + find_patches_to_rollback(&manifest, Some("pkg:pypi/six@1.16.0?artifact_id=sdist")); // A fully-qualified PURL targets exactly one variant. assert_eq!(result.len(), 1); assert_eq!(result[0].purl, "pkg:pypi/six@1.16.0?artifact_id=sdist"); @@ -821,8 +855,7 @@ mod tests { #[test] fn test_find_patches_to_rollback_base_purl_does_not_leak_other_packages() { let manifest = make_multi_variant_manifest(); - let result = - find_patches_to_rollback(&manifest, Some("pkg:pypi/six@1.16.0")); + let result = find_patches_to_rollback(&manifest, Some("pkg:pypi/six@1.16.0")); assert!(result.iter().all(|p| p.purl.contains("six@1.16.0"))); } @@ -853,8 +886,7 @@ mod tests { verified_statuses: &[VerifyRollbackStatus], rolled_back: &[&str], ) -> RollbackResult { - let files_verified: Vec<_> = - verified_statuses.iter().cloned().map(verified).collect(); + let files_verified: Vec<_> = verified_statuses.iter().cloned().map(verified).collect(); let success = files_verified.iter().all(|f| { f.status == VerifyRollbackStatus::Ready || f.status == VerifyRollbackStatus::AlreadyOriginal @@ -988,7 +1020,10 @@ mod tests { "pkg:npm/foo@1.0.0".to_string(), record_with_file("uuid-npm", "index.js", "npm_before"), ); - let manifest = PatchManifest { patches, setup: None }; + let manifest = PatchManifest { + patches, + setup: None, + }; // Local mode (no --global / --global-prefix). let common = crate::args::GlobalArgs::default(); @@ -997,7 +1032,9 @@ mod tests { // Blobs dir holds only the npm before-blob; the cargo one is absent. let tmp = tempfile::tempdir().unwrap(); let blobs = tmp.path(); - tokio::fs::write(blobs.join("npm_before"), b"x").await.unwrap(); + tokio::fs::write(blobs.join("npm_before"), b"x") + .await + .unwrap(); // The gate must STILL report the cargo before-blob as missing — cargo // is an in-place rollback that genuinely needs it. @@ -1030,7 +1067,10 @@ mod tests { "pkg:npm/foo@1.0.0".to_string(), record_with_file("uuid-npm", "index.js", "npm_before"), ); - let manifest = PatchManifest { patches, setup: None }; + let manifest = PatchManifest { + patches, + setup: None, + }; // Local mode (no --global / --global-prefix). let common = crate::args::GlobalArgs::default(); @@ -1039,7 +1079,9 @@ mod tests { // Blobs dir holds only the npm before-blob; the go one is absent. let tmp = tempfile::tempdir().unwrap(); let blobs = tmp.path(); - tokio::fs::write(blobs.join("npm_before"), b"x").await.unwrap(); + tokio::fs::write(blobs.join("npm_before"), b"x") + .await + .unwrap(); // Full manifest: the go before-blob shows up as missing — exactly what // the buggy (cargo-only) gate left in, spuriously aborting rollback. @@ -1058,12 +1100,18 @@ mod tests { // And `is_local_redirect` must classify the go PURL as a redirect in // local mode but a global PURL as in-place (gate must keep the latter). - assert!(is_local_redirect("pkg:golang/github.com%2Fpkg%2Ferrors@0.9.1", &common)); + assert!(is_local_redirect( + "pkg:golang/github.com%2Fpkg%2Ferrors@0.9.1", + &common + )); let global = crate::args::GlobalArgs { global: true, ..crate::args::GlobalArgs::default() }; - assert!(!is_local_redirect("pkg:golang/github.com%2Fpkg%2Ferrors@0.9.1", &global)); + assert!(!is_local_redirect( + "pkg:golang/github.com%2Fpkg%2Ferrors@0.9.1", + &global + )); } /// Regression: rolling back a local-GO patch must DROP the project-local @@ -1171,6 +1219,9 @@ mod tests { &global, ) .await; - assert!(result.is_none(), "global go must not use the redirect backend"); + assert!( + result.is_none(), + "global go must not use the redirect backend" + ); } } diff --git a/crates/socket-patch-cli/src/commands/scan.rs b/crates/socket-patch-cli/src/commands/scan.rs index d3430b0..ec134b0 100644 --- a/crates/socket-patch-cli/src/commands/scan.rs +++ b/crates/socket-patch-cli/src/commands/scan.rs @@ -138,7 +138,7 @@ async fn run_apply_gc( // file-level cleanup below still operates on the in-memory copy. let _ = write_manifest(manifest_path, &manifest).await; } - run_gc(&manifest, prunable, socket_dir, /*dry_run=*/false).await + run_gc(&manifest, prunable, socket_dir, /*dry_run=*/ false).await } /// Dry-run preview of the apply-mode GC pass. Same shape as @@ -164,7 +164,7 @@ async fn preview_apply_gc( for purl in &prunable { manifest.patches.remove(purl); } - run_gc(&manifest, prunable, socket_dir, /*dry_run=*/true).await + run_gc(&manifest, prunable, socket_dir, /*dry_run=*/ true).await } /// PURL strings present in the manifest but absent from `scanned_purls`. @@ -184,8 +184,10 @@ pub(crate) fn detect_prunable( manifest: &PatchManifest, scanned_purls: &HashSet, ) -> Vec { - let scanned_bases: HashSet<&str> = - scanned_purls.iter().map(|p| strip_purl_qualifiers(p)).collect(); + let scanned_bases: HashSet<&str> = scanned_purls + .iter() + .map(|p| strip_purl_qualifiers(p)) + .collect(); manifest .patches .keys() @@ -449,8 +451,7 @@ pub async fn run(args: ScanArgs) -> i32 { // prune used the filtered set instead, `scan --ecosystems npm --prune` // would treat every cargo/go/pypi/gem manifest entry as "uninstalled" // and delete it (plus its blobs) — silent cross-ecosystem data loss. - let installed_purls: HashSet = - all_crawled.iter().map(|p| p.purl.clone()).collect(); + let installed_purls: HashSet = all_crawled.iter().map(|p| p.purl.clone()).collect(); // Filter by --ecosystems if provided let filtered_crawled: Vec<_> = if let Some(ref allowed) = args.common.ecosystems { @@ -481,7 +482,11 @@ pub async fn run(args: ScanArgs) -> i32 { 0, 0, false, - args.common.ecosystems.clone().unwrap_or_default().as_slice(), + args.common + .ecosystems + .clone() + .unwrap_or_default() + .as_slice(), false, telemetry_token.as_deref(), telemetry_org.as_deref(), @@ -531,7 +536,10 @@ pub async fn run(args: ScanArgs) -> i32 { for eco in Ecosystem::all() { let count = if args.common.ecosystems.is_some() { // When filtering, count the filtered packages - filtered_crawled.iter().filter(|p| Ecosystem::from_purl(&p.purl) == Some(*eco)).count() + filtered_crawled + .iter() + .filter(|p| Ecosystem::from_purl(&p.purl) == Some(*eco)) + .count() } else { eco_counts.get(eco).copied().unwrap_or(0) }; @@ -626,8 +634,7 @@ pub async fn run(args: ScanArgs) -> i32 { // than silently reporting zero patches (which historically looked // identical to "no patches for these packages"). if total_batches > 0 && batch_error_count == total_batches { - let err = last_batch_error - .unwrap_or_else(|| "all batches failed".to_string()); + let err = last_batch_error.unwrap_or_else(|| "all batches failed".to_string()); track_patch_scan_failed( &err, fallback_to_proxy, @@ -709,7 +716,11 @@ pub async fn run(args: ScanArgs) -> i32 { free_patches, paid_patches, can_access_paid_patches, - args.common.ecosystems.clone().unwrap_or_default().as_slice(), + args.common + .ecosystems + .clone() + .unwrap_or_default() + .as_slice(), fallback_to_proxy, telemetry_token.as_deref(), telemetry_org.as_deref(), @@ -784,9 +795,8 @@ pub async fn run(args: ScanArgs) -> i32 { // Synthesize the per-patch outcome without touching disk. // `decide_patch_action` consults the existing manifest, // so it accurately reports what `--apply` *would* do. - let manifest_for_preview = existing_manifest - .clone() - .unwrap_or_else(PatchManifest::new); + let manifest_for_preview = + existing_manifest.clone().unwrap_or_else(PatchManifest::new); let patches: Vec = selected .iter() .map(|p| { @@ -871,9 +881,14 @@ pub async fn run(args: ScanArgs) -> i32 { }; } - let final_code = - embed_vex_into_json(&args.common, &args.vex, &manifest_path, apply_code, &mut result) - .await; + let final_code = embed_vex_into_json( + &args.common, + &args.vex, + &manifest_path, + apply_code, + &mut result, + ) + .await; println!("{}", serde_json::to_string_pretty(&result).unwrap()); return final_code; } @@ -939,7 +954,11 @@ pub async fn run(args: ScanArgs) -> i32 { if can_access_paid_patches { format!("{}+{}", pkg_free, pkg_paid) } else { - format!("{}+{}", pkg_free, color(&pkg_paid.to_string(), "33", use_color)) + format!( + "{}+{}", + pkg_free, + color(&pkg_paid.to_string(), "33", use_color) + ) } } else { format!("{}", pkg_free) @@ -957,11 +976,7 @@ pub async fn run(args: ScanArgs) -> i32 { // each group sorted — see collect_vuln_ids). let vuln_ids = collect_vuln_ids(pkg); let vuln_str = if vuln_ids.len() > 2 { - format!( - "{} (+{})", - vuln_ids[..2].join(", "), - vuln_ids.len() - 2 - ) + format!("{} (+{})", vuln_ids[..2].join(", "), vuln_ids.len() - 2) } else if vuln_ids.is_empty() { "-".to_string() } else { @@ -1011,7 +1026,10 @@ pub async fn run(args: ScanArgs) -> i32 { println!( "{}", color( - &format!(" + {} additional patch(es) available with paid subscription", paid_patches), + &format!( + " + {} additional patch(es) available with paid subscription", + paid_patches + ), "33", use_color, ), @@ -1111,9 +1129,7 @@ pub async fn run(args: ScanArgs) -> i32 { } } let sev = vuln.severity.as_str(); - if highest_severity - .is_none_or(|cur| severity_order(sev) < severity_order(cur)) - { + if highest_severity.is_none_or(|cur| severity_order(sev) < severity_order(cur)) { highest_severity = Some(sev); } } @@ -1338,10 +1354,7 @@ mod tests { #[test] fn detect_updates_reports_multiple_updates() { - let m = manifest_with(&[ - ("pkg:npm/foo@1.0", "uuid-a"), - ("pkg:npm/bar@2.0", "uuid-c"), - ]); + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-c")]); let pkgs = vec![ batch_with("pkg:npm/foo@1.0", &["uuid-b"]), batch_with("pkg:npm/bar@2.0", &["uuid-d"]), @@ -1414,20 +1427,14 @@ mod tests { #[test] fn detect_prunable_all_entries_present_in_scan() { - let m = manifest_with(&[ - ("pkg:npm/foo@1.0", "uuid-a"), - ("pkg:npm/bar@2.0", "uuid-b"), - ]); + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-b")]); let s = scanned(&["pkg:npm/foo@1.0", "pkg:npm/bar@2.0"]); assert!(detect_prunable(&m, &s).is_empty()); } #[test] fn detect_prunable_returns_missing_entries() { - let m = manifest_with(&[ - ("pkg:npm/foo@1.0", "uuid-a"), - ("pkg:npm/bar@2.0", "uuid-b"), - ]); + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-b")]); // foo is still installed, bar is gone. let s = scanned(&["pkg:npm/foo@1.0"]); let mut out = detect_prunable(&m, &s); @@ -1437,10 +1444,7 @@ mod tests { #[test] fn detect_prunable_returns_everything_when_scan_is_empty() { - let m = manifest_with(&[ - ("pkg:npm/foo@1.0", "uuid-a"), - ("pkg:npm/bar@2.0", "uuid-b"), - ]); + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-b")]); let mut out = detect_prunable(&m, &scanned(&[])); out.sort(); assert_eq!( @@ -1556,7 +1560,10 @@ mod tests { "bytesReclaimable must be > 0 when an orphan blob would be freed" ); // Preview is non-mutating: blob and manifest untouched. - assert!(blob_path.exists(), "dry-run preview must not delete the blob"); + assert!( + blob_path.exists(), + "dry-run preview must not delete the blob" + ); let m = read_manifest(&manifest_path).await.unwrap().unwrap(); assert!( m.patches.contains_key("pkg:npm/gone@1.0.0"), @@ -1674,10 +1681,7 @@ mod tests { }; assert_eq!( collect_vuln_ids(&pkg), - vec![ - "CVE-2024-1".to_string(), - "GHSA-aaaa-aaaa-aaaa".to_string(), - ], + vec!["CVE-2024-1".to_string(), "GHSA-aaaa-aaaa-aaaa".to_string(),], ); } diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index af0b59c..c450134 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -1,8 +1,11 @@ use clap::Args; -use socket_patch_core::gem_setup::{self, GemSetupStatus}; #[cfg(feature = "composer")] use socket_patch_core::composer_setup::{self, ComposerSetupStatus}; use socket_patch_core::crawlers::python_crawler::is_python_project; +use socket_patch_core::crawlers::CrawlerOptions; +use socket_patch_core::gem_setup::{self, GemSetupStatus}; +use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; +use socket_patch_core::manifest::schema::{PatchManifest, SetupConfig}; use socket_patch_core::package_json::detect::{is_setup_configured_str, PackageManager}; use socket_patch_core::package_json::find::{ detect_package_manager, find_package_json_files, PackageJsonLocation, WorkspaceType, @@ -15,9 +18,6 @@ use socket_patch_core::pth_hook::{ add_hook_dependency, deps_contain_hook, detect_python_pm, pyproject_contains_hook, remove_hook_dependency, ManifestKind, PthEditResult, PthStatus, PythonPackageManager, }; -use socket_patch_core::crawlers::CrawlerOptions; -use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; -use socket_patch_core::manifest::schema::{PatchManifest, SetupConfig}; use socket_patch_core::utils::telemetry::track_patch_setup; use socket_patch_core::vex::applied_patches; use std::io::{self, Write}; @@ -90,11 +90,7 @@ pub struct SetupArgs { /// to the repo root). The exclusion is persisted in `.socket/manifest.json` /// so `setup --check` and a fresh clone honor it without re-passing the flag /// (CLI_CONTRACT property 9). - #[arg( - long = "exclude", - env = "SOCKET_SETUP_EXCLUDE", - value_delimiter = ',', - )] + #[arg(long = "exclude", env = "SOCKET_SETUP_EXCLUDE", value_delimiter = ',')] pub exclude: Vec, #[command(flatten)] @@ -584,7 +580,8 @@ async fn build_gem_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> }; out.preview.push(header.to_string()); for p in &added_paths { - out.preview.push(format!(" + {}", pathdiff(p, &common.cwd))); + out.preview + .push(format!(" + {}", pathdiff(p, &common.cwd))); } } @@ -655,7 +652,8 @@ async fn build_composer_outcome(common: &GlobalArgs, remove: bool, dry_run: bool }; out.preview.push(header.to_string()); for p in &added_paths { - out.preview.push(format!(" + {}", pathdiff(p, &common.cwd))); + out.preview + .push(format!(" + {}", pathdiff(p, &common.cwd))); } } @@ -663,7 +661,11 @@ async fn build_composer_outcome(common: &GlobalArgs, remove: bool, dry_run: bool } #[cfg(not(feature = "composer"))] -async fn build_composer_outcome(_common: &GlobalArgs, _remove: bool, _dry_run: bool) -> SetupOutcome { +async fn build_composer_outcome( + _common: &GlobalArgs, + _remove: bool, + _dry_run: bool, +) -> SetupOutcome { SetupOutcome::default() } @@ -940,9 +942,18 @@ async fn run_check(args: &SetupArgs) -> i32 { ); } - let configured = entries.iter().filter(|(_, _, s, _)| *s == CheckState::Configured).count(); - let needs = entries.iter().filter(|(_, _, s, _)| *s == CheckState::NeedsConfiguration).count(); - let errs = entries.iter().filter(|(_, _, s, _)| *s == CheckState::Error).count(); + let configured = entries + .iter() + .filter(|(_, _, s, _)| *s == CheckState::Configured) + .count(); + let needs = entries + .iter() + .filter(|(_, _, s, _)| *s == CheckState::NeedsConfiguration) + .count(); + let errs = entries + .iter() + .filter(|(_, _, s, _)| *s == CheckState::Error) + .count(); let all_ok = needs == 0 && errs == 0; let status = if errs > 0 { @@ -1060,18 +1071,34 @@ async fn run_remove(args: &SetupArgs) -> i32 { print_remove_preview(&npm_preview, &py_preview, &extra_preview, common); } - let n_remove = npm_preview.iter().filter(|r| r.status == RemoveStatus::Removed).count() - + py_preview.iter().filter(|r| r.status == PthStatus::Updated).count() + let n_remove = npm_preview + .iter() + .filter(|r| r.status == RemoveStatus::Removed) + .count() + + py_preview + .iter() + .filter(|r| r.status == PthStatus::Updated) + .count() + extra_preview.changed; - let preview_errs = npm_preview.iter().filter(|r| r.status == RemoveStatus::Error).count() - + py_preview.iter().filter(|r| r.status == PthStatus::Error).count() + let preview_errs = npm_preview + .iter() + .filter(|r| r.status == RemoveStatus::Error) + .count() + + py_preview + .iter() + .filter(|r| r.status == PthStatus::Error) + .count() + extra_preview.errors; // Nothing to remove: clean (exit 0) or some file errored (exit 1). if n_remove == 0 { if common.json { print_remove_envelope( - if preview_errs > 0 { "error" } else { "not_configured" }, + if preview_errs > 0 { + "error" + } else { + "not_configured" + }, &npm_preview, &py_preview, &extra_preview, @@ -1133,21 +1160,37 @@ async fn run_remove(args: &SetupArgs) -> i32 { build_composer_outcome(common, true, false).await, ); - let errs = npm_results.iter().filter(|r| r.status == RemoveStatus::Error).count() - + py_results.iter().filter(|r| r.status == PthStatus::Error).count() + let errs = npm_results + .iter() + .filter(|r| r.status == RemoveStatus::Error) + .count() + + py_results + .iter() + .filter(|r| r.status == PthStatus::Error) + .count() + extra_results.errors; if common.json { print_remove_envelope( - if errs > 0 { "partial_failure" } else { "success" }, + if errs > 0 { + "partial_failure" + } else { + "success" + }, &npm_results, &py_results, &extra_results, &warnings, ); } else { - let removed = npm_results.iter().filter(|r| r.status == RemoveStatus::Removed).count() - + py_results.iter().filter(|r| r.status == PthStatus::Updated).count() + let removed = npm_results + .iter() + .filter(|r| r.status == RemoveStatus::Removed) + .count() + + py_results + .iter() + .filter(|r| r.status == PthStatus::Updated) + .count() + extra_results.changed; println!("\nSummary:"); println!(" {removed} item(s) had socket-patch removed"); @@ -1194,8 +1237,14 @@ fn print_remove_preview( extra: &SetupOutcome, common: &GlobalArgs, ) { - let to_remove: Vec<_> = npm.iter().filter(|r| r.status == RemoveStatus::Removed).collect(); - let py_remove: Vec<_> = py.iter().filter(|r| r.status == PthStatus::Updated).collect(); + let to_remove: Vec<_> = npm + .iter() + .filter(|r| r.status == RemoveStatus::Removed) + .collect(); + let py_remove: Vec<_> = py + .iter() + .filter(|r| r.status == PthStatus::Updated) + .collect(); println!("\nProposed changes:\n"); if !to_remove.is_empty() { println!("Will remove socket-patch from:"); @@ -1205,7 +1254,10 @@ fn print_remove_preview( println!(" postinstall: \"{}\"", r.old_script); println!(" -> postinstall: {}", render_removed(&r.new_script)); println!(" dependencies: \"{}\"", r.old_dependencies_script); - println!(" -> dependencies: {}", render_removed(&r.new_dependencies_script)); + println!( + " -> dependencies: {}", + render_removed(&r.new_dependencies_script) + ); } println!(); } @@ -1252,13 +1304,24 @@ fn print_remove_envelope( extra: &SetupOutcome, warnings: &[String], ) { - let removed = npm.iter().filter(|r| r.status == RemoveStatus::Removed).count() + let removed = npm + .iter() + .filter(|r| r.status == RemoveStatus::Removed) + .count() + py.iter().filter(|r| r.status == PthStatus::Updated).count() + extra.changed; - let not_cfg = npm.iter().filter(|r| r.status == RemoveStatus::NotConfigured).count() - + py.iter().filter(|r| r.status == PthStatus::AlreadyConfigured).count() + let not_cfg = npm + .iter() + .filter(|r| r.status == RemoveStatus::NotConfigured) + .count() + + py.iter() + .filter(|r| r.status == PthStatus::AlreadyConfigured) + .count() + extra.already; - let errors = npm.iter().filter(|r| r.status == RemoveStatus::Error).count() + let errors = npm + .iter() + .filter(|r| r.status == RemoveStatus::Error) + .count() + py.iter().filter(|r| r.status == PthStatus::Error).count() + extra.errors; @@ -1391,17 +1454,33 @@ async fn run_setup(args: &SetupArgs) -> i32 { print_setup_preview(&npm_preview, &py_preview, &extra_preview, common); } - let n_changes = npm_preview.iter().filter(|r| r.status == UpdateStatus::Updated).count() - + py_preview.iter().filter(|r| r.status == PthStatus::Updated).count() + let n_changes = npm_preview + .iter() + .filter(|r| r.status == UpdateStatus::Updated) + .count() + + py_preview + .iter() + .filter(|r| r.status == PthStatus::Updated) + .count() + extra_preview.changed; - let preview_errors = npm_preview.iter().filter(|r| r.status == UpdateStatus::Error).count() - + py_preview.iter().filter(|r| r.status == PthStatus::Error).count() + let preview_errors = npm_preview + .iter() + .filter(|r| r.status == UpdateStatus::Error) + .count() + + py_preview + .iter() + .filter(|r| r.status == PthStatus::Error) + .count() + extra_preview.errors; if n_changes == 0 { if common.json { print_setup_envelope( - if preview_errors > 0 { "error" } else { "already_configured" }, + if preview_errors > 0 { + "error" + } else { + "already_configured" + }, &npm_preview, &py_preview, &extra_preview, @@ -1478,13 +1557,23 @@ async fn run_setup(args: &SetupArgs) -> i32 { warnings.extend(finalize_gem(common).await); } - let errors = npm_results.iter().filter(|r| r.status == UpdateStatus::Error).count() - + py_results.iter().filter(|r| r.status == PthStatus::Error).count() + let errors = npm_results + .iter() + .filter(|r| r.status == UpdateStatus::Error) + .count() + + py_results + .iter() + .filter(|r| r.status == PthStatus::Error) + .count() + extra_results.errors; if common.json { print_setup_envelope( - if errors > 0 { "partial_failure" } else { "success" }, + if errors > 0 { + "partial_failure" + } else { + "success" + }, &npm_results, &py_results, &extra_results, @@ -1493,8 +1582,14 @@ async fn run_setup(args: &SetupArgs) -> i32 { &warnings, ); } else { - let updated = npm_results.iter().filter(|r| r.status == UpdateStatus::Updated).count() - + py_results.iter().filter(|r| r.status == PthStatus::Updated).count() + let updated = npm_results + .iter() + .filter(|r| r.status == UpdateStatus::Updated) + .count() + + py_results + .iter() + .filter(|r| r.status == PthStatus::Updated) + .count() + extra_results.changed; println!("\nSummary:"); println!(" {updated} item(s) updated"); @@ -1534,8 +1629,14 @@ fn print_setup_preview( extra: &SetupOutcome, common: &GlobalArgs, ) { - let npm_changes: Vec<_> = npm.iter().filter(|r| r.status == UpdateStatus::Updated).collect(); - let py_changes: Vec<_> = py.iter().filter(|r| r.status == PthStatus::Updated).collect(); + let npm_changes: Vec<_> = npm + .iter() + .filter(|r| r.status == UpdateStatus::Updated) + .collect(); + let py_changes: Vec<_> = py + .iter() + .filter(|r| r.status == PthStatus::Updated) + .collect(); if !npm_changes.is_empty() { println!("\npackage.json files to update:"); @@ -1557,8 +1658,14 @@ fn print_setup_preview( } } - let npm_already = npm.iter().filter(|r| r.status == UpdateStatus::AlreadyConfigured).count(); - let py_already = py.iter().filter(|r| r.status == PthStatus::AlreadyConfigured).count(); + let npm_already = npm + .iter() + .filter(|r| r.status == UpdateStatus::AlreadyConfigured) + .count(); + let py_already = py + .iter() + .filter(|r| r.status == PthStatus::AlreadyConfigured) + .count(); if npm_already + py_already + extra.already > 0 { println!( "\nAlready configured (will skip): {}", @@ -1595,13 +1702,24 @@ fn print_setup_envelope( py_plan: Option<&PythonPlan>, warnings: &[String], ) { - let updated = npm.iter().filter(|r| r.status == UpdateStatus::Updated).count() + let updated = npm + .iter() + .filter(|r| r.status == UpdateStatus::Updated) + .count() + py.iter().filter(|r| r.status == PthStatus::Updated).count() + extra.changed; - let already = npm.iter().filter(|r| r.status == UpdateStatus::AlreadyConfigured).count() - + py.iter().filter(|r| r.status == PthStatus::AlreadyConfigured).count() + let already = npm + .iter() + .filter(|r| r.status == UpdateStatus::AlreadyConfigured) + .count() + + py.iter() + .filter(|r| r.status == PthStatus::AlreadyConfigured) + .count() + extra.already; - let errors = npm.iter().filter(|r| r.status == UpdateStatus::Error).count() + let errors = npm + .iter() + .filter(|r| r.status == UpdateStatus::Error) + .count() + py.iter().filter(|r| r.status == PthStatus::Error).count() + extra.errors; diff --git a/crates/socket-patch-cli/src/commands/unlock.rs b/crates/socket-patch-cli/src/commands/unlock.rs index 938fd38..f911ead 100644 --- a/crates/socket-patch-cli/src/commands/unlock.rs +++ b/crates/socket-patch-cli/src/commands/unlock.rs @@ -34,7 +34,11 @@ pub struct UnlockArgs { /// When the lock is free, also delete the lock file. Refused if /// the lock is currently held — use `--break-lock` on the /// mutating subcommand instead for that scenario. - #[arg(long = "release", env = "SOCKET_UNLOCK_RELEASE", default_value_t = false)] + #[arg( + long = "release", + env = "SOCKET_UNLOCK_RELEASE", + default_value_t = false + )] pub release: bool, } @@ -97,8 +101,13 @@ pub async fn run(args: UnlockArgs) -> i32 { // The file was never created (e.g. socket // dir existed but no run has acquired the // lock yet). Treat as success. - track_patch_unlocked(false, false, api_token.as_deref(), org_slug.as_deref()) - .await; + track_patch_unlocked( + false, + false, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; emit_free(args.common.json, &lock_file, false, true) } Err(e) => { @@ -153,11 +162,7 @@ pub async fn run(args: UnlockArgs) -> i32 { 1 } Err(LockError::Io { path, source }) => { - let msg = format!( - "failed to open lock file at {}: {}", - path.display(), - source - ); + let msg = format!("failed to open lock file at {}: {}", path.display(), source); track_patch_unlock_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; emit_error(args.common.json, args.common.silent, "lock_io", &msg); 1 diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index eb5cd6e..4c9b72c 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -248,7 +248,10 @@ pub async fn run(args: VendorArgs) -> i32 { let org_slug = telemetry_client.org_slug().cloned(); let manifest_path = args.common.resolved_manifest_path(); - let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")).to_path_buf(); + let socket_dir = manifest_path + .parent() + .unwrap_or(Path::new(".")) + .to_path_buf(); // `--revert` derives everything from state.json + the vendor tree; it // must work after the manifest was deleted. Plain vendor needs the @@ -411,7 +414,10 @@ async fn run_vendor(args: &VendorArgs, manifest_path: &Path, env: &mut Envelope) .map(|(eco, purls)| { ( eco, - purls.into_iter().filter(|p| vendor::is_vendorable(p)).collect(), + purls + .into_iter() + .filter(|p| vendor::is_vendorable(p)) + .collect(), ) }) .collect(); @@ -462,7 +468,10 @@ async fn run_vendor(args: &VendorArgs, manifest_path: &Path, env: &mut Envelope) if !handled_bases.insert(base.clone()) { continue; } - variant_groups.get(&base).cloned().unwrap_or_else(|| vec![base]) + variant_groups + .get(&base) + .cloned() + .unwrap_or_else(|| vec![base]) } else { vec![purl.clone()] }; @@ -608,13 +617,19 @@ async fn run_vendor(args: &VendorArgs, manifest_path: &Path, env: &mut Envelope) } if !common.json && !common.silent { - let verb = if common.dry_run { "Would vendor" } else { "Vendored" }; + let verb = if common.dry_run { + "Would vendor" + } else { + "Vendored" + }; println!( "{verb} {} package(s); {} skipped; {} failed.", env.summary.applied, env.summary.skipped, env.summary.failed ); if env.summary.applied > 0 && !common.dry_run { - println!("Commit .socket/vendor/ and the updated lockfiles to make the patches portable."); + println!( + "Commit .socket/vendor/ and the updated lockfiles to make the patches portable." + ); } } @@ -726,10 +741,12 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { } } else { has_errors = true; - env.record(PatchEvent::new(PatchAction::Failed, purl.clone()).with_error( - "revert_failed", - outcome.error.unwrap_or_else(|| "unknown error".into()), - )); + env.record( + PatchEvent::new(PatchAction::Failed, purl.clone()).with_error( + "revert_failed", + outcome.error.unwrap_or_else(|| "unknown error".into()), + ), + ); if !common.silent && !common.json { eprintln!("Failed to revert {purl}"); } @@ -772,7 +789,11 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { } if !common.json && !common.silent { - let verb = if common.dry_run { "Would revert" } else { "Reverted" }; + let verb = if common.dry_run { + "Would revert" + } else { + "Reverted" + }; println!( "{verb} {} vendored package(s); {} failed.", env.summary.removed, env.summary.failed diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 49920fc..f41f5ae 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -28,9 +28,7 @@ use socket_patch_core::vex::{ use crate::args::{apply_env_toggles, GlobalArgs}; use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; -use crate::json_envelope::{ - Command, Envelope, EnvelopeError, PatchAction, PatchEvent, -}; +use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent}; #[derive(Args)] pub struct VexArgs { @@ -57,7 +55,11 @@ pub struct VexArgs { /// emitted; this flag flips that off — useful when generating a /// VEX doc on a build machine that doesn't have the patched files /// laid out yet. - #[arg(long = "no-verify", env = "SOCKET_VEX_NO_VERIFY", default_value_t = false)] + #[arg( + long = "no-verify", + env = "SOCKET_VEX_NO_VERIFY", + default_value_t = false + )] pub no_verify: bool, /// Override the document `@id`. Default is `urn:uuid:`, @@ -94,7 +96,11 @@ pub struct VexEmbedArgs { /// Skip the on-disk file-hash check when building the VEX document and /// trust the manifest. See `socket-patch vex --no-verify`. - #[arg(long = "vex-no-verify", env = "SOCKET_VEX_NO_VERIFY", default_value_t = false)] + #[arg( + long = "vex-no-verify", + env = "SOCKET_VEX_NO_VERIFY", + default_value_t = false + )] pub vex_no_verify: bool, /// Pin the VEX document `@id`. See `socket-patch vex --doc-id`. @@ -102,7 +108,11 @@ pub struct VexEmbedArgs { pub vex_doc_id: Option, /// Emit compact (non-pretty) JSON for the VEX document. - #[arg(long = "vex-compact", env = "SOCKET_VEX_COMPACT", default_value_t = false)] + #[arg( + long = "vex-compact", + env = "SOCKET_VEX_COMPACT", + default_value_t = false + )] pub vex_compact: bool, } @@ -355,23 +365,24 @@ pub(crate) async fn generate_vex( tooling: Some(format!("socket-patch {}", env!("CARGO_PKG_VERSION"))), }; - let doc = match build_document_with_vendored(manifest, &outcome.applied, &outcome.vendored, &opts) - { - Some(doc) => doc, - None => { - track_vex_failed( - "no_applicable_patches", - common.api_token.as_deref(), - common.org.as_deref(), - ) - .await; - return Err(VexGenError { - code: "no_applicable_patches", - message: "No applied patches with vulnerability metadata to attest.".to_string(), - failed: outcome.failed, - }); - } - }; + let doc = + match build_document_with_vendored(manifest, &outcome.applied, &outcome.vendored, &opts) { + Some(doc) => doc, + None => { + track_vex_failed( + "no_applicable_patches", + common.api_token.as_deref(), + common.org.as_deref(), + ) + .await; + return Err(VexGenError { + code: "no_applicable_patches", + message: "No applied patches with vulnerability metadata to attest." + .to_string(), + failed: outcome.failed, + }); + } + }; // Serialize. let serialized = if params.compact { @@ -689,12 +700,13 @@ fn emit_envelope_success(doc: &Document, failures: &[FailedPatch]) { for prod in &st.products { for sub in &prod.subcomponents { env.record( - PatchEvent::new(PatchAction::Verified, sub.id.clone()) - .with_details(serde_json::json!({ + PatchEvent::new(PatchAction::Verified, sub.id.clone()).with_details( + serde_json::json!({ "vulnerability": st.vulnerability.name, "aliases": st.vulnerability.aliases, "status": "not_affected", - })), + }), + ), ); } } @@ -733,7 +745,10 @@ mod tests { #[cfg(feature = "golang")] assert_eq!(ecosystem_from_manual_name("go"), Some(Ecosystem::Golang)); #[cfg(feature = "composer")] - assert_eq!(ecosystem_from_manual_name("composer"), Some(Ecosystem::Composer)); + assert_eq!( + ecosystem_from_manual_name("composer"), + Some(Ecosystem::Composer) + ); #[cfg(feature = "maven")] assert_eq!(ecosystem_from_manual_name("maven"), Some(Ecosystem::Maven)); #[cfg(feature = "nuget")] @@ -778,13 +793,19 @@ mod tests { "v2.0.0-20210101000000-abcdef123456" )); assert!(!are_safe_go_redirect_coords("../../../etc", "v1.0.0")); - assert!(!are_safe_go_redirect_coords("github.com/../../../etc", "v1.0.0")); + assert!(!are_safe_go_redirect_coords( + "github.com/../../../etc", + "v1.0.0" + )); assert!(!are_safe_go_redirect_coords("/abs/path", "v1.0.0")); assert!(!are_safe_go_redirect_coords("github.com//bar", "v1.0.0")); assert!(!are_safe_go_redirect_coords("foo/./bar", "v1.0.0")); assert!(!are_safe_go_redirect_coords("foo\\bar", "v1.0.0")); assert!(!are_safe_go_redirect_coords("", "v1.0.0")); - assert!(!are_safe_go_redirect_coords("github.com/foo/bar", "../../../evil")); + assert!(!are_safe_go_redirect_coords( + "github.com/foo/bar", + "../../../evil" + )); assert!(!are_safe_go_redirect_coords("github.com/foo/bar", "v1/0/0")); assert!(!are_safe_go_redirect_coords("github.com/foo/bar", "..")); assert!(!are_safe_go_redirect_coords("github.com/foo/bar", "")); diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index a2d6ade..11f55a9 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -7,17 +7,17 @@ use std::path::PathBuf; #[cfg(feature = "cargo")] use socket_patch_core::crawlers::CargoCrawler; -use socket_patch_core::crawlers::RubyCrawler; +#[cfg(feature = "composer")] +use socket_patch_core::crawlers::ComposerCrawler; +#[cfg(feature = "deno")] +use socket_patch_core::crawlers::DenoCrawler; #[cfg(feature = "golang")] use socket_patch_core::crawlers::GoCrawler; #[cfg(feature = "maven")] use socket_patch_core::crawlers::MavenCrawler; -#[cfg(feature = "composer")] -use socket_patch_core::crawlers::ComposerCrawler; #[cfg(feature = "nuget")] use socket_patch_core::crawlers::NuGetCrawler; -#[cfg(feature = "deno")] -use socket_patch_core::crawlers::DenoCrawler; +use socket_patch_core::crawlers::RubyCrawler; /// Runtime opt-in gate for experimental Maven support. /// @@ -158,8 +158,7 @@ macro_rules! scan_ecosystem { /// Signature shared by `merge_first_wins` and `merge_qualified`. /// `dispatch_find` swaps between them so the rollback path can fan one /// crawler result back out to every caller-supplied qualified PURL. -type MergeFn = - fn(&mut HashMap, &[String], HashMap); +type MergeFn = fn(&mut HashMap, &[String], HashMap); /// Default merge: insert the crawler-returned PURL → first wins. fn merge_first_wins( @@ -185,9 +184,7 @@ fn merge_qualified( ) { for (base_purl, pkg) in packages { for qualified in purls { - if strip_purl_qualifiers(qualified) == base_purl - && !out.contains_key(qualified) - { + if strip_purl_qualifiers(qualified) == base_purl && !out.contains_key(qualified) { out.insert(qualified.clone(), pkg.path.clone()); } } @@ -546,7 +543,10 @@ mod tests { &purls, packages(&[("pkg:pypi/requests@2.28.0", "/sp")]), ); - assert_eq!(out.get("pkg:pypi/requests@2.28.0"), Some(&PathBuf::from("/sp"))); + assert_eq!( + out.get("pkg:pypi/requests@2.28.0"), + Some(&PathBuf::from("/sp")) + ); } #[test] @@ -615,8 +615,16 @@ mod tests { // the per-path iteration in the scan macro. let mut out: HashMap = HashMap::new(); let purls = vec!["pkg:gem/nokogiri@1.16.5?platform=arm64-darwin".to_string()]; - merge_qualified(&mut out, &purls, packages(&[("pkg:gem/nokogiri@1.16.5", "/first")])); - merge_qualified(&mut out, &purls, packages(&[("pkg:gem/nokogiri@1.16.5", "/second")])); + merge_qualified( + &mut out, + &purls, + packages(&[("pkg:gem/nokogiri@1.16.5", "/first")]), + ); + merge_qualified( + &mut out, + &purls, + packages(&[("pkg:gem/nokogiri@1.16.5", "/second")]), + ); assert_eq!( out.get("pkg:gem/nokogiri@1.16.5?platform=arm64-darwin"), Some(&PathBuf::from("/first")) @@ -670,10 +678,7 @@ mod tests { #[test] fn passthrough_purls_is_identity() { - let purls = vec![ - "pkg:npm/foo@1.0".to_string(), - "pkg:npm/bar@2.0".to_string(), - ]; + let purls = vec!["pkg:npm/foo@1.0".to_string(), "pkg:npm/bar@2.0".to_string()]; assert_eq!(passthrough_purls(&purls), purls); } @@ -784,10 +789,7 @@ mod tests { #[test] fn partition_purls_no_filter_duplicate_purls_preserved() { - let purls = vec![ - "pkg:npm/foo@1.0".to_string(), - "pkg:npm/foo@1.0".to_string(), - ]; + let purls = vec!["pkg:npm/foo@1.0".to_string(), "pkg:npm/foo@1.0".to_string()]; let map = partition_purls(&purls, None); assert_eq!(map.len(), 1); assert_eq!( @@ -952,9 +954,12 @@ mod tests { let qualified = "pkg:npm/foo@1.0.0?vcs_url=git@github.com".to_string(); let partitioned = partition_purls(std::slice::from_ref(&qualified), None); - let out = - find_packages_for_rollback(&partitioned, &local_options(tmp.path().to_path_buf()), true) - .await; + let out = find_packages_for_rollback( + &partitioned, + &local_options(tmp.path().to_path_buf()), + true, + ) + .await; assert_eq!(out.get(&qualified), Some(&pkg_dir)); } @@ -963,7 +968,9 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let empty: HashMap> = HashMap::new(); let opts = local_options(tmp.path().to_path_buf()); - assert!(find_packages_for_purls(&empty, &opts, true).await.is_empty()); + assert!(find_packages_for_purls(&empty, &opts, true) + .await + .is_empty()); assert!(find_packages_for_rollback(&empty, &opts, true) .await .is_empty()); diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 6c8211b..5a0919b 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -255,21 +255,13 @@ impl PatchEvent { self } - pub fn with_reason( - mut self, - code: impl Into, - message: impl Into, - ) -> Self { + pub fn with_reason(mut self, code: impl Into, message: impl Into) -> Self { self.error_code = Some(code.into()); self.reason = Some(message.into()); self } - pub fn with_error( - mut self, - code: impl Into, - message: impl Into, - ) -> Self { + pub fn with_error(mut self, code: impl Into, message: impl Into) -> Self { self.error_code = Some(code.into()); self.error = Some(message.into()); self @@ -376,7 +368,6 @@ pub enum Command { Vex, } - /// Top-level status. Serializes camelCase. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] @@ -482,7 +473,10 @@ mod tests { let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(|s| s.as_str()).collect(); keys.sort(); // `error` is skipped when None, so it shouldn't appear. - assert_eq!(keys, vec!["command", "dryRun", "events", "status", "summary"]); + assert_eq!( + keys, + vec!["command", "dryRun", "events", "status", "summary"] + ); assert_eq!(v["command"], "scan"); assert_eq!(v["status"], "success"); assert_eq!(v["dryRun"], false); @@ -493,7 +487,10 @@ mod tests { fn record_keeps_summary_in_sync() { let mut env = Envelope::new(Command::Apply); env.record(PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0")); - env.record(PatchEvent::new(PatchAction::Downloaded, "pkg:npm/foo@1.0.0")); + env.record(PatchEvent::new( + PatchAction::Downloaded, + "pkg:npm/foo@1.0.0", + )); env.record( PatchEvent::new(PatchAction::Skipped, "pkg:npm/bar@2.0.0") .with_reason("already_patched", "Files match afterHash"), @@ -563,14 +560,21 @@ mod tests { fn skipped_event_omits_uuid_and_files() { let event = PatchEvent::new(PatchAction::Skipped, "pkg:npm/foo@1.0.0") .with_reason("package_not_installed", "no matching package on disk"); - let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap(); + let v: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap(); let obj = v.as_object().unwrap(); assert!(!obj.contains_key("uuid")); assert!(!obj.contains_key("files")); assert!(!obj.contains_key("oldUuid")); assert!(!obj.contains_key("error")); - assert_eq!(obj.get("errorCode").and_then(|v| v.as_str()), Some("package_not_installed")); - assert_eq!(obj.get("reason").and_then(|v| v.as_str()), Some("no matching package on disk")); + assert_eq!( + obj.get("errorCode").and_then(|v| v.as_str()), + Some("package_not_installed") + ); + assert_eq!( + obj.get("reason").and_then(|v| v.as_str()), + Some("no matching package on disk") + ); } #[test] @@ -589,7 +593,8 @@ mod tests { applied_via: Some(AppliedVia::Blob), }, ]); - let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap(); + let v: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap(); let files = v["files"].as_array().unwrap(); assert_eq!(files.len(), 2); assert_eq!(files[0]["path"], "package/index.js"); @@ -611,7 +616,10 @@ mod tests { #[test] fn top_level_error_serializes_inline() { let mut env = Envelope::new(Command::Get); - env.mark_error(EnvelopeError::new("paid_required", "Patch requires paid plan")); + env.mark_error(EnvelopeError::new( + "paid_required", + "Patch requires paid plan", + )); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["status"], "error"); assert_eq!(v["error"]["code"], "paid_required"); @@ -632,7 +640,8 @@ mod tests { // GC sweep events aren't scoped to a single PURL. let event = PatchEvent::artifact(PatchAction::Removed) .with_reason("orphan_blob", "Blob not referenced by any manifest entry"); - let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap(); + let v: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap(); let obj = v.as_object().unwrap(); assert!(!obj.contains_key("purl")); assert_eq!(obj["action"], "removed"); @@ -710,7 +719,9 @@ mod tests { }); assert_eq!(env.sidecars.len(), 1); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); - let sidecars = v["sidecars"].as_array().expect("sidecars present once recorded"); + let sidecars = v["sidecars"] + .as_array() + .expect("sidecars present once recorded"); assert_eq!(sidecars.len(), 1); assert_eq!(sidecars[0]["purl"], "pkg:cargo/foo@1.0.0"); assert_eq!(sidecars[0]["ecosystem"], "cargo"); @@ -788,7 +799,10 @@ mod tests { let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["dryRun"], true); assert_eq!(v["events"][0]["details"]["tier"], "free"); - assert_eq!(v["events"][0]["details"]["vulns"], serde_json::json!([1, 2])); + assert_eq!( + v["events"][0]["details"]["vulns"], + serde_json::json!([1, 2]) + ); } #[test] diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 755b7d5..8c0181e 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -288,8 +288,8 @@ mod tests { // Every arg after the program name (UUID included) must be forwarded // after the synthesized `get`, preserving order, so multiple flags // all reach the rewritten command. - let cli = parse_with_uuid_fallback(argv(&["socket-patch", UUID, "--id", "--json"])) - .unwrap(); + let cli = + parse_with_uuid_fallback(argv(&["socket-patch", UUID, "--id", "--json"])).unwrap(); match cli.command { Commands::Get(args) => { assert_eq!(args.identifier, UUID); diff --git a/crates/socket-patch-cli/tests/api_client_errors_e2e.rs b/crates/socket-patch-cli/tests/api_client_errors_e2e.rs index d8fd159..f977209 100644 --- a/crates/socket-patch-cli/tests/api_client_errors_e2e.rs +++ b/crates/socket-patch-cli/tests/api_client_errors_e2e.rs @@ -68,7 +68,8 @@ fn assert_error_envelope(v: &serde_json::Value, needle: &str) { .unwrap_or_else(|| panic!("error field must be a string, got: {v}")); assert!(!msg.is_empty(), "error message must not be empty: {v}"); assert!( - msg.to_ascii_lowercase().contains(&needle.to_ascii_lowercase()), + msg.to_ascii_lowercase() + .contains(&needle.to_ascii_lowercase()), "error message {msg:?} must mention {needle:?}" ); } @@ -459,7 +460,11 @@ async fn get_by_ghsa_with_404_reports_not_found() { .output() .expect("run"); let code = out.status.code().unwrap_or(-1); - assert_path_hit(&mock, &format!("/v0/orgs/{ORG_SLUG}/patches/by-ghsa/{ghsa}")).await; + assert_path_hit( + &mock, + &format!("/v0/orgs/{ORG_SLUG}/patches/by-ghsa/{ghsa}"), + ) + .await; assert_eq!(code, 0, "GHSA 404 is a graceful not-found, exit 0"); let v = json_stdout(&out); assert_eq!( @@ -478,7 +483,9 @@ async fn repair_with_blob_404_marks_failure_in_summary() { let after_hash = "1111111111111111111111111111111111111111111111111111111111111111"; let mock = MockServer::start().await; Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}"))) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}" + ))) .respond_with(ResponseTemplate::new(404)) .mount(&mock) .await; @@ -530,14 +537,17 @@ async fn repair_with_blob_404_marks_failure_in_summary() { // Prove the blob download was actually attempted against the mock (and // returned 404) — the failure must come from the real fetch path, not // from repair bailing out before it ever tried to download. - assert_path_hit(&mock, &format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}")).await; + assert_path_hit( + &mock, + &format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}"), + ) + .await; assert_eq!( code, 1, "repair must exit non-zero when an artifact download fails so CI guarding on \ the exit code doesn't treat a half-finished repair as success; stdout={stdout}" ); - let v: serde_json::Value = - serde_json::from_str(stdout.trim()).expect("must be JSON"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("must be JSON"); // The repair envelope's summary tracks failures. Require BOTH the // summary counter AND a per-event `failed` record so a regression that // drops one but not the other is still caught (the original test diff --git a/crates/socket-patch-cli/tests/apply_invariants.rs b/crates/socket-patch-cli/tests/apply_invariants.rs index 4a389ff..8131edb 100644 --- a/crates/socket-patch-cli/tests/apply_invariants.rs +++ b/crates/socket-patch-cli/tests/apply_invariants.rs @@ -57,11 +57,7 @@ fn write_project(root: &Path) { // alter this file. let blobs = socket.join("blobs"); std::fs::create_dir_all(&blobs).expect("create blobs dir"); - std::fs::write( - blobs.join("sentinel"), - b"do not modify me", - ) - .expect("write sentinel"); + std::fs::write(blobs.join("sentinel"), b"do not modify me").expect("write sentinel"); // Empty node_modules so the npm crawler returns nothing. std::fs::create_dir_all(root.join("node_modules")).expect("create node_modules"); // A package.json so the crawler considers this a project root. @@ -311,8 +307,7 @@ fn apply_with_no_socket_dir_emits_no_manifest_envelope() { // Note: NO .socket/ directory at all — completely fresh tree. let (code, stdout) = run_apply(tmp.path(), &[]); assert_eq!(code, 0, "no-manifest is not an error; stdout=\n{stdout}"); - let v: serde_json::Value = - serde_json::from_str(&stdout).expect("envelope must be valid JSON"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("envelope must be valid JSON"); assert_eq!(v["command"], "apply"); assert_eq!(v["status"], "noManifest"); // noManifest is a clean no-op, not a partial failure dressed up: no @@ -346,7 +341,10 @@ fn apply_with_no_socket_dir_silent_emits_nothing() { .expect("run socket-patch"); assert_eq!(out.status.code(), Some(0)); let stdout = String::from_utf8_lossy(&out.stdout); - assert!(stdout.trim().is_empty(), "silent must produce no stdout; got {stdout:?}"); + assert!( + stdout.trim().is_empty(), + "silent must produce no stdout; got {stdout:?}" + ); // Control run: the same no-manifest scenario WITHOUT `--silent` must // print the friendly skip message to stdout. Without this control the diff --git a/crates/socket-patch-cli/tests/apply_network.rs b/crates/socket-patch-cli/tests/apply_network.rs index 43ba4fd..3dfd19a 100644 --- a/crates/socket-patch-cli/tests/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply_network.rs @@ -55,7 +55,13 @@ fn write_root_package_json(root: &Path) { .expect("write root package.json"); } -fn write_manifest_with_patch(socket: &Path, purl: &str, uuid: &str, before_hash: &str, after_hash: &str) { +fn write_manifest_with_patch( + socket: &Path, + purl: &str, + uuid: &str, + before_hash: &str, + after_hash: &str, +) { std::fs::create_dir_all(socket).expect("create .socket"); let body = format!( r#"{{ @@ -118,7 +124,9 @@ async fn apply_online_fetches_missing_blob_and_patches_file() { // The fetcher hits /v0/orgs/{slug}/patches/blob/{hash}. Return the // patched bytes so the binary's content-hash check passes. Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}"))) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}" + ))) .respond_with(ResponseTemplate::new(200).set_body_bytes(after.to_vec())) .mount(&mock) .await; @@ -147,8 +155,7 @@ async fn apply_online_fetches_missing_blob_and_patches_file() { let socket = tmp.path().join(".socket"); write_manifest_with_patch(&socket, purl, uuid, &before_hash, &after_hash); - let (code, stdout, stderr) = - run_apply(tmp.path(), &mock.uri(), &["--download-mode", "file"]); + let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &["--download-mode", "file"]); assert_eq!( code, 0, "apply must succeed; stdout={stdout}; stderr={stderr}" @@ -169,7 +176,10 @@ async fn apply_online_fetches_missing_blob_and_patches_file() { requests.iter().any(|r| r.url.path() == blob_path), "apply must fetch the missing blob from the API; \ got requests={:?}", - requests.iter().map(|r| r.url.path().to_string()).collect::>() + requests + .iter() + .map(|r| r.url.path().to_string()) + .collect::>() ); // The fetch path must have actually applied the patch (not silently // no-op'd to a green exit). Assert the JSON summary, not just exit code. @@ -192,9 +202,7 @@ async fn apply_online_fetches_missing_blob_and_patches_file() { ); // The file under node_modules should now contain the patched bytes. - let patched_path = tmp - .path() - .join("node_modules/apply-network-test/index.js"); + let patched_path = tmp.path().join("node_modules/apply-network-test/index.js"); let patched_content = std::fs::read(&patched_path).expect("read patched file"); assert_eq!( patched_content, after, @@ -234,11 +242,7 @@ async fn apply_with_ecosystem_filter_excluding_npm_skips_all_npm_patches() { let socket = tmp.path().join(".socket"); write_manifest_with_patch(&socket, purl, uuid, &before_hash, &after_hash); - let (code, stdout, stderr) = run_apply( - tmp.path(), - &mock.uri(), - &["--ecosystems", "pypi"], - ); + let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &["--ecosystems", "pypi"]); // Filtering out npm leaves nothing in scope: apply reports this as a // partial-failure (exit 1, status "partialFailure", all-zero summary). // Pin the exact contract — a disjoint `0 || 1` accept would let a @@ -255,9 +259,18 @@ async fn apply_with_ecosystem_filter_excluding_npm_skips_all_npm_patches() { // Nothing in the npm ecosystem may even be discovered/downloaded once // it's filtered out — guards against the filter being applied only at // the write step while still crawling/fetching the excluded packages. - assert_eq!(v["summary"]["discovered"], 0, "filtered npm must not be discovered"); - assert_eq!(v["summary"]["downloaded"], 0, "filtered npm must not be downloaded"); - assert_eq!(v["summary"]["failed"], 0, "skipping out-of-scope is not a failure"); + assert_eq!( + v["summary"]["discovered"], 0, + "filtered npm must not be discovered" + ); + assert_eq!( + v["summary"]["downloaded"], 0, + "filtered npm must not be downloaded" + ); + assert_eq!( + v["summary"]["failed"], 0, + "skipping out-of-scope is not a failure" + ); // The excluded npm patch must not appear as an applied/patched event — // an empty `events` array or one without our purl is fine, but a // "patched" event for the skipped purl would mean the filter leaked. @@ -271,8 +284,7 @@ async fn apply_with_ecosystem_filter_excluding_npm_skips_all_npm_patches() { } // Node_modules file must be UNCHANGED. - let content = - std::fs::read(tmp.path().join("node_modules/skipped/index.js")).unwrap(); + let content = std::fs::read(tmp.path().join("node_modules/skipped/index.js")).unwrap(); assert_eq!(content, before, "non-matching ecosystem must skip apply"); } @@ -289,13 +301,7 @@ async fn apply_dry_run_emits_verified_event_without_writing() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); - write_npm_package( - tmp.path(), - "dryrun-target", - "1.0.0", - "index.js", - before, - ); + write_npm_package(tmp.path(), "dryrun-target", "1.0.0", "index.js", before); let socket = tmp.path().join(".socket"); write_manifest_with_patch( &socket, @@ -331,8 +337,9 @@ async fn apply_dry_run_emits_verified_event_without_writing() { // The verified event must be for OUR purl, not some unrelated event; // and dry-run must NOT emit a real "patched"/"applied" action. assert!( - events.iter().any(|e| e["purl"] == "pkg:npm/dryrun-target@1.0.0" - && e["action"] == "verified"), + events + .iter() + .any(|e| e["purl"] == "pkg:npm/dryrun-target@1.0.0" && e["action"] == "verified"), "dry-run must emit a verified event for the target purl; events={events:?}" ); assert!( @@ -343,9 +350,11 @@ async fn apply_dry_run_emits_verified_event_without_writing() { ); // File content must be UNCHANGED. - let content = - std::fs::read(tmp.path().join("node_modules/dryrun-target/index.js")).unwrap(); - assert_eq!(content, before, "dry-run must not modify node_modules files"); + let content = std::fs::read(tmp.path().join("node_modules/dryrun-target/index.js")).unwrap(); + assert_eq!( + content, before, + "dry-run must not modify node_modules files" + ); } // --------------------------------------------------------------------------- @@ -366,7 +375,13 @@ async fn apply_with_force_overrides_hash_mismatch() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "force-target", "1.0.0", "index.js", actual_before); + write_npm_package( + tmp.path(), + "force-target", + "1.0.0", + "index.js", + actual_before, + ); let socket = tmp.path().join(".socket"); write_manifest_with_patch( &socket, @@ -390,7 +405,10 @@ async fn apply_with_force_overrides_hash_mismatch() { .expect("run socket-patch"); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - assert_eq!(code, 0, "--force must succeed past hash mismatch; stdout={stdout}"); + assert_eq!( + code, 0, + "--force must succeed past hash mismatch; stdout={stdout}" + ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); // With force on a HashMismatch, the diff path bails because the // on-disk hash still doesn't match `before_hash`, but the blob @@ -404,14 +422,14 @@ async fn apply_with_force_overrides_hash_mismatch() { ); let events = v["events"].as_array().expect("events array"); assert!( - events - .iter() - .all(|e| e["action"] != "failed"), + events.iter().all(|e| e["action"] != "failed"), "--force run must not emit a failed event; events={events:?}" ); - let content = - std::fs::read(tmp.path().join("node_modules/force-target/index.js")).unwrap(); - assert_eq!(content, after, "--force must overwrite file with afterHash content"); + let content = std::fs::read(tmp.path().join("node_modules/force-target/index.js")).unwrap(); + assert_eq!( + content, after, + "--force must overwrite file with afterHash content" + ); } #[tokio::test] @@ -486,7 +504,11 @@ async fn apply_pypi_package_uses_python_crawler() { let site_packages = if cfg!(windows) { tmp.path().join(".venv").join("Lib").join("site-packages") } else { - tmp.path().join(".venv").join("lib").join("python3.12").join("site-packages") + tmp.path() + .join(".venv") + .join("lib") + .join("python3.12") + .join("site-packages") }; std::fs::create_dir_all(&site_packages).expect("create site-packages"); std::fs::write(site_packages.join("index.js"), before).expect("write source"); @@ -516,13 +538,7 @@ async fn apply_pypi_package_uses_python_crawler() { // end, not just "without panicking". `VIRTUAL_ENV` is cleared so an // ambient venv in CI can't redirect discovery away from our `.venv`. let out = Command::new(binary()) - .args([ - "apply", - "--json", - "--offline", - "--ecosystems", - "pypi", - ]) + .args(["apply", "--json", "--offline", "--ecosystems", "pypi"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") .env_remove("VIRTUAL_ENV") @@ -547,8 +563,7 @@ async fn apply_pypi_package_uses_python_crawler() { assert!( events .iter() - .any(|e| e["purl"] == "pkg:pypi/pypi_target@1.0.0" - && e["action"] != "failed"), + .any(|e| e["purl"] == "pkg:pypi/pypi_target@1.0.0" && e["action"] != "failed"), "must emit a non-failed event for the pypi purl; got events={events:?}" ); @@ -614,5 +629,8 @@ async fn apply_uses_locally_cached_blob_without_fetching() { // `.socket/blobs/` must still contain the cached blob (apply is // read-only against the persistent cache). - assert!(blobs.join(&after_hash).exists(), "cached blob must survive apply"); + assert!( + blobs.join(&after_hash).exists(), + "cached blob must survive apply" + ); } diff --git a/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs b/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs index 330e21f..eda92c8 100644 --- a/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs @@ -15,11 +15,7 @@ fn binary() -> PathBuf { fn make_socket_with_empty_manifest(root: &std::path::Path) { let socket = root.join(".socket"); std::fs::create_dir_all(&socket).unwrap(); - std::fs::write( - socket.join("manifest.json"), - r#"{"patches":{}}"#, - ) - .unwrap(); + std::fs::write(socket.join("manifest.json"), r#"{"patches":{}}"#).unwrap(); std::fs::create_dir_all(socket.join("blobs")).unwrap(); } @@ -117,7 +113,11 @@ fn apply_dry_run_empty_manifest_emits_dry_run_envelope() { // known, separately-tracked production bug (it breaks the npm // postinstall hook, which runs `apply` on every install) — NOT a test // defect. Do not relax these to match the buggy output; fix the bug. - assert_eq!(out.status.code(), Some(0), "empty-manifest dry-run should exit 0: {v}"); + assert_eq!( + out.status.code(), + Some(0), + "empty-manifest dry-run should exit 0: {v}" + ); assert_eq!(v["status"], "success", "expected success status: {v}"); // A dry-run must never mutate anything: every "did work" counter is 0. // NOTE: with an *empty* manifest this is vacuously true regardless of @@ -130,7 +130,10 @@ fn apply_dry_run_empty_manifest_emits_dry_run_envelope() { assert_eq!(summary["updated"], 0, "dry-run updated a patch: {v}"); assert_eq!(summary["removed"], 0, "dry-run removed a patch: {v}"); assert_eq!(summary["downloaded"], 0, "dry-run downloaded a blob: {v}"); - assert_eq!(summary["verified"], 0, "empty manifest verified nothing: {v}"); + assert_eq!( + summary["verified"], 0, + "empty manifest verified nothing: {v}" + ); // Empty manifest → nothing to do; events stay empty. assert_eq!(v["events"], serde_json::json!([]), "unexpected events: {v}"); } @@ -147,7 +150,11 @@ fn apply_dry_run_empty_manifest_emits_dry_run_envelope() { fn apply_dry_run_with_real_patch_verifies_without_mutating() { let tmp = tempfile::tempdir().expect("tempdir"); make_applicable_npm_patch(tmp.path()); - let target = tmp.path().join("node_modules").join("dryrunpkg").join("index.js"); + let target = tmp + .path() + .join("node_modules") + .join("dryrunpkg") + .join("index.js"); // Sanity: fixture starts at the unpatched bytes. assert_eq!( @@ -172,31 +179,57 @@ fn apply_dry_run_with_real_patch_verifies_without_mutating() { }); assert_eq!(v["command"], "apply"); assert_eq!(v["dryRun"], true); - assert_eq!(out.status.code(), Some(0), "clean applicable dry-run must exit 0: {v}"); - assert_eq!(v["status"], "success", "dry-run of an applicable patch should succeed: {v}"); + assert_eq!( + out.status.code(), + Some(0), + "clean applicable dry-run must exit 0: {v}" + ); + assert_eq!( + v["status"], "success", + "dry-run of an applicable patch should succeed: {v}" + ); // The dry-run must REPORT that it would patch this package... let summary = &v["summary"]; - assert_eq!(summary["verified"], 1, "dry-run must verify the applicable patch: {v}"); + assert_eq!( + summary["verified"], 1, + "dry-run must verify the applicable patch: {v}" + ); // ...while doing zero actual mutation work. assert_eq!(summary["applied"], 0, "dry-run must not apply: {v}"); assert_eq!(summary["updated"], 0, "dry-run must not update: {v}"); assert_eq!(summary["downloaded"], 0, "dry-run must not download: {v}"); - assert_eq!(summary["failed"], 0, "dry-run should not fail on a clean patch: {v}"); + assert_eq!( + summary["failed"], 0, + "dry-run should not fail on a clean patch: {v}" + ); // The per-patch event must be a `verified` event for our exact PURL — // not a generic skip, and not an `applied` event. - let events = v["events"].as_array().expect("envelope must carry an events array"); + let events = v["events"] + .as_array() + .expect("envelope must carry an events array"); let ev = events .iter() .find(|e| e["purl"] == DRYRUN_PURL) .unwrap_or_else(|| panic!("dry-run must emit an event for {DRYRUN_PURL}: {v}")); - assert_eq!(ev["action"], "verified", "dry-run event must be `verified`: {v}"); + assert_eq!( + ev["action"], "verified", + "dry-run event must be `verified`: {v}" + ); // Dry-run events expose verified files but NEVER an appliedVia strategy. - let files = ev["files"].as_array().expect("verified event must list files"); - assert!(!files.is_empty(), "verified event must name the file it checked: {v}"); + let files = ev["files"] + .as_array() + .expect("verified event must list files"); + assert!( + !files.is_empty(), + "verified event must name the file it checked: {v}" + ); for f in files { - assert_eq!(f["verified"], true, "dry-run file must be marked verified: {v}"); + assert_eq!( + f["verified"], true, + "dry-run file must be marked verified: {v}" + ); assert!( f.get("appliedVia").map(|x| x.is_null()).unwrap_or(true), "dry-run must not record an appliedVia strategy: {v}" @@ -228,8 +261,14 @@ fn apply_dry_run_with_real_patch_verifies_without_mutating() { ) }); assert_eq!(out2.status.code(), Some(0), "real apply must succeed: {v2}"); - assert_eq!(v2["dryRun"], false, "control run must not be a dry-run: {v2}"); - assert_eq!(v2["summary"]["applied"], 1, "real apply must patch the package: {v2}"); + assert_eq!( + v2["dryRun"], false, + "control run must not be a dry-run: {v2}" + ); + assert_eq!( + v2["summary"]["applied"], 1, + "real apply must patch the package: {v2}" + ); assert_eq!( std::fs::read(&target).unwrap(), DRYRUN_PATCHED, @@ -277,16 +316,23 @@ fn rollback_with_empty_manifest_emits_envelope() { .output() .expect("run rollback"); let stdout = String::from_utf8_lossy(&out.stdout); - let v: serde_json::Value = serde_json::from_str(stdout.trim()) - .unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout:\n{stdout}\nstderr:\n{}", - String::from_utf8_lossy(&out.stderr))); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "invalid JSON: {e}\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); // Empty-but-valid manifest: rollback is a clean success that touches nothing. assert_eq!(out.status.code(), Some(0), "rollback should exit 0: {v}"); assert_eq!(v["status"], "success", "expected success status: {v}"); assert_eq!(v["rolledBack"], 0, "nothing should roll back: {v}"); assert_eq!(v["alreadyOriginal"], 0, "no files to inspect: {v}"); assert_eq!(v["failed"], 0, "no rollback should fail: {v}"); - assert_eq!(v["results"], serde_json::json!([]), "unexpected results: {v}"); + assert_eq!( + v["results"], + serde_json::json!([]), + "unexpected results: {v}" + ); } /// `remove --json` with no manifest at all: the early-exit @@ -311,7 +357,10 @@ fn remove_with_no_socket_dir_emits_manifest_not_found() { let stdout = String::from_utf8_lossy(&out.stdout); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["command"], "remove"); - assert_eq!(v["status"], "error", "missing manifest must be an error: {v}"); + assert_eq!( + v["status"], "error", + "missing manifest must be an error: {v}" + ); assert_eq!(out.status.code(), Some(1), "error must exit nonzero: {v}"); // Must be the *specific* missing-manifest code, not a generic not_found. assert_eq!( @@ -341,7 +390,10 @@ fn list_with_empty_manifest_emits_empty_envelope() { // Empty manifest: nothing discovered, no events emitted. let summary = &v["summary"]; assert!(summary.is_object(), "expected summary object; got {v}"); - assert_eq!(summary["discovered"], 0, "empty manifest discovered patches: {v}"); + assert_eq!( + summary["discovered"], 0, + "empty manifest discovered patches: {v}" + ); assert_eq!(v["events"], serde_json::json!([]), "unexpected events: {v}"); } @@ -358,5 +410,8 @@ fn apply_silent_no_manifest_produces_no_output() { .expect("run apply"); assert_eq!(out.status.code(), Some(0)); let stdout = String::from_utf8_lossy(&out.stdout); - assert!(stdout.trim().is_empty(), "silent mode should produce no stdout"); + assert!( + stdout.trim().is_empty(), + "silent mode should produce no stdout" + ); } diff --git a/crates/socket-patch-cli/tests/cli_env_deprecation.rs b/crates/socket-patch-cli/tests/cli_env_deprecation.rs index 3e110b5..d09daac 100644 --- a/crates/socket-patch-cli/tests/cli_env_deprecation.rs +++ b/crates/socket-patch-cli/tests/cli_env_deprecation.rs @@ -199,7 +199,11 @@ fn legacy_telemetry_disabled_warns() { /// must still be a complete, correct warning, not a degraded one. #[test] fn legacy_warning_fires_under_silent() { - let out = run_with_legacy_env("SOCKET_PATCH_PROXY_URL", "https://legacy.example", &["--silent"]); + let out = run_with_legacy_env( + "SOCKET_PATCH_PROXY_URL", + "https://legacy.example", + &["--silent"], + ); // The exact-line check inside this helper is the real guard: passing // `--silent` must not degrade, truncate, or suppress the warning — under // `--silent` it must be byte-for-byte the same line emitted without it. @@ -226,7 +230,11 @@ fn legacy_warning_fires_under_silent() { /// deprecation belongs on stderr, separate from the JSON payload on stdout. #[test] fn legacy_warning_fires_under_json() { - let out = run_with_legacy_env("SOCKET_PATCH_PROXY_URL", "https://legacy.example", &["--json"]); + let out = run_with_legacy_env( + "SOCKET_PATCH_PROXY_URL", + "https://legacy.example", + &["--json"], + ); assert_deprecation_warning(&out.stderr, "SOCKET_PATCH_PROXY_URL", "SOCKET_PROXY_URL"); // The whole point of routing the warning to stderr under --json is that // stdout stays parseable. Prove stdout is untouched JSON, free of the @@ -243,8 +251,12 @@ fn legacy_warning_fires_under_json() { "--json should still emit a JSON document on stdout; stdout was:\n{}", out.stdout ); - let parsed: serde_json::Value = - serde_json::from_str(trimmed).unwrap_or_else(|e| panic!("stdout must be valid JSON ({e}); stdout was:\n{}", out.stdout)); + let parsed: serde_json::Value = serde_json::from_str(trimmed).unwrap_or_else(|e| { + panic!( + "stdout must be valid JSON ({e}); stdout was:\n{}", + out.stdout + ) + }); assert_eq!( parsed.get("command").and_then(|v| v.as_str()), Some("list"), diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index c817cdf..ea1bd03 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -45,7 +45,9 @@ const DUMMY_IDENTIFIER: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; /// the assertion can distinguish "bound" from "left at default". fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArgs))> { vec![ - ("--cwd", Some("/tmp"), |c| assert_eq!(c.cwd, PathBuf::from("/tmp"))), + ("--cwd", Some("/tmp"), |c| { + assert_eq!(c.cwd, PathBuf::from("/tmp")) + }), ("--manifest-path", Some("custom.json"), |c| { assert_eq!(c.manifest_path, "custom.json") }), @@ -55,7 +57,9 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg ("--api-token", Some("tok123"), |c| { assert_eq!(c.api_token.as_deref(), Some("tok123")) }), - ("--org", Some("acme"), |c| assert_eq!(c.org.as_deref(), Some("acme"))), + ("--org", Some("acme"), |c| { + assert_eq!(c.org.as_deref(), Some("acme")) + }), ("--proxy-url", Some("https://proxy.example.com"), |c| { assert_eq!(c.proxy_url, "https://proxy.example.com") }), @@ -81,7 +85,9 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg ("--debug", None, |c| assert!(c.debug)), ("--no-telemetry", None, |c| assert!(c.no_telemetry)), ("--break-lock", None, |c| assert!(c.break_lock)), - ("--lock-timeout", Some("30"), |c| assert_eq!(c.lock_timeout, Some(30))), + ("--lock-timeout", Some("30"), |c| { + assert_eq!(c.lock_timeout, Some(30)) + }), ] } @@ -229,7 +235,10 @@ fn all_subcommands_are_covered() { .collect(); // Every real subcommand is exercised by the global-flag matrix. - let missing: Vec<&String> = real.iter().filter(|n| !tested.contains(n.as_str())).collect(); + let missing: Vec<&String> = real + .iter() + .filter(|n| !tested.contains(n.as_str())) + .collect(); assert!( missing.is_empty(), "subcommands not covered by the global-flag tests: {:?}. \ @@ -264,7 +273,9 @@ fn every_global_short_form_parses_on_every_subcommand() { // field, not just that it parses (a short silently rebound to a different // field would otherwise stay green). let shorts: &[(&str, Option<&str>, fn(&GlobalArgs))] = &[ - ("-o", Some("acme"), |c| assert_eq!(c.org.as_deref(), Some("acme"))), // --org + ("-o", Some("acme"), |c| { + assert_eq!(c.org.as_deref(), Some("acme")) + }), // --org ("-e", Some("npm"), |c| { assert_eq!(c.ecosystems.as_deref(), Some(&["npm".to_string()][..])) }), // --ecosystems @@ -477,7 +488,10 @@ fn bool_env_vars_accept_one_and_yes() { assert!(args.common.silent, "SOCKET_SILENT=y must parse as true"); assert!(args.common.dry_run, "SOCKET_DRY_RUN=1 must parse as true"); assert!(args.common.yes, "SOCKET_YES=yes must parse as true"); - assert!(args.common.break_lock, "SOCKET_BREAK_LOCK=1 must parse as true"); + assert!( + args.common.break_lock, + "SOCKET_BREAK_LOCK=1 must parse as true" + ); assert!(args.common.debug, "SOCKET_DEBUG=1 must parse as true"); assert!( args.common.no_telemetry, @@ -534,7 +548,10 @@ fn bool_env_vars_reject_zero_and_falsey() { // vacuous. std::env::set_var(var, "1"); let common = parse_list().unwrap_or_else(|e| panic!("{var}=1 should parse: {e}")); - assert!(get(&common), "{var}=1 must engage the bool (proves binding is live)"); + assert!( + get(&common), + "{var}=1 must engage the bool (proves binding is live)" + ); std::env::remove_var(var); // Each falsey idiom must resolve to false — not true, not a parse error. @@ -584,9 +601,8 @@ fn empty_bool_env_var_resolves_to_false_not_crash() { let result = Cli::try_parse_from(["socket-patch", "list"]); std::env::remove_var(var); - let cli = result.unwrap_or_else(|e| { - panic!("{var}= (empty) must parse cleanly, got error: {e}") - }); + let cli = + result.unwrap_or_else(|e| panic!("{var}= (empty) must parse cleanly, got error: {e}")); assert!( !accessor(common_of(&cli)), "{var}= (empty) must resolve to false", @@ -724,7 +740,10 @@ fn production_defaults_populate_when_unset() { // non-empty, so api_client_overrides must forward them. let o = c.api_client_overrides(); assert_eq!(o.api_url.as_deref(), Some("https://api.socket.dev")); - assert_eq!(o.proxy_url.as_deref(), Some("https://patches-api.socket.dev")); + assert_eq!( + o.proxy_url.as_deref(), + Some("https://patches-api.socket.dev") + ); assert!(o.api_token.is_none()); assert!(o.org_slug.is_none()); diff --git a/crates/socket-patch-cli/tests/cli_parse_apply.rs b/crates/socket-patch-cli/tests/cli_parse_apply.rs index f18399f..0add267 100644 --- a/crates/socket-patch-cli/tests/cli_parse_apply.rs +++ b/crates/socket-patch-cli/tests/cli_parse_apply.rs @@ -174,7 +174,10 @@ fn default_download_mode_is_diff() { /// `.socket/manifest.json` as the canonical location. #[test] fn default_manifest_path_is_dot_socket_manifest_json() { - assert_eq!(parse_apply(&[]).common.manifest_path, ".socket/manifest.json"); + assert_eq!( + parse_apply(&[]).common.manifest_path, + ".socket/manifest.json" + ); } // --------------------------------------------------------------------------- @@ -379,13 +382,18 @@ fn all_short_flags_map_to_distinct_fields() { #[test] fn cwd_long() { - assert_eq!(parse_apply(&["--cwd", "/tmp/x"]).common.cwd, PathBuf::from("/tmp/x")); + assert_eq!( + parse_apply(&["--cwd", "/tmp/x"]).common.cwd, + PathBuf::from("/tmp/x") + ); } #[test] fn manifest_path_long() { assert_eq!( - parse_apply(&["--manifest-path", "custom.json"]).common.manifest_path, + parse_apply(&["--manifest-path", "custom.json"]) + .common + .manifest_path, "custom.json" ); } @@ -393,7 +401,9 @@ fn manifest_path_long() { #[test] fn global_prefix_long() { assert_eq!( - parse_apply(&["--global-prefix", "/foo"]).common.global_prefix, + parse_apply(&["--global-prefix", "/foo"]) + .common + .global_prefix, Some(PathBuf::from("/foo")) ); } @@ -401,7 +411,9 @@ fn global_prefix_long() { #[test] fn api_url_long() { assert_eq!( - parse_apply(&["--api-url", "https://api.example.test"]).common.api_url, + parse_apply(&["--api-url", "https://api.example.test"]) + .common + .api_url, "https://api.example.test" ); } @@ -409,7 +421,10 @@ fn api_url_long() { #[test] fn api_token_long() { assert_eq!( - parse_apply(&["--api-token", "tok-123"]).common.api_token.as_deref(), + parse_apply(&["--api-token", "tok-123"]) + .common + .api_token + .as_deref(), Some("tok-123") ); } @@ -417,24 +432,35 @@ fn api_token_long() { #[test] fn proxy_url_long() { assert_eq!( - parse_apply(&["--proxy-url", "https://proxy.example.test"]).common.proxy_url, + parse_apply(&["--proxy-url", "https://proxy.example.test"]) + .common + .proxy_url, "https://proxy.example.test" ); } #[test] fn org_long() { - assert_eq!(parse_apply(&["--org", "acme"]).common.org.as_deref(), Some("acme")); + assert_eq!( + parse_apply(&["--org", "acme"]).common.org.as_deref(), + Some("acme") + ); } #[test] fn org_short() { - assert_eq!(parse_apply(&["-o", "acme"]).common.org.as_deref(), Some("acme")); + assert_eq!( + parse_apply(&["-o", "acme"]).common.org.as_deref(), + Some("acme") + ); } #[test] fn lock_timeout_long() { - assert_eq!(parse_apply(&["--lock-timeout", "30"]).common.lock_timeout, Some(30)); + assert_eq!( + parse_apply(&["--lock-timeout", "30"]).common.lock_timeout, + Some(30) + ); } #[test] @@ -453,8 +479,14 @@ fn ecosystems_short() { #[test] fn ecosystems_csv_splits_into_vec() { assert_eq!( - parse_apply(&["--ecosystems", "npm,pypi,cargo"]).common.ecosystems, - Some(vec!["npm".to_string(), "pypi".to_string(), "cargo".to_string()]) + parse_apply(&["--ecosystems", "npm,pypi,cargo"]) + .common + .ecosystems, + Some(vec![ + "npm".to_string(), + "pypi".to_string(), + "cargo".to_string() + ]) ); } @@ -472,20 +504,32 @@ fn ecosystems_single_value() { #[test] fn download_mode_diff() { - assert_eq!(parse_apply(&["--download-mode", "diff"]).common.download_mode, "diff"); + assert_eq!( + parse_apply(&["--download-mode", "diff"]) + .common + .download_mode, + "diff" + ); } #[test] fn download_mode_package() { assert_eq!( - parse_apply(&["--download-mode", "package"]).common.download_mode, + parse_apply(&["--download-mode", "package"]) + .common + .download_mode, "package" ); } #[test] fn download_mode_file() { - assert_eq!(parse_apply(&["--download-mode", "file"]).common.download_mode, "file"); + assert_eq!( + parse_apply(&["--download-mode", "file"]) + .common + .download_mode, + "file" + ); } /// Values pass through verbatim — no lowercasing, trimming, or aliasing at the @@ -495,13 +539,20 @@ fn download_mode_file() { fn download_mode_values_are_not_normalized() { // Case is preserved verbatim (parse does not canonicalize). assert_eq!( - parse_apply(&["--download-mode", "DIFF"]).common.download_mode, + parse_apply(&["--download-mode", "DIFF"]) + .common + .download_mode, "DIFF" ); // The three valid tokens are distinct and round-trip exactly. for token in ["diff", "package", "file"] { - let got = parse_apply(&["--download-mode", token]).common.download_mode; - assert_eq!(got, token, "download-mode `{token}` must round-trip exactly"); + let got = parse_apply(&["--download-mode", token]) + .common + .download_mode; + assert_eq!( + got, token, + "download-mode `{token}` must round-trip exactly" + ); } } diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index f04ed0e..ca5b11f 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use clap::Parser; -use socket_patch_cli::commands::list::{ListArgs, run}; +use socket_patch_cli::commands::list::{run, ListArgs}; use socket_patch_cli::{Cli, Commands}; use socket_patch_core::manifest::schema::{ PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, @@ -83,12 +83,10 @@ fn populated_manifest() -> PatchManifest { files.insert( "package/index.js".to_string(), PatchFileInfo { - before_hash: - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111" - .to_string(), - after_hash: - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1111" - .to_string(), + before_hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111" + .to_string(), + after_hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1111" + .to_string(), }, ); @@ -117,7 +115,10 @@ fn populated_manifest() -> PatchManifest { }, ); - PatchManifest { patches, setup: None } + PatchManifest { + patches, + setup: None, + } } #[tokio::test] @@ -271,12 +272,7 @@ fn missing_manifest_json_status_is_error_via_binary() { // (object with code + message), plus the usual envelope fields. let tmp = tempfile::tempdir().unwrap(); let out = Command::new(env!("CARGO_BIN_EXE_socket-patch")) - .args([ - "list", - "--cwd", - tmp.path().to_str().unwrap(), - "--json", - ]) + .args(["list", "--cwd", tmp.path().to_str().unwrap(), "--json"]) .output() .expect("failed to execute socket-patch binary"); @@ -319,9 +315,8 @@ fn run_list_with_manifest_body(body: &str) -> (Option, serde_json::Value) { std::fs::write(socket_dir.join("manifest.json"), body).unwrap(); let out = run_list_binary(tmp.path(), &["--json"]); - let v: serde_json::Value = - serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON envelope"); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON envelope"); (out.status.code(), v) } @@ -375,9 +370,8 @@ fn missing_manifest_under_valid_cwd_reports_manifest_not_found_via_binary() { // file was corrupt. It was masked by a now-removed metadata pre-check.) let tmp = tempfile::tempdir().unwrap(); let out = run_list_binary(tmp.path(), &["--json"]); - let v: serde_json::Value = - serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON envelope"); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON envelope"); assert_eq!(out.status.code(), Some(1), "missing manifest must exit 1"); assert_eq!(v["status"], "error"); assert_eq!( @@ -414,9 +408,8 @@ fn manifest_path_is_existing_directory_reports_unreadable_via_binary() { tmp.path(), &["--json", "--manifest-path", manifest_path.to_str().unwrap()], ); - let v: serde_json::Value = - serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON envelope"); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON envelope"); assert_eq!(out.status.code(), Some(1), "I/O error must exit 1"); assert_eq!(v["status"], "error"); assert_eq!( @@ -472,21 +465,45 @@ fn populated_manifest_plain_lists_full_record_via_binary() { ); // Every field of the single record must be rendered, not just an exit 0. - assert!(stdout.contains("Found 1 patch(es):"), "missing count header: {stdout}"); - assert!(stdout.contains("Package: pkg:npm/test-pkg@1.0.0"), "missing purl: {stdout}"); + assert!( + stdout.contains("Found 1 patch(es):"), + "missing count header: {stdout}" + ); + assert!( + stdout.contains("Package: pkg:npm/test-pkg@1.0.0"), + "missing purl: {stdout}" + ); assert!( stdout.contains("UUID: 11111111-1111-4111-8111-111111111111"), "missing uuid: {stdout}" ); assert!(stdout.contains("Tier: free"), "missing tier: {stdout}"); assert!(stdout.contains("License: MIT"), "missing license: {stdout}"); - assert!(stdout.contains("Exported: 2024-01-01T00:00:00Z"), "missing exportedAt: {stdout}"); - assert!(stdout.contains("Description: Test patch"), "missing description: {stdout}"); - assert!(stdout.contains("GHSA-test-test-test"), "missing advisory id: {stdout}"); + assert!( + stdout.contains("Exported: 2024-01-01T00:00:00Z"), + "missing exportedAt: {stdout}" + ); + assert!( + stdout.contains("Description: Test patch"), + "missing description: {stdout}" + ); + assert!( + stdout.contains("GHSA-test-test-test"), + "missing advisory id: {stdout}" + ); assert!(stdout.contains("CVE-2024-0001"), "missing cve: {stdout}"); - assert!(stdout.contains("Severity: high"), "missing severity: {stdout}"); - assert!(stdout.contains("Summary: test vuln"), "missing summary: {stdout}"); - assert!(stdout.contains("package/index.js"), "missing patched file path: {stdout}"); + assert!( + stdout.contains("Severity: high"), + "missing severity: {stdout}" + ); + assert!( + stdout.contains("Summary: test vuln"), + "missing summary: {stdout}" + ); + assert!( + stdout.contains("package/index.js"), + "missing patched file path: {stdout}" + ); } #[test] @@ -502,9 +519,8 @@ fn populated_manifest_json_envelope_via_binary() { String::from_utf8_lossy(&out.stderr) ); - let v: serde_json::Value = - serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON"); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); assert_eq!(v["command"], "list"); assert_eq!(v["status"], "success"); assert_eq!(v["summary"]["discovered"], 1); @@ -550,7 +566,10 @@ fn empty_manifest_plain_says_no_patches_via_binary() { "empty manifest must report no patches, got: {stdout}" ); // Guard against a regression that prints a record anyway. - assert!(!stdout.contains("Package:"), "empty manifest must not list any package: {stdout}"); + assert!( + !stdout.contains("Package:"), + "empty manifest must not list any package: {stdout}" + ); } #[test] @@ -560,9 +579,8 @@ fn empty_manifest_json_has_no_events_via_binary() { let out = run_list_binary(tmp.path(), &["--json"]); assert_eq!(out.status.code(), Some(0), "empty list --json must exit 0"); - let v: serde_json::Value = - serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON"); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); assert_eq!(v["command"], "list"); assert_eq!(v["status"], "success"); assert_eq!(v["summary"]["discovered"], 0); @@ -645,7 +663,10 @@ fn multi_manifest() -> PatchManifest { &["mmm/only.js"], ), ); - PatchManifest { patches, setup: None } + PatchManifest { + patches, + setup: None, + } } /// Byte offset of `needle` in `haystack`; panics with context if absent. @@ -741,9 +762,8 @@ fn multi_manifest_json_lists_all_records_sorted_via_binary() { String::from_utf8_lossy(&out.stderr) ); - let v: serde_json::Value = - serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON"); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); assert_eq!(v["status"], "success"); assert_eq!(v["summary"]["discovered"], 3, "discovered count must be 3"); @@ -787,7 +807,11 @@ fn multi_manifest_json_lists_all_records_sorted_via_binary() { .iter() .map(|f| f["path"].as_str().expect("path")) .collect(); - assert_eq!(paths, vec!["zzz/a.js", "zzz/z.js"], "files must be sorted by path"); + assert_eq!( + paths, + vec!["zzz/a.js", "zzz/z.js"], + "files must be sorted by path" + ); } #[test] @@ -810,14 +834,23 @@ fn absolute_manifest_path_content_wins_over_cwd_via_binary() { .patches .insert("pkg:npm/abs-only-pkg@9.9.9".to_string(), rec); let abs_path = tmp_manifest_dir.path().join("abs.json"); - std::fs::write(&abs_path, serde_json::to_string_pretty(&abs_manifest).unwrap()).unwrap(); + std::fs::write( + &abs_path, + serde_json::to_string_pretty(&abs_manifest).unwrap(), + ) + .unwrap(); let out = run_list_binary( tmp_cwd.path(), &["--manifest-path", abs_path.to_str().unwrap()], ); let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!(out.status.code(), Some(0), "must exit 0, stderr={}", String::from_utf8_lossy(&out.stderr)); + assert_eq!( + out.status.code(), + Some(0), + "must exit 0, stderr={}", + String::from_utf8_lossy(&out.stderr) + ); assert!( stdout.contains("pkg:npm/abs-only-pkg@9.9.9"), "absolute manifest's package must be listed: {stdout}" diff --git a/crates/socket-patch-cli/tests/cli_parse_main.rs b/crates/socket-patch-cli/tests/cli_parse_main.rs index 3d2fbb5..e0f9c76 100644 --- a/crates/socket-patch-cli/tests/cli_parse_main.rs +++ b/crates/socket-patch-cli/tests/cli_parse_main.rs @@ -119,8 +119,7 @@ fn apply_subcommand_parses() { #[test] fn rollback_subcommand_parses_without_identifier() { // rollback's identifier is optional — bare `rollback` must succeed. - let cli = - parse(&["socket-patch", "rollback"]).expect("rollback must parse with no positional"); + let cli = parse(&["socket-patch", "rollback"]).expect("rollback must parse with no positional"); assert!(matches!(cli.command, Commands::Rollback(_))); } diff --git a/crates/socket-patch-cli/tests/cli_parse_remove.rs b/crates/socket-patch-cli/tests/cli_parse_remove.rs index 5c63f35..519f5ea 100644 --- a/crates/socket-patch-cli/tests/cli_parse_remove.rs +++ b/crates/socket-patch-cli/tests/cli_parse_remove.rs @@ -85,11 +85,7 @@ fn global_long_form() { #[test] fn manifest_path_long_form() { - let args = parse_remove(&[ - "pkg:npm/foo@1", - "--manifest-path", - "custom/manifest.json", - ]); + let args = parse_remove(&["pkg:npm/foo@1", "--manifest-path", "custom/manifest.json"]); assert_eq!(args.common.manifest_path, "custom/manifest.json"); } @@ -113,12 +109,11 @@ fn json_long_form() { #[test] fn global_prefix_long_form() { - let args = parse_remove(&[ - "pkg:npm/foo@1", - "--global-prefix", - "/opt/node-global", - ]); - assert_eq!(args.common.global_prefix, Some(PathBuf::from("/opt/node-global"))); + let args = parse_remove(&["pkg:npm/foo@1", "--global-prefix", "/opt/node-global"]); + assert_eq!( + args.common.global_prefix, + Some(PathBuf::from("/opt/node-global")) + ); } #[test] @@ -142,7 +137,10 @@ fn all_flags_combined() { assert!(args.skip_rollback); assert!(args.common.yes); assert!(args.common.global); - assert_eq!(args.common.global_prefix, Some(PathBuf::from("/opt/node-global"))); + assert_eq!( + args.common.global_prefix, + Some(PathBuf::from("/opt/node-global")) + ); assert!(args.common.json); } @@ -246,9 +244,15 @@ async fn run_removes_matching_patch_and_exits_zero() { "pkg:npm/bar@2".to_string(), record("22222222-2222-2222-2222-222222222222"), ); - write_manifest(&manifest_path, &PatchManifest { patches, setup: None }) - .await - .expect("write manifest"); + write_manifest( + &manifest_path, + &PatchManifest { + patches, + setup: None, + }, + ) + .await + .expect("write manifest"); let args = RemoveArgs { common: socket_patch_cli::args::GlobalArgs { @@ -351,9 +355,8 @@ fn missing_manifest_json_envelope_via_binary() { "missing manifest must exit 1, stderr={}", String::from_utf8_lossy(&out.stderr) ); - let v: serde_json::Value = - serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON envelope"); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON envelope"); assert_eq!(v["command"], "remove"); assert_eq!(v["status"], "error", "missing manifest is a hard error"); assert_eq!( @@ -386,9 +389,8 @@ fn no_match_json_envelope_via_binary() { "no-match remove must exit 1, stderr={}", String::from_utf8_lossy(&out.stderr) ); - let v: serde_json::Value = - serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON envelope"); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON envelope"); assert_eq!(v["command"], "remove"); assert_eq!(v["status"], "notFound", "unmatched identifier → notFound"); assert_eq!(v["error"]["code"], "not_found"); @@ -423,9 +425,8 @@ fn removes_matching_patch_json_envelope_via_binary() { String::from_utf8_lossy(&out.stderr) ); - let v: serde_json::Value = - serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON envelope"); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON envelope"); assert_eq!(v["command"], "remove"); assert_eq!(v["status"], "success"); assert_eq!( @@ -450,10 +451,9 @@ fn removes_matching_patch_json_envelope_via_binary() { // The on-disk manifest must actually reflect the removal — parsed // independently of the production schema types. - let after: serde_json::Value = serde_json::from_slice( - &std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(), - ) - .expect("manifest still valid JSON"); + let after: serde_json::Value = + serde_json::from_slice(&std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap()) + .expect("manifest still valid JSON"); let patches = after["patches"].as_object().expect("patches object"); assert!( !patches.contains_key("pkg:npm/foo@1"), diff --git a/crates/socket-patch-cli/tests/cli_parse_repair.rs b/crates/socket-patch-cli/tests/cli_parse_repair.rs index 5681db6..ebb2fc5 100644 --- a/crates/socket-patch-cli/tests/cli_parse_repair.rs +++ b/crates/socket-patch-cli/tests/cli_parse_repair.rs @@ -28,9 +28,9 @@ use std::path::PathBuf; use clap::Parser; -use socket_patch_core::api::blob_fetcher::DownloadMode; use socket_patch_cli::commands::repair::RepairArgs; use socket_patch_cli::{Cli, Commands}; +use socket_patch_core::api::blob_fetcher::DownloadMode; /// Every `SOCKET_*` env var that clap consults while parsing `repair` (its own /// `--download-only` flag plus the flattened `GlobalArgs`). If any leaks in @@ -413,8 +413,9 @@ fn top_level_help() -> String { fn repair_appears_in_top_level_help() { let help = top_level_help(); assert!( - help.lines().any(|l| l.trim_start().starts_with("repair ") - || l.trim_start().starts_with("repair\t")), + help.lines().any( + |l| l.trim_start().starts_with("repair ") || l.trim_start().starts_with("repair\t") + ), "`repair` must be listed in --help output:\n{help}" ); } diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs index 0f00f4f..f0d582f 100644 --- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs +++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs @@ -389,11 +389,7 @@ fn second_positional_fails() { #[test] fn unknown_flag_fails() { - let err = match Cli::try_parse_from([ - "socket-patch", - "rollback", - "--unknown-flag", - ]) { + let err = match Cli::try_parse_from(["socket-patch", "rollback", "--unknown-flag"]) { Ok(_) => panic!("expected parse failure"), Err(e) => e, }; diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 05338e0..806dba0 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -121,8 +121,14 @@ fn defaults_match_contract() { assert_eq!(args.common.api_url, "https://api.socket.dev"); assert_eq!(args.common.api_token, None); assert_eq!(args.common.ecosystems, None); - assert!(!args.apply, "--apply default is false (scan --json stays read-only)"); - assert!(!args.prune, "--prune default is false (GC is opt-in in v3.0)"); + assert!( + !args.apply, + "--apply default is false (scan --json stays read-only)" + ); + assert!( + !args.prune, + "--prune default is false (GC is opt-in in v3.0)" + ); assert!(!args.sync, "--sync default is false"); assert!(!args.common.dry_run, "--dry-run default is false"); assert!( @@ -226,7 +232,10 @@ fn json_flag() { #[serial_test::serial] fn global_prefix_flag() { let args = parse_scan(&["--global-prefix", "/foo"]); - assert_eq!(args.common.global_prefix, Some(std::path::PathBuf::from("/foo"))); + assert_eq!( + args.common.global_prefix, + Some(std::path::PathBuf::from("/foo")) + ); } #[test] @@ -521,7 +530,8 @@ fn scan_json_empty_cwd_emits_updates_key() { "updates": [], }); assert_eq!( - v, expected, + v, + expected, "empty-scan JSON contract drifted.\nexpected:\n{}\ngot:\n{}", serde_json::to_string_pretty(&expected).unwrap(), serde_json::to_string_pretty(&v).unwrap(), @@ -530,7 +540,10 @@ fn scan_json_empty_cwd_emits_updates_key() { // Belt-and-suspenders on the two type invariants the contract names, // in case the object above is ever loosened during maintenance. assert!(v["packages"].is_array(), "packages must be an array"); - assert!(v["updates"].is_array(), "updates must be present and an array"); + assert!( + v["updates"].is_array(), + "updates must be present and an array" + ); assert!( v.get("gc").is_none(), "no `gc` sub-object may appear when --prune was not passed" diff --git a/crates/socket-patch-cli/tests/cli_parse_setup.rs b/crates/socket-patch-cli/tests/cli_parse_setup.rs index 96378d1..597b839 100644 --- a/crates/socket-patch-cli/tests/cli_parse_setup.rs +++ b/crates/socket-patch-cli/tests/cli_parse_setup.rs @@ -333,8 +333,14 @@ fn subprocess_configures_real_package_json() { "postinstall must invoke `socket-patch apply`, got {postinstall:?}" ); // Original metadata must be preserved, not clobbered. - assert_eq!(parsed["name"], "demo", "setup must preserve existing fields"); - assert_eq!(parsed["version"], "1.0.0", "setup must preserve existing fields"); + assert_eq!( + parsed["name"], "demo", + "setup must preserve existing fields" + ); + assert_eq!( + parsed["version"], "1.0.0", + "setup must preserve existing fields" + ); } // --------------------------------------------------------------------------- @@ -464,7 +470,10 @@ fn subprocess_already_configured_is_idempotent() { v2["status"], "already_configured", "re-running setup on a configured project must report 'already_configured'; payload: {v2}" ); - assert_eq!(v2["updated"], 0, "no further updates expected; payload: {v2}"); + assert_eq!( + v2["updated"], 0, + "no further updates expected; payload: {v2}" + ); let after_second = std::fs::read_to_string(&pkg_path).expect("read"); assert_eq!( diff --git a/crates/socket-patch-cli/tests/cli_parse_vendor.rs b/crates/socket-patch-cli/tests/cli_parse_vendor.rs index 4e6f96d..97b458d 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vendor.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vendor.rs @@ -118,10 +118,7 @@ fn parse_vendor(extra: &[&str]) -> VendorArgs { /// result so env-wiring tests can assert both the success and the failure /// shapes. The injected vars are removed before the scrub guard restores /// the ambient values. -fn parse_vendor_with_env( - env: &[(&str, &str)], - extra: &[&str], -) -> Result { +fn parse_vendor_with_env(env: &[(&str, &str)], extra: &[&str]) -> Result { let _scrub = EnvScrub::new(); for (k, v) in env { std::env::set_var(k, v); @@ -498,7 +495,11 @@ fn env_socket_vendor_revert_falsey_tokens_keep_revert_off() { for token in ["0", "false", "no", "off"] { let a = parse_vendor_with_env(&[("SOCKET_VENDOR_REVERT", token)], &[]) .unwrap_or_else(|e| panic!("SOCKET_VENDOR_REVERT={token} must parse: {e}")); - assert_eq!(snapshot(&a), expected_defaults(), "SOCKET_VENDOR_REVERT={token}"); + assert_eq!( + snapshot(&a), + expected_defaults(), + "SOCKET_VENDOR_REVERT={token}" + ); } } @@ -526,8 +527,8 @@ fn env_socket_vendor_revert_garbage_is_rejected() { fn cli_revert_flag_wins_over_falsey_env() { // Precedence contract: CLI arg > env var. A falsey env value must not // override an explicit `--revert` on the argv. - let a = parse_vendor_with_env(&[("SOCKET_VENDOR_REVERT", "false")], &["--revert"]) - .expect("parse"); + let a = + parse_vendor_with_env(&[("SOCKET_VENDOR_REVERT", "false")], &["--revert"]).expect("parse"); let mut want = expected_defaults(); want.revert = true; assert_eq!(snapshot(&a), want); @@ -553,7 +554,9 @@ fn vendor_appears_in_subcommand_list() { assert!( cmd.get_subcommands().any(|c| c.get_name() == "vendor"), "`vendor` must be a registered subcommand; found: {:?}", - cmd.get_subcommands().map(|c| c.get_name()).collect::>() + cmd.get_subcommands() + .map(|c| c.get_name()) + .collect::>() ); } @@ -567,9 +570,8 @@ fn vendor_appears_in_top_level_help() { }; let help = format!("{err}"); assert!( - help.lines().any(|l| { - l.trim_start().starts_with("vendor ") || l.trim_start() == "vendor" - }), + help.lines() + .any(|l| { l.trim_start().starts_with("vendor ") || l.trim_start() == "vendor" }), "`vendor` must be listed in --help output:\n{help}" ); } diff --git a/crates/socket-patch-cli/tests/common/mod.rs b/crates/socket-patch-cli/tests/common/mod.rs index a30ca6c..6bc804a 100644 --- a/crates/socket-patch-cli/tests/common/mod.rs +++ b/crates/socket-patch-cli/tests/common/mod.rs @@ -56,13 +56,11 @@ pub fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { /// to flip the per-ecosystem runtime gates (`SOCKET_EXPERIMENTAL_NUGET`) /// or override discovery roots (`NUGET_PACKAGES`, `GOMODCACHE`) without /// touching the parent process's environment — keeps tests parallel-safe. -pub fn run_with_env( - cwd: &Path, - args: &[&str], - env: &[(&str, &str)], -) -> (i32, String, String) { +pub fn run_with_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd).env_remove("SOCKET_API_TOKEN"); + cmd.args(args) + .current_dir(cwd) + .env_remove("SOCKET_API_TOKEN"); for (k, v) in env { cmd.env(k, v); } @@ -101,8 +99,7 @@ pub fn git_sha256(content: &[u8]) -> String { /// Git-SHA-256 of the file at `path`. Panics if the file can't be /// read — tests use this on paths they know exist. pub fn git_sha256_file(path: &Path) -> String { - let content = - std::fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let content = std::fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); git_sha256(&content) } @@ -303,15 +300,11 @@ mod oracle_selftests { use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; // Independently computed: sha256(b"blob \0" + content). - const GIT_BLOB_EMPTY: &str = - "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813"; - const GIT_BLOB_HELLO: &str = - "8aec4e4876f854f688d0ebfc8f37598f38e5fd6903cccc850ca36591175aeb60"; + const GIT_BLOB_EMPTY: &str = "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813"; + const GIT_BLOB_HELLO: &str = "8aec4e4876f854f688d0ebfc8f37598f38e5fd6903cccc850ca36591175aeb60"; // Independently computed: bare sha256(content), no Git framing. - const SHA256_EMPTY: &str = - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; - const SHA256_HELLO: &str = - "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + const SHA256_EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + const SHA256_HELLO: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; #[test] fn git_sha256_matches_independent_golden() { @@ -374,8 +367,7 @@ mod oracle_selftests { // which differs in nothing BUT the length digits. let content = b"socket-patch length-header probe"; let mut framed_with_len = Vec::new(); - framed_with_len - .extend_from_slice(format!("blob {}\0", content.len()).as_bytes()); + framed_with_len.extend_from_slice(format!("blob {}\0", content.len()).as_bytes()); framed_with_len.extend_from_slice(content); assert_eq!( git_sha256(content), @@ -402,7 +394,8 @@ mod oracle_selftests { let h = git_sha256(b"hello"); assert_eq!(h.len(), 64, "hash must be 32 bytes of hex"); assert!( - h.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()), + h.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()), "hash must be lowercase hex, got {h}" ); } @@ -445,8 +438,11 @@ mod oracle_selftests { // Unique temp dir per (pid, callsite) so the fixture-builder self-tests // never collide with each other or across parallel test binaries. fn scratch_dir(tag: &str) -> PathBuf { - let d = std::env::temp_dir() - .join(format!("socket-patch-oracle-{}-{}", std::process::id(), tag)); + let d = std::env::temp_dir().join(format!( + "socket-patch-oracle-{}-{}", + std::process::id(), + tag + )); let _ = std::fs::remove_dir_all(&d); d } @@ -480,8 +476,7 @@ mod oracle_selftests { "manifest must land at /manifest.json" ); let raw = std::fs::read_to_string(&path).expect("manifest written"); - let v: serde_json::Value = - serde_json::from_str(&raw).expect("manifest must be valid JSON"); + let v: serde_json::Value = serde_json::from_str(&raw).expect("manifest must be valid JSON"); let patch = v .get("patches") @@ -561,7 +556,11 @@ mod oracle_selftests { // Non-string and absent top-level fields must yield None, not a coerced // value — otherwise `assert_eq!(json_string(..), Some(..))` could be // dodged or a missing field read as empty. - assert_eq!(json_string(&env, "count"), None, "numeric field is not a string"); + assert_eq!( + json_string(&env, "count"), + None, + "numeric field is not a string" + ); assert_eq!(json_string(&env, "missing"), None); assert_eq!(envelope_error_code(&env), Some("lock_held")); assert_eq!( diff --git a/crates/socket-patch-cli/tests/docker_e2e_cargo.rs b/crates/socket-patch-cli/tests/docker_e2e_cargo.rs index 891b642..9966217 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_cargo.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_cargo.rs @@ -53,8 +53,7 @@ fn git_sha256(content: &[u8]) -> String { } async fn make_mock_server(after_hash: &str) -> MockServer { - let listener = - std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); let server = MockServer::builder().listener(listener).start().await; Mock::given(method("POST")) @@ -74,7 +73,9 @@ async fn make_mock_server(after_hash: &str) -> MockServer { .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": PURL, @@ -350,7 +351,9 @@ async fn cargo_fetch_full_apply_chain() { let batch = received .iter() .find(|r| format!("{}", r.method) == "POST" && r.url.path().contains("/patches/batch")) - .unwrap_or_else(|| panic!("scan should have POSTed /patches/batch; received={received:#?}")); + .unwrap_or_else(|| { + panic!("scan should have POSTed /patches/batch; received={received:#?}") + }); let batch_body = String::from_utf8_lossy(&batch.body); assert!( batch_body.contains(PURL), diff --git a/crates/socket-patch-cli/tests/docker_e2e_composer.rs b/crates/socket-patch-cli/tests/docker_e2e_composer.rs index 3101acd..6686e82 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_composer.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_composer.rs @@ -135,8 +135,7 @@ exit 0 } async fn make_mock_server(after_hash: &str) -> MockServer { - let listener = - std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); let server = MockServer::builder().listener(listener).start().await; Mock::given(method("POST")) @@ -156,7 +155,9 @@ async fn make_mock_server(after_hash: &str) -> MockServer { .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": PURL, diff --git a/crates/socket-patch-cli/tests/docker_e2e_deno.rs b/crates/socket-patch-cli/tests/docker_e2e_deno.rs index 90138d2..e8a7a3a 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_deno.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_deno.rs @@ -80,8 +80,7 @@ fn cov_docker_args() -> Vec { /// minimist fixture as `docker_e2e_npm.rs`; we duplicate it here to /// keep this test file self-contained. async fn make_npm_mock_server(after_hash: &str) -> MockServer { - let listener = - std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock to 0.0.0.0:0"); + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock to 0.0.0.0:0"); let server = MockServer::builder().listener(listener).start().await; Mock::given(method("POST")) @@ -151,9 +150,7 @@ async fn make_npm_mock_server(after_hash: &str) -> MockServer { .await; Mock::given(method("GET")) - .and(path(format!( - "/v0/orgs/{ORG}/patches/blob/{after_hash}" - ))) + .and(path(format!("/v0/orgs/{ORG}/patches/blob/{after_hash}"))) .respond_with(ResponseTemplate::new(200).set_body_bytes(PATCHED_BYTES)) .mount(&server) .await; diff --git a/crates/socket-patch-cli/tests/docker_e2e_gem.rs b/crates/socket-patch-cli/tests/docker_e2e_gem.rs index b0a540a..8fa6f5d 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_gem.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_gem.rs @@ -120,8 +120,7 @@ exit 0 } async fn make_mock_server(after_hash: &str) -> MockServer { - let listener = - std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); let server = MockServer::builder().listener(listener).start().await; Mock::given(method("POST")) @@ -141,7 +140,9 @@ async fn make_mock_server(after_hash: &str) -> MockServer { .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": PURL, diff --git a/crates/socket-patch-cli/tests/docker_e2e_golang.rs b/crates/socket-patch-cli/tests/docker_e2e_golang.rs index fa6e9b8..e281432 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_golang.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_golang.rs @@ -51,8 +51,7 @@ fn git_sha256(content: &[u8]) -> String { } async fn make_mock_server(after_hash: &str) -> MockServer { - let listener = - std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); let server = MockServer::builder().listener(listener).start().await; Mock::given(method("POST")) @@ -72,7 +71,9 @@ async fn make_mock_server(after_hash: &str) -> MockServer { .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": PURL, @@ -331,8 +332,7 @@ async fn golang_download_full_apply_chain() { // this test is named after). The body is // `{"components":[{"purl":"pkg:golang/.../gin@v1.9.1"}]}`. let batch_with_purl = received.iter().any(|r| { - r.url.path().contains("/patches/batch") - && String::from_utf8_lossy(&r.body).contains(PURL) + r.url.path().contains("/patches/batch") && String::from_utf8_lossy(&r.body).contains(PURL) }); assert!( batch_with_purl, diff --git a/crates/socket-patch-cli/tests/docker_e2e_maven.rs b/crates/socket-patch-cli/tests/docker_e2e_maven.rs index a818579..526bbf4 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_maven.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_maven.rs @@ -60,8 +60,7 @@ fn git_sha256(content: &[u8]) -> String { } async fn make_mock_server(after_hash: &str) -> MockServer { - let listener = - std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); let server = MockServer::builder().listener(listener).start().await; Mock::given(method("POST")) @@ -81,7 +80,9 @@ async fn make_mock_server(after_hash: &str) -> MockServer { .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": PURL, diff --git a/crates/socket-patch-cli/tests/docker_e2e_npm.rs b/crates/socket-patch-cli/tests/docker_e2e_npm.rs index 77576a3..3b4e1d6 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_npm.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_npm.rs @@ -40,7 +40,8 @@ const UUID: &str = "11111111-1111-4111-8111-111111111111"; /// Marker we splice into the patched bytes so the test can assert /// post-apply that the file has been overwritten. -const PATCHED_BYTES: &[u8] = b"/* SOCKET-PATCH-E2E-MARKER */\nmodule.exports = function () { return {}; };\n"; +const PATCHED_BYTES: &[u8] = + b"/* SOCKET-PATCH-E2E-MARKER */\nmodule.exports = function () { return {}; };\n"; /// Git-SHA256: SHA256("blob \0" ++ content). Matches the binary's /// content-addressable hashing for fetched blobs. @@ -101,8 +102,7 @@ async fn make_mock_server(after_hash: &str) -> MockServer { // Bind to 0.0.0.0 so the container can reach the host via the // `host.docker.internal` alias (added with `--add-host` in // `run_in_container`). Random port chosen by the kernel. - let listener = - std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock to 0.0.0.0:0"); + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock to 0.0.0.0:0"); let server = MockServer::builder().listener(listener).start().await; // 1. Batch search → returns one patch for the installed PURL. @@ -567,7 +567,9 @@ fn skip_if_no_docker_image() -> bool { .args(["image", "inspect", "socket-patch-test-npm:latest"]) .output() else { - eprintln!("skipping: `docker` not on PATH (set SOCKET_PATCH_TEST_HOST=1 to run on the host)"); + eprintln!( + "skipping: `docker` not on PATH (set SOCKET_PATCH_TEST_HOST=1 to run on the host)" + ); return true; }; if !out.status.success() { diff --git a/crates/socket-patch-cli/tests/docker_e2e_nuget.rs b/crates/socket-patch-cli/tests/docker_e2e_nuget.rs index c638a01..7cad49e 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_nuget.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_nuget.rs @@ -74,8 +74,7 @@ fn plain_sha256(content: &[u8]) -> String { } async fn make_mock_server(after_hash: &str) -> MockServer { - let listener = - std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); let server = MockServer::builder().listener(listener).start().await; Mock::given(method("POST")) @@ -95,7 +94,9 @@ async fn make_mock_server(after_hash: &str) -> MockServer { .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": PURL, diff --git a/crates/socket-patch-cli/tests/docker_e2e_pypi.rs b/crates/socket-patch-cli/tests/docker_e2e_pypi.rs index be88c83..29b4a43 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_pypi.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_pypi.rs @@ -99,7 +99,9 @@ async fn assert_api_path_exercised(server: &MockServer) { let batch = received .iter() .find(|r| format!("{}", r.method) == "POST" && r.url.path().contains("/patches/batch")) - .unwrap_or_else(|| panic!("scan should have POSTed /patches/batch; received={received:#?}")); + .unwrap_or_else(|| { + panic!("scan should have POSTed /patches/batch; received={received:#?}") + }); let batch_body = String::from_utf8_lossy(&batch.body); assert!( batch_body.contains(PURL), @@ -119,8 +121,7 @@ async fn assert_api_path_exercised(server: &MockServer) { } async fn make_mock_server(after_hash: &str) -> MockServer { - let listener = - std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock to 0.0.0.0:0"); + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock to 0.0.0.0:0"); let server = MockServer::builder().listener(listener).start().await; // 1. Batch search reports a patch for the installed PURL. diff --git a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs index 2abf245..610d94e 100644 --- a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs @@ -251,12 +251,14 @@ fn apply_vex_writes_document_on_success() { assert_eq!(compute_git_sha256_from_bytes(&on_disk), after_hash); // The VEX doc landed at --vex with a statement for our GHSA. - let doc: Value = - serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); + let doc: Value = serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); assert_eq!(doc["@context"], "https://openvex.dev/ns/v0.2.0"); assert_eq!(doc["version"], 1, "OpenVEX revision counter starts at 1"); assert!( - doc["author"].as_str().map(|s| !s.is_empty()).unwrap_or(false), + doc["author"] + .as_str() + .map(|s| !s.is_empty()) + .unwrap_or(false), "document must carry a non-empty author, got {:?}", doc["author"] ); @@ -305,8 +307,7 @@ fn apply_json_envelope_carries_vex_summary() { // The envelope's reported count must match what actually landed on // disk — otherwise a stub could report `statements: 1` while writing // an empty (or absent) document. - let doc: Value = - serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); + let doc: Value = serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); let stmts = doc["statements"].as_array().expect("doc.statements array"); assert_eq!( stmts.len(), @@ -413,12 +414,14 @@ fn scan_json_vex_no_verify_emits_summary() { assert_eq!(result["vex"]["format"], "openvex-0.2.0"); assert_eq!(result["vex"]["path"], vex_path.to_str().unwrap()); - let doc: Value = - serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); + let doc: Value = serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); assert_eq!(doc["@context"], "https://openvex.dev/ns/v0.2.0"); assert_eq!(doc["version"], 1, "OpenVEX revision counter starts at 1"); assert!( - doc["author"].as_str().map(|s| !s.is_empty()).unwrap_or(false), + doc["author"] + .as_str() + .map(|s| !s.is_empty()) + .unwrap_or(false), "document must carry a non-empty author, got {:?}", doc["author"] ); diff --git a/crates/socket-patch-cli/tests/e2e_gem.rs b/crates/socket-patch-cli/tests/e2e_gem.rs index 6f0a8c9..5bf80be 100644 --- a/crates/socket-patch-cli/tests/e2e_gem.rs +++ b/crates/socket-patch-cli/tests/e2e_gem.rs @@ -287,14 +287,7 @@ async fn scan_via_proxy(project_dir: &Path) -> (serde_json::Value, Vec) let cwd = dir.to_str().unwrap().to_string(); run( &dir, - &[ - "scan", - "--json", - "--cwd", - &cwd, - "--proxy-url", - &proxy_uri, - ], + &["scan", "--json", "--cwd", &cwd, "--proxy-url", &proxy_uri], ) }) .await @@ -329,7 +322,11 @@ async fn scan_discovers_vendored_gems() { std::fs::create_dir_all(&project_dir).unwrap(); // Create Gemfile so local mode activates - std::fs::write(project_dir.join("Gemfile"), "source 'https://rubygems.org'\n").unwrap(); + std::fs::write( + project_dir.join("Gemfile"), + "source 'https://rubygems.org'\n", + ) + .unwrap(); // Set up vendor/bundle/ruby//gems/ layout let gems_dir = project_dir @@ -444,7 +441,10 @@ fn test_gem_full_lifecycle() { assert_run_ok(cwd, &["get", GEM_UUID], "get"); let manifest_path = cwd.join(".socket/manifest.json"); - assert!(manifest_path.exists(), ".socket/manifest.json should exist after get"); + assert!( + manifest_path.exists(), + ".socket/manifest.json should exist after get" + ); let manifest: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); @@ -479,7 +479,10 @@ fn test_gem_full_lifecycle() { let vulns = patches[0]["details"]["vulnerabilities"] .as_array() .expect("vulnerabilities array"); - assert!(!vulns.is_empty(), "patch should report at least one vulnerability"); + assert!( + !vulns.is_empty(), + "patch should report at least one vulnerability" + ); let has_cve = vulns.iter().any(|v| { v["cves"] diff --git a/crates/socket-patch-cli/tests/e2e_golang.rs b/crates/socket-patch-cli/tests/e2e_golang.rs index 379f3ee..83b41e1 100644 --- a/crates/socket-patch-cli/tests/e2e_golang.rs +++ b/crates/socket-patch-cli/tests/e2e_golang.rs @@ -195,8 +195,7 @@ async fn scan_discovers_go_modules() { // assertion below would fail. let decoy_cache = cache_dir.join("cache").join("download").join("evil@v9.9.9"); std::fs::create_dir_all(&decoy_cache).unwrap(); - std::fs::create_dir_all(cache_dir.join("github.com").join("plain").join("noversion")) - .unwrap(); + std::fs::create_dir_all(cache_dir.join("github.com").join("plain").join("noversion")).unwrap(); // Create a go.mod in the project directory so local mode activates std::fs::write( @@ -291,8 +290,7 @@ async fn scan_discovers_case_encoded_modules() { // Decoy: a root-level cache/ download dir whose versioned entry must be // pruned, so the count stays at exactly one. - std::fs::create_dir_all(cache_dir.join("cache").join("download").join("evil@v9.9.9")) - .unwrap(); + std::fs::create_dir_all(cache_dir.join("cache").join("download").join("evil@v9.9.9")).unwrap(); // Create a go.mod in the project directory so local mode activates. std::fs::write( diff --git a/crates/socket-patch-cli/tests/e2e_golang_build.rs b/crates/socket-patch-cli/tests/e2e_golang_build.rs index 6ae2d9d..ab9ed60 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_build.rs @@ -58,11 +58,24 @@ fn stage(tmp: &Path) -> (std::path::PathBuf, std::path::PathBuf, String) { // File-proxy layout: proxy//@v/.{info,mod,zip}. let pxv = tmp.join("proxy").join(UMOD).join("@v"); std::fs::create_dir_all(&pxv).unwrap(); - std::fs::write(pxv.join(format!("{UVER}.info")), format!("{{\"Version\":\"{UVER}\"}}")).unwrap(); - std::fs::write(pxv.join(format!("{UVER}.mod")), format!("module {UMOD}\n\ngo 1.21\n")).unwrap(); + std::fs::write( + pxv.join(format!("{UVER}.info")), + format!("{{\"Version\":\"{UVER}\"}}"), + ) + .unwrap(); + std::fs::write( + pxv.join(format!("{UVER}.mod")), + format!("module {UMOD}\n\ngo 1.21\n"), + ) + .unwrap(); let zip_out = pxv.join(format!("{UVER}.zip")); let zip_status = Command::new("zip") - .args(["-q", "-r", zip_out.to_str().unwrap(), &format!("{UMOD}@{UVER}")]) + .args([ + "-q", + "-r", + zip_out.to_str().unwrap(), + &format!("{UMOD}@{UVER}"), + ]) .current_dir(tmp.join("stage")) .status() .expect("run zip"); @@ -89,8 +102,16 @@ fn stage(tmp: &Path) -> (std::path::PathBuf, std::path::PathBuf, String) { .unwrap(); let env = go_env(modcache.to_str().unwrap(), &proxy_url); - let dl = go(&consumer, &["mod", "download", &format!("{UMOD}@{UVER}")], &env); - assert!(dl.status.success(), "go mod download failed: {}", String::from_utf8_lossy(&dl.stderr)); + let dl = go( + &consumer, + &["mod", "download", &format!("{UMOD}@{UVER}")], + &env, + ); + assert!( + dl.status.success(), + "go mod download failed: {}", + String::from_utf8_lossy(&dl.stderr) + ); (consumer, modcache, proxy_url) } @@ -143,7 +164,11 @@ fn go_build_links_patch_via_replace_redirect() { // Baseline build links PRISTINE. let base = go(&consumer, &["run", "."], &goenv); - assert!(base.status.success(), "baseline run failed: {}", String::from_utf8_lossy(&base.stderr)); + assert!( + base.status.success(), + "baseline run failed: {}", + String::from_utf8_lossy(&base.stderr) + ); assert!(String::from_utf8_lossy(&base.stdout).contains("OUT: PRISTINE")); // Patch + apply (socket-patch reads only the cache; no `go`). This writes the @@ -158,7 +183,11 @@ fn go_build_links_patch_via_replace_redirect() { // The patched bytes are now LINKED by `go build` via the `replace` redirect. let patched = go(&consumer, &["run", "."], &goenv); - assert!(patched.status.success(), "patched run failed: {}", String::from_utf8_lossy(&patched.stderr)); + assert!( + patched.status.success(), + "patched run failed: {}", + String::from_utf8_lossy(&patched.stderr) + ); assert!( String::from_utf8_lossy(&patched.stdout).contains("OUT: PATCHED"), "patched symbol not linked: {}", @@ -183,13 +212,20 @@ fn go_build_links_patch_via_replace_redirect() { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(©_file, std::fs::Permissions::from_mode(0o644)); } - std::fs::write(©_file, "package upstream\n\nfunc Greeting() string { return \"DRIFT\" }\n").unwrap(); + std::fs::write( + ©_file, + "package upstream\n\nfunc Greeting() string { return \"DRIFT\" }\n", + ) + .unwrap(); let (code, _so, _se) = run_with_env( &consumer, &["apply", "--check", "--ecosystems", "golang", "--cwd", cs], &[("GOMODCACHE", mc)], ); - assert_ne!(code, 0, "apply --check must detect drift in the committed copy"); + assert_ne!( + code, 0, + "apply --check must detect drift in the committed copy" + ); // A fresh `apply` re-materialises the copy and `go build` links PATCHED again. let (code, _so, _se) = run_with_env( diff --git a/crates/socket-patch-cli/tests/e2e_golang_redirect.rs b/crates/socket-patch-cli/tests/e2e_golang_redirect.rs index 393dd97..c59d21f 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_redirect.rs @@ -15,7 +15,9 @@ use std::path::Path; #[path = "common/mod.rs"] mod common; -use common::{git_sha256, git_sha256_file, run_with_env, write_blob, write_minimal_manifest, PatchEntry}; +use common::{ + git_sha256, git_sha256_file, run_with_env, write_blob, write_minimal_manifest, PatchEntry, +}; const MODULE: &str = "github.com/foo/bar"; const VERSION: &str = "v1.4.2"; @@ -35,7 +37,11 @@ fn stage(root: &Path) -> (std::path::PathBuf, std::path::PathBuf) { let cache_dir = gomodcache.join(format!("{MODULE}@{VERSION}")); std::fs::create_dir_all(&cache_dir).unwrap(); std::fs::write(cache_dir.join("bar.go"), PRISTINE).unwrap(); - std::fs::write(cache_dir.join("go.mod"), "module github.com/foo/bar\n\ngo 1.21\n").unwrap(); + std::fs::write( + cache_dir.join("go.mod"), + "module github.com/foo/bar\n\ngo 1.21\n", + ) + .unwrap(); // Consumer module. std::fs::write( @@ -64,7 +70,14 @@ fn stage(root: &Path) -> (std::path::PathBuf, std::path::PathBuf) { fn apply(root: &Path, gomodcache: &Path) -> (i32, String, String) { run_with_env( root, - &["apply", "--offline", "--ecosystems", "golang", "--cwd", root.to_str().unwrap()], + &[ + "apply", + "--offline", + "--ecosystems", + "golang", + "--cwd", + root.to_str().unwrap(), + ], &[("GOMODCACHE", gomodcache.to_str().unwrap())], ) } @@ -72,7 +85,15 @@ fn apply(root: &Path, gomodcache: &Path) -> (i32, String, String) { fn check(root: &Path, gomodcache: &Path) -> i32 { run_with_env( root, - &["apply", "--check", "--offline", "--ecosystems", "golang", "--cwd", root.to_str().unwrap()], + &[ + "apply", + "--check", + "--offline", + "--ecosystems", + "golang", + "--cwd", + root.to_str().unwrap(), + ], &[("GOMODCACHE", gomodcache.to_str().unwrap())], ) .0 @@ -85,11 +106,17 @@ fn apply_materializes_redirect_and_check_passes() { let (gomodcache, cache_dir) = stage(root); let (code, stdout, stderr) = apply(root, &gomodcache); - assert_eq!(code, 0, "apply failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "apply failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); // go.mod gained the socket-owned replace. let gomod = std::fs::read_to_string(root.join("go.mod")).unwrap(); - assert!(gomod.contains(REPLACE_LINE), "replace directive missing:\n{gomod}"); + assert!( + gomod.contains(REPLACE_LINE), + "replace directive missing:\n{gomod}" + ); // The copy holds the patched bytes (== afterHash); the module cache is pristine. let copy_file = root.join(COPY_REL).join("bar.go"); @@ -104,12 +131,19 @@ fn apply_materializes_redirect_and_check_passes() { assert!(root.join(COPY_REL).join("go.mod").exists()); // In sync. - assert_eq!(check(root, &gomodcache), 0, "apply --check should be in sync"); + assert_eq!( + check(root, &gomodcache), + 0, + "apply --check should be in sync" + ); // Idempotent re-apply: still in sync, replace unchanged. assert_eq!(apply(root, &gomodcache).0, 0); assert_eq!( - std::fs::read_to_string(root.join("go.mod")).unwrap().matches(REPLACE_LINE).count(), + std::fs::read_to_string(root.join("go.mod")) + .unwrap() + .matches(REPLACE_LINE) + .count(), 1, "re-apply must not duplicate the replace" ); @@ -125,7 +159,11 @@ fn check_detects_missing_replace_and_heals() { // Simulate a `go mod tidy`/`go mod vendor` that wiped our replace. let gomod = std::fs::read_to_string(root.join("go.mod")).unwrap(); - let stripped: String = gomod.lines().filter(|l| !l.contains("go-patches")).collect::>().join("\n"); + let stripped: String = gomod + .lines() + .filter(|l| !l.contains("go-patches")) + .collect::>() + .join("\n"); std::fs::write(root.join("go.mod"), format!("{stripped}\n")).unwrap(); assert_eq!(check(root, &gomodcache), 1, "missing replace must be drift"); @@ -133,7 +171,9 @@ fn check_detects_missing_replace_and_heals() { // Heal. assert_eq!(apply(root, &gomodcache).0, 0); assert_eq!(check(root, &gomodcache), 0, "re-apply heals the replace"); - assert!(std::fs::read_to_string(root.join("go.mod")).unwrap().contains(REPLACE_LINE)); + assert!(std::fs::read_to_string(root.join("go.mod")) + .unwrap() + .contains(REPLACE_LINE)); } #[test] @@ -168,9 +208,7 @@ fn check_detects_resolved_version_mismatch() { // would silently link the UNPATCHED v1.5.0 — must be flagged. std::fs::write( root.join("go.mod"), - format!( - "module example.com/app\n\ngo 1.21\n\nrequire {MODULE} v1.5.0\n\n{REPLACE_LINE}\n" - ), + format!("module example.com/app\n\ngo 1.21\n\nrequire {MODULE} v1.5.0\n\n{REPLACE_LINE}\n"), ) .unwrap(); assert_eq!( @@ -183,7 +221,11 @@ fn check_detects_resolved_version_mismatch() { // is stale (it patches v1.4.2, the build wants v1.5.0). apply re-affirms the // v1.4.2 redirect but cannot make the build use it, so check STAYS red until // a human re-scans. (Fail-closed stays closed — never a false "in sync".) - assert_eq!(apply(root, &gomodcache).0, 0, "apply itself succeeds (re-affirms v1.4.2)"); + assert_eq!( + apply(root, &gomodcache).0, + 0, + "apply itself succeeds (re-affirms v1.4.2)" + ); assert_eq!( check(root, &gomodcache), 1, @@ -206,11 +248,24 @@ fn coexists_with_user_replace_at_different_version() { .unwrap(); let (code, so, se) = apply(root, &gomodcache); - assert_eq!(code, 0, "apply must coexist with a user replace.\n{so}\n{se}"); + assert_eq!( + code, 0, + "apply must coexist with a user replace.\n{so}\n{se}" + ); // Both replaces survive: the user's v1.0.0 fork AND our v1.4.2 redirect. let gomod = std::fs::read_to_string(root.join("go.mod")).unwrap(); - assert!(gomod.contains(&format!("replace {MODULE} v1.0.0 => ../my-fork")), "user replace clobbered:\n{gomod}"); - assert!(gomod.contains(REPLACE_LINE), "socket replace missing:\n{gomod}"); - assert_eq!(check(root, &gomodcache), 0, "check passes with both replaces present"); + assert!( + gomod.contains(&format!("replace {MODULE} v1.0.0 => ../my-fork")), + "user replace clobbered:\n{gomod}" + ); + assert!( + gomod.contains(REPLACE_LINE), + "socket replace missing:\n{gomod}" + ); + assert_eq!( + check(root, &gomodcache), + 0, + "check passes with both replaces present" + ); } diff --git a/crates/socket-patch-cli/tests/e2e_maven.rs b/crates/socket-patch-cli/tests/e2e_maven.rs index ebe2175..ba08172 100644 --- a/crates/socket-patch-cli/tests/e2e_maven.rs +++ b/crates/socket-patch-cli/tests/e2e_maven.rs @@ -146,7 +146,10 @@ fn scan_discovers_maven_artifacts() { &m2_repo, ); let json = String::from_utf8_lossy(&json_out.stdout); - assert!(json_out.status.success(), "scan --json should exit 0:\n{json}"); + assert!( + json_out.status.success(), + "scan --json should exit 0:\n{json}" + ); // Anchor on the trailing comma so this matches *exactly* 2, not any // number that merely starts with "2" (20, 25, 200, ...). Without the // comma, `contains("scannedPackages\": 2")` is satisfied by an @@ -192,11 +195,7 @@ fn scan_discovers_gradle_project_artifacts() { // Create a build.gradle in the project directory (Gradle project) let project_dir = dir.path().join("project"); std::fs::create_dir_all(&project_dir).unwrap(); - std::fs::write( - project_dir.join("build.gradle"), - "plugins { id 'java' }\n", - ) - .unwrap(); + std::fs::write(project_dir.join("build.gradle"), "plugins { id 'java' }\n").unwrap(); // --- JSON run: the `scannedPackages` count is the contract field ----- // A single artifact lives in the repo. We assert the *value* (1), not @@ -213,8 +212,8 @@ fn scan_discovers_gradle_project_artifacts() { assert!( output.status.success(), - "scan --json should exit 0; got {:?}\n{stdout}{stderr}" - , output.status.code() + "scan --json should exit 0; got {:?}\n{stdout}{stderr}", + output.status.code() ); // Anchor on the trailing comma: a bare `contains("scannedPackages\": 1")` // is also satisfied by 10..=19, 100, etc., so an over-counting crawler diff --git a/crates/socket-patch-cli/tests/e2e_npm.rs b/crates/socket-patch-cli/tests/e2e_npm.rs index 9b53307..bf963a7 100644 --- a/crates/socket-patch-cli/tests/e2e_npm.rs +++ b/crates/socket-patch-cli/tests/e2e_npm.rs @@ -133,7 +133,10 @@ fn test_npm_full_lifecycle() { npm_run(cwd, &["install", "minimist@1.2.2"]); let index_js = cwd.join("node_modules/minimist/index.js"); - assert!(index_js.exists(), "minimist/index.js must exist after npm install"); + assert!( + index_js.exists(), + "minimist/index.js must exist after npm install" + ); // Confirm the original file matches the expected before-hash. assert_eq!( @@ -147,7 +150,10 @@ fn test_npm_full_lifecycle() { // Manifest should exist and contain the patch. let manifest_path = cwd.join(".socket/manifest.json"); - assert!(manifest_path.exists(), ".socket/manifest.json should exist after get"); + assert!( + manifest_path.exists(), + ".socket/manifest.json should exist after get" + ); let manifest: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); @@ -180,7 +186,10 @@ fn test_npm_full_lifecycle() { let vulns = patches[0]["details"]["vulnerabilities"] .as_array() .expect("vulnerabilities array"); - assert!(!vulns.is_empty(), "patch should report at least one vulnerability"); + assert!( + !vulns.is_empty(), + "patch should report at least one vulnerability" + ); // Verify the vulnerability details match CVE-2021-44906 let has_cve = vulns.iter().any(|v| { @@ -265,16 +274,19 @@ fn test_npm_dry_run() { &["apply", "--dry-run", "--json"], "apply --dry-run --json", ); - let env: serde_json::Value = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("apply --dry-run --json should emit JSON: {e}\nstdout:\n{stdout}")); + let env: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("apply --dry-run --json should emit JSON: {e}\nstdout:\n{stdout}") + }); assert_eq!( env["dryRun"], serde_json::Value::Bool(true), "envelope should be flagged dryRun" ); let events = env["events"].as_array().expect("envelope events array"); - let verified: Vec<&serde_json::Value> = - events.iter().filter(|e| e["action"] == "verified").collect(); + let verified: Vec<&serde_json::Value> = events + .iter() + .filter(|e| e["action"] == "verified") + .collect(); assert_eq!( verified.len(), 1, @@ -313,7 +325,13 @@ fn test_npm_global_lifecycle() { // -- Setup: install minimist@1.2.2 globally into a temp prefix ---------- let out = Command::new("npm") - .args(["install", "-g", "--prefix", global_dir.path().to_str().unwrap(), "minimist@1.2.2"]) + .args([ + "install", + "-g", + "--prefix", + global_dir.path().to_str().unwrap(), + "minimist@1.2.2", + ]) .output() .expect("failed to run npm install -g"); assert!( @@ -359,7 +377,10 @@ fn test_npm_global_lifecycle() { let scanned = scan["scannedPackages"] .as_u64() .expect("scannedPackages should be a number"); - assert!(scanned >= 1, "scan should find at least 1 package, got {scanned}"); + assert!( + scanned >= 1, + "scan should find at least 1 package, got {scanned}" + ); // A bare count is a loophole: scan could enumerate *some* package while // failing to discover minimist or match its patch, and `scanned >= 1` @@ -424,11 +445,7 @@ fn test_npm_global_lifecycle() { ); // -- APPLY: re-apply from manifest globally ------------------------------ - assert_run_ok( - cwd, - &["apply", "-g", "--global-prefix", nm_str], - "apply -g", - ); + assert_run_ok(cwd, &["apply", "-g", "--global-prefix", nm_str], "apply -g"); assert_eq!( git_sha256_file(&index_js), AFTER_HASH, @@ -485,7 +502,10 @@ fn test_npm_save_only() { // Manifest should exist with the patch. let manifest_path = cwd.join(".socket/manifest.json"); - assert!(manifest_path.exists(), "manifest should exist after get --save-only"); + assert!( + manifest_path.exists(), + "manifest should exist after get --save-only" + ); let manifest: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); @@ -605,12 +625,18 @@ fn test_npm_macos_global_auto_discovery() { scan["status"], "success", "scan -g envelope should report success, got: {scan:#?}" ); - let scanned = scan["scannedPackages"] - .as_u64() - .unwrap_or_else(|| panic!("scannedPackages should be a number, got: {}", scan["scannedPackages"])); - let with_patches = scan["packagesWithPatches"] - .as_u64() - .unwrap_or_else(|| panic!("packagesWithPatches should be a number, got: {}", scan["packagesWithPatches"])); + let scanned = scan["scannedPackages"].as_u64().unwrap_or_else(|| { + panic!( + "scannedPackages should be a number, got: {}", + scan["scannedPackages"] + ) + }); + let with_patches = scan["packagesWithPatches"].as_u64().unwrap_or_else(|| { + panic!( + "packagesWithPatches should be a number, got: {}", + scan["packagesWithPatches"] + ) + }); let packages = scan["packages"] .as_array() .expect("scan -g should emit a packages array"); @@ -658,10 +684,16 @@ fn test_npm_uuid_shortcut() { // The shortcut must behave like `get`: the manifest must actually record // our patch, not merely exist as an empty stub. let manifest_path = cwd.join(".socket/manifest.json"); - assert!(manifest_path.exists(), "manifest should exist after UUID shortcut"); + assert!( + manifest_path.exists(), + "manifest should exist after UUID shortcut" + ); let manifest: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); let patch = &manifest["patches"][NPM_PURL]; - assert!(patch.is_object(), "manifest should contain {NPM_PURL} after UUID shortcut"); + assert!( + patch.is_object(), + "manifest should contain {NPM_PURL} after UUID shortcut" + ); assert_eq!(patch["uuid"].as_str().unwrap(), NPM_UUID); } diff --git a/crates/socket-patch-cli/tests/e2e_pypi.rs b/crates/socket-patch-cli/tests/e2e_pypi.rs index 8c74843..04bc1e1 100644 --- a/crates/socket-patch-cli/tests/e2e_pypi.rs +++ b/crates/socket-patch-cli/tests/e2e_pypi.rs @@ -211,7 +211,10 @@ fn test_pypi_full_lifecycle() { assert_run_ok(cwd, &["get", PYPI_UUID], "get"); let manifest_path = cwd.join(".socket/manifest.json"); - assert!(manifest_path.exists(), ".socket/manifest.json should exist after get"); + assert!( + manifest_path.exists(), + ".socket/manifest.json should exist after get" + ); // Parse the manifest to get file hashes from the API. let (purl, files_value) = read_patch_files(&manifest_path); @@ -426,7 +429,11 @@ fn test_pypi_dry_run() { .keys() .map(|rel| { let p = site_packages.join(rel); - let h = if p.exists() { Some(git_sha256_file(&p)) } else { None }; + let h = if p.exists() { + Some(git_sha256_file(&p)) + } else { + None + }; (rel.clone(), h) }) .collect(); @@ -441,10 +448,7 @@ fn test_pypi_dry_run() { h, "{rel} must be unchanged after apply --dry-run" ), - None => assert!( - !p.exists(), - "{rel} must not be created by apply --dry-run" - ), + None => assert!(!p.exists(), "{rel} must not be created by apply --dry-run"), } } @@ -526,7 +530,10 @@ fn test_pypi_global_lifecycle() { let scanned = scan["scannedPackages"] .as_u64() .expect("scannedPackages should be a number"); - assert!(scanned >= 1, "scan should find at least 1 package, got {scanned}"); + assert!( + scanned >= 1, + "scan should find at least 1 package, got {scanned}" + ); // -- GET: download + apply patch globally -------------------------------- assert_run_ok( @@ -553,7 +560,11 @@ fn test_pypi_global_lifecycle() { for (rel_path, info) in files { let after_hash = info["afterHash"].as_str().expect("afterHash"); let full_path = global_dir.path().join(rel_path); - assert!(full_path.exists(), "patched file should exist: {}", full_path.display()); + assert!( + full_path.exists(), + "patched file should exist: {}", + full_path.display() + ); assert_eq!( git_sha256_file(&full_path), after_hash, @@ -586,11 +597,7 @@ fn test_pypi_global_lifecycle() { } // -- APPLY: re-apply from manifest globally ------------------------------ - assert_run_ok( - cwd, - &["apply", "-g", "--global-prefix", gp_str], - "apply -g", - ); + assert_run_ok(cwd, &["apply", "-g", "--global-prefix", gp_str], "apply -g"); for (rel_path, info) in files { let after_hash = info["afterHash"].as_str().expect("afterHash"); @@ -666,7 +673,10 @@ fn test_pypi_save_only() { // Manifest should exist with the patch. let manifest_path = cwd.join(".socket/manifest.json"); - assert!(manifest_path.exists(), "manifest should exist after get --save-only"); + assert!( + manifest_path.exists(), + "manifest should exist after get --save-only" + ); let (purl, files_value) = read_patch_files(&manifest_path); assert!( @@ -764,9 +774,12 @@ fn test_pypi_macos_global_auto_discovery() { scan["status"] ); - let scanned = scan["scannedPackages"] - .as_u64() - .unwrap_or_else(|| panic!("scannedPackages should be a number, got: {}", scan["scannedPackages"])); + let scanned = scan["scannedPackages"].as_u64().unwrap_or_else(|| { + panic!( + "scannedPackages should be a number, got: {}", + scan["scannedPackages"] + ) + }); // The whole point of this test is that auto-discovery (no --global-prefix) // actually probes the real macOS framework/global site-packages. A working @@ -837,7 +850,10 @@ fn test_pypi_uuid_shortcut() { assert_run_ok(cwd, &[PYPI_UUID], "uuid shortcut"); let manifest_path = cwd.join(".socket/manifest.json"); - assert!(manifest_path.exists(), "manifest should exist after UUID shortcut"); + assert!( + manifest_path.exists(), + "manifest should exist after UUID shortcut" + ); let (purl, files_value) = read_patch_files(&manifest_path); assert!( diff --git a/crates/socket-patch-cli/tests/e2e_safety_advisories.rs b/crates/socket-patch-cli/tests/e2e_safety_advisories.rs index e5456b8..027a946 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_advisories.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_advisories.rs @@ -32,8 +32,7 @@ use std::path::Path; mod common; use common::{ - git_sha256, parse_json_envelope, run_with_env, write_blob, write_minimal_manifest, - PatchEntry, + git_sha256, parse_json_envelope, run_with_env, write_blob, write_minimal_manifest, PatchEntry, }; // Only the cargo sidecar test needs the bare (un-framed) digest used in // `.cargo-checksum.json`; gate the import so a `--no-default-features` @@ -71,9 +70,7 @@ fn apply_and_parse( extra_env, ); if stdout.trim().is_empty() { - panic!( - "socket-patch apply emitted no JSON.\nstderr:\n{stderr}" - ); + panic!("socket-patch apply emitted no JSON.\nstderr:\n{stderr}"); } let env = parse_json_envelope(&stdout); @@ -160,10 +157,7 @@ fn assert_sidecar_joins_applied_event(env: &serde_json::Value, record: &serde_js /// ecosystem tag, or panic with the full envelope on miss. Tests use /// this to drill into the per-ecosystem record without re-implementing /// the lookup five times. -fn find_sidecar_record<'a>( - env: &'a serde_json::Value, - ecosystem: &str, -) -> &'a serde_json::Value { +fn find_sidecar_record<'a>(env: &'a serde_json::Value, ecosystem: &str) -> &'a serde_json::Value { let sidecars = env["sidecars"] .as_array() .unwrap_or_else(|| panic!("envelope.sidecars must be an array.\nenv: {env}")); @@ -354,7 +348,10 @@ fn golang_apply_emits_go_mod_verify_fails_advisory() { // GOMODCACHE layout: @/. For // `github.com/gin-gonic/gin` there are no uppercase letters, // so the encoded form equals the path verbatim. - let module_dir = cache.join("github.com").join("gin-gonic").join("gin@v1.9.1"); + let module_dir = cache + .join("github.com") + .join("gin-gonic") + .join("gin@v1.9.1"); std::fs::create_dir_all(&module_dir).unwrap(); let target = module_dir.join("gin.go"); @@ -378,20 +375,13 @@ fn golang_apply_emits_go_mod_verify_fails_advisory() { ); write_blob(&socket_dir, &after, patched); - let env = apply_and_parse( - cwd, - &cache, - &[("GOMODCACHE", cache.to_str().unwrap())], - ); + let env = apply_and_parse(cwd, &cache, &[("GOMODCACHE", cache.to_str().unwrap())]); assert_eq!(std::fs::read(&target).unwrap(), patched); let record = find_sidecar_record(&env, "golang"); assert_sidecar_joins_applied_event(&env, record); - assert_eq!( - record["purl"], - "pkg:golang/github.com/gin-gonic/gin@v1.9.1" - ); + assert_eq!(record["purl"], "pkg:golang/github.com/gin-gonic/gin@v1.9.1"); let files = record["files"].as_array().expect("files array"); assert!( files.is_empty(), @@ -777,9 +767,7 @@ fn nuget_apply_signed_package_emits_files_and_advisory() { // AND the signed-package advisory rides alongside. let advisory = record.get("advisory").unwrap_or_else(|| { - panic!( - "signed package must emit an advisory alongside files[].\nrecord: {record}" - ) + panic!("signed package must emit an advisory alongside files[].\nrecord: {record}") }); assert_eq!( advisory["code"], "nuget_signed_package_tampered", diff --git a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs index 56b1d1c..2d37498 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs @@ -54,7 +54,8 @@ use common::{ const ORIGINAL_LIB_RS: &str = "pub fn hello() -> &'static str { \"world\" }\n"; const PATCHED_LIB_RS: &str = "pub fn hello() -> &'static str { \"PATCHED\" }\n"; -const FIXTURE_TOML: &str = "[package]\nname = \"safety-fixture\"\nversion = \"1.0.0\"\nedition = \"2021\"\n"; +const FIXTURE_TOML: &str = + "[package]\nname = \"safety-fixture\"\nversion = \"1.0.0\"\nedition = \"2021\"\n"; /// PURL the synthetic manifest points at. The cargo crawler resolves /// `pkg:cargo/@` against the consumer's `vendor/` @@ -311,10 +312,8 @@ fn apply_then_cargo_check_succeeds() { // the apply both rewrote the per-file hash AND preserved the // `package` field. let pre_checksum: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string( - consumer.join("vendor/safety-fixture/.cargo-checksum.json"), - ) - .unwrap(), + &std::fs::read_to_string(consumer.join("vendor/safety-fixture/.cargo-checksum.json")) + .unwrap(), ) .unwrap(); @@ -335,10 +334,8 @@ fn apply_then_cargo_check_succeeds() { // entry must now be the raw SHA256 of the patched bytes; the // `package` field must be unchanged. let post_checksum: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string( - consumer.join("vendor/safety-fixture/.cargo-checksum.json"), - ) - .unwrap(), + &std::fs::read_to_string(consumer.join("vendor/safety-fixture/.cargo-checksum.json")) + .unwrap(), ) .unwrap(); let expected_lib_hash = sha256_hex(PATCHED_LIB_RS.as_bytes()); @@ -402,17 +399,17 @@ fn apply_reports_cargo_checksum_in_sidecars_updated() { ); let env = parse_json_envelope(&stdout); - let sidecars = env["sidecars"] - .as_array() - .unwrap_or_else(|| panic!( - "envelope must carry `sidecars` array.\nstdout:\n{stdout}\nstderr:\n{stderr}" - )); + let sidecars = env["sidecars"].as_array().unwrap_or_else(|| { + panic!("envelope must carry `sidecars` array.\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); let cargo_record = sidecars .iter() .find(|s| s["ecosystem"] == "cargo") - .unwrap_or_else(|| panic!( - "envelope.sidecars must contain a record with ecosystem=cargo.\nstdout:\n{stdout}" - )); + .unwrap_or_else(|| { + panic!( + "envelope.sidecars must contain a record with ecosystem=cargo.\nstdout:\n{stdout}" + ) + }); let files = cargo_record["files"].as_array().expect("files array"); assert!( files.iter().any(|f| { @@ -422,8 +419,7 @@ fn apply_reports_cargo_checksum_in_sidecars_updated() { ); // No advisory expected for the cargo success path. assert!( - cargo_record.get("advisory").is_none() - || cargo_record["advisory"].is_null(), + cargo_record.get("advisory").is_none() || cargo_record["advisory"].is_null(), "cargo success path should not carry an advisory; got {cargo_record}" ); // PURL is denormalized into the record for jq filtering. @@ -491,21 +487,17 @@ fn apply_with_malformed_checksum_reports_sidecar_fixup_failed() { env["status"], "success", "sidecar fixup failure must not flip the top-level status; got {env}" ); - let sidecars = env["sidecars"] - .as_array() - .unwrap_or_else(|| panic!( - "envelope must carry `sidecars` array.\nstdout:\n{stdout}\nstderr:\n{stderr}" - )); + let sidecars = env["sidecars"].as_array().unwrap_or_else(|| { + panic!("envelope must carry `sidecars` array.\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); let cargo_record = sidecars .iter() .find(|s| s["ecosystem"] == "cargo") - .unwrap_or_else(|| panic!( - "envelope.sidecars must contain a cargo record.\nstdout:\n{stdout}" - )); + .unwrap_or_else(|| { + panic!("envelope.sidecars must contain a cargo record.\nstdout:\n{stdout}") + }); let advisory = cargo_record.get("advisory").unwrap_or_else(|| { - panic!( - "malformed checksum should produce an advisory.\nrecord: {cargo_record}" - ) + panic!("malformed checksum should produce an advisory.\nrecord: {cargo_record}") }); assert_eq!( advisory["code"], "sidecar_fixup_failed", @@ -555,7 +547,11 @@ fn apply_with_missing_files_field_reports_sidecar_fixup_failed() { // arm in cargo::fixup that returns Malformed with a different // detail string than the serde parse path. let checksum = consumer.join("vendor/safety-fixture/.cargo-checksum.json"); - std::fs::write(&checksum, br#"{"package":"0000000000000000000000000000000000000000000000000000000000000000"}"#).unwrap(); + std::fs::write( + &checksum, + br#"{"package":"0000000000000000000000000000000000000000000000000000000000000000"}"#, + ) + .unwrap(); let (code, stdout, stderr) = run( &consumer, @@ -661,7 +657,10 @@ fn apply_with_readonly_checksum_still_rewrites_it() { let mode = std::fs::metadata(&checksum).unwrap().permissions().mode() & 0o7777; // Re-grant write so tempdir cleanup can unlink. let _ = std::fs::set_permissions(&checksum, std::fs::Permissions::from_mode(0o644)); - assert_eq!(mode, 0o444, "checksum file must stay read-only after rewrite"); + assert_eq!( + mode, 0o444, + "checksum file must stay read-only after rewrite" + ); // The sidecar reports a successful rewrite — not a failure advisory. let env = parse_json_envelope(&stdout); @@ -770,8 +769,7 @@ fn apply_without_cargo_checksum_emits_no_sidecar_record() { // Remove the checksum entirely so the fixup hits the // `NotFound -> Ok(None)` early return. - std::fs::remove_file(consumer.join("vendor/safety-fixture/.cargo-checksum.json")) - .unwrap(); + std::fs::remove_file(consumer.join("vendor/safety-fixture/.cargo-checksum.json")).unwrap(); let (code, stdout, stderr) = run( &consumer, @@ -868,10 +866,8 @@ fn apply_normalizes_package_prefix_in_cargo_checksum() { // of the PATCHED bytes (cargo's directory source verifies exactly // this). Compare against that, not just "a string exists". let checksum: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string( - consumer.join("vendor/safety-fixture/.cargo-checksum.json"), - ) - .unwrap(), + &std::fs::read_to_string(consumer.join("vendor/safety-fixture/.cargo-checksum.json")) + .unwrap(), ) .unwrap(); let expected_patched_hash = sha256_hex(PATCHED_LIB_RS.as_bytes()); @@ -890,9 +886,7 @@ fn apply_normalizes_package_prefix_in_cargo_checksum() { the raw SHA256 of the patched bytes; got {checksum}" ); assert!( - checksum["files"] - .get("package/src/lib.rs") - .is_none(), + checksum["files"].get("package/src/lib.rs").is_none(), "rewriter must NOT create a `package/`-prefixed key" ); // The unpatched Cargo.toml entry must survive untouched — proves @@ -910,8 +904,9 @@ fn apply_normalizes_package_prefix_in_cargo_checksum() { let cargo = sidecars.iter().find(|s| s["ecosystem"] == "cargo").unwrap(); let files = cargo["files"].as_array().unwrap(); assert!( - files.iter().any(|f| f["path"] == ".cargo-checksum.json" - && f["action"] == "rewritten"), + files + .iter() + .any(|f| f["path"] == ".cargo-checksum.json" && f["action"] == "rewritten"), "sidecar record must still report .cargo-checksum.json:rewritten; got {cargo}" ); } @@ -962,11 +957,7 @@ traitobject = { version = "0.0.1", features = ["allow-unmaintained"] } "#, ) .unwrap(); - std::fs::write( - consumer.join("src/main.rs"), - "fn main() {}\n", - ) - .unwrap(); + std::fs::write(consumer.join("src/main.rs"), "fn main() {}\n").unwrap(); // 1. Fetch traitobject@0.0.1 from crates.io (real network). // Hermetic CARGO_HOME means we never touch the user's cache. diff --git a/crates/socket-patch-cli/tests/e2e_safety_cow.rs b/crates/socket-patch-cli/tests/e2e_safety_cow.rs index 58ae96c..3d7cc99 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_cow.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_cow.rs @@ -314,10 +314,7 @@ fn apply_replaces_symlink_with_private_file() { "index.js must be a regular file after apply, not a symlink" ); // Patched content on the package side. - assert_eq!( - git_sha256_file(&fx.index_js()), - git_sha256(PATCHED_BYTES) - ); + assert_eq!(git_sha256_file(&fx.index_js()), git_sha256(PATCHED_BYTES)); // Original outside target untouched. assert_eq!( git_sha256_file(&outside), @@ -391,7 +388,10 @@ fn apply_breaks_hardlinks_on_multi_file_patch() { ); // Both inside files patched. - assert_eq!(std::fs::read(pkg.join("index.js")).unwrap(), b"AAA patched!\n"); + assert_eq!( + std::fs::read(pkg.join("index.js")).unwrap(), + b"AAA patched!\n" + ); assert_eq!( std::fs::read(pkg.join("lib/helper.js")).unwrap(), b"BBB patched!\n" diff --git a/crates/socket-patch-cli/tests/e2e_safety_internals.rs b/crates/socket-patch-cli/tests/e2e_safety_internals.rs index 39ac703..8e4aa45 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_internals.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_internals.rs @@ -44,14 +44,9 @@ use socket_patch_core::patch::sidecars::dispatch_fixup; #[tokio::test] async fn dispatch_fixup_empty_patched_returns_none() { let tmp = tempfile::tempdir().unwrap(); - let out = dispatch_fixup( - "pkg:pypi/requests@2.28.0", - tmp.path(), - &[], - &HashMap::new(), - ) - .await - .unwrap(); + let out = dispatch_fixup("pkg:pypi/requests@2.28.0", tmp.path(), &[], &HashMap::new()) + .await + .unwrap(); assert!( out.is_none(), "empty patched must short-circuit to None *before* the pypi advisory arm; \ @@ -72,7 +67,10 @@ async fn dispatch_fixup_unknown_ecosystem_returns_none() { ) .await .unwrap(); - assert!(out.is_none(), "unknown ecosystem must short-circuit to None"); + assert!( + out.is_none(), + "unknown ecosystem must short-circuit to None" + ); } /// `dispatch_fixup` cargo path with a `patched` entry that points @@ -176,10 +174,9 @@ async fn dispatch_fixup_nuget_with_nonexistent_pkg_path() { #[tokio::test] async fn cow_missing_path_yields_no_file() { let tmp = tempfile::tempdir().unwrap(); - let action = - break_hardlink_if_needed(&tmp.path().join("does-not-exist.txt")) - .await - .expect("lstat NotFound is the explicit early-return arm"); + let action = break_hardlink_if_needed(&tmp.path().join("does-not-exist.txt")) + .await + .expect("lstat NotFound is the explicit early-return arm"); assert!(matches!(action, CowAction::NoFile)); } @@ -262,8 +259,8 @@ async fn cow_symlink_to_missing_target_propagates_read_error() { assert_eq!(err.kind(), std::io::ErrorKind::NotFound); // The dangling link itself must still exist — read-fail-fast must // never enter the remove/rewrite dance that could destroy it. - let meta = std::fs::symlink_metadata(&link) - .expect("dangling symlink must survive a read-fail-fast"); + let meta = + std::fs::symlink_metadata(&link).expect("dangling symlink must survive a read-fail-fast"); assert!( meta.file_type().is_symlink(), "read-through failure must leave the symlink untouched, got {meta:?}" @@ -317,7 +314,11 @@ async fn cow_symlink_unremovable_propagates_remove_error() { let result = break_hardlink_if_needed(&link).await; // Clear so tempdir cleanup can recurse. - let _ = Command::new("chflags").arg("-h").arg("nouchg").arg(&link).status(); + let _ = Command::new("chflags") + .arg("-h") + .arg("nouchg") + .arg(&link) + .status(); let err = result.expect_err("rename over immutable symlink must propagate EPERM"); assert_eq!( @@ -347,7 +348,10 @@ async fn cow_symlink_unremovable_propagates_remove_error() { .filter_map(|e| e.ok()) .filter(|e| e.file_name().to_string_lossy().starts_with(".socket-cow-")) .collect(); - assert!(leftover.is_empty(), "stage litter left behind: {leftover:?}"); + assert!( + leftover.is_empty(), + "stage litter left behind: {leftover:?}" + ); } /// Hardlink branch read-fails arm (cow.rs:84): a hardlinked file @@ -489,7 +493,10 @@ async fn cow_stage_write_failure_propagates() { .filter_map(|e| e.ok()) .filter(|e| e.file_name().to_string_lossy().starts_with(".socket-cow-")) .collect(); - assert!(leftover.is_empty(), "stage litter left behind: {leftover:?}"); + assert!( + leftover.is_empty(), + "stage litter left behind: {leftover:?}" + ); } /// Symlink-branch `write_via_stage_rename` stage-create failure arm: @@ -694,11 +701,7 @@ async fn cow_rename_failure_runs_stage_cleanup() { let leftover_stages: Vec<_> = std::fs::read_dir(tmp.path()) .unwrap() .filter_map(|e| e.ok()) - .filter(|e| { - e.file_name() - .to_string_lossy() - .starts_with(".socket-cow-") - }) + .filter(|e| e.file_name().to_string_lossy().starts_with(".socket-cow-")) .collect(); assert!( leftover_stages.is_empty(), diff --git a/crates/socket-patch-cli/tests/e2e_safety_lock.rs b/crates/socket-patch-cli/tests/e2e_safety_lock.rs index d9db449..61539ca 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_lock.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_lock.rs @@ -153,7 +153,10 @@ fn lock_held_human_mode_mentions_other_process() { let _external = take_external_lock(&socket_dir); let (code, stdout, stderr) = run(dir.path(), &["apply"]); - assert_eq!(code, 1, "human-mode contention must exit 1.\nstderr:\n{stderr}"); + assert_eq!( + code, 1, + "human-mode contention must exit 1.\nstderr:\n{stderr}" + ); // Human mode must NOT leak a JSON envelope to stdout — the error // is a human line on stderr. A regression that printed JSON here // (or emitted nothing) would otherwise slip past a loose diff --git a/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs b/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs index 1b19014..71da9be 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs @@ -157,8 +157,7 @@ where #[cfg(unix)] fn file_identity(path: &Path) -> (u64, u64) { use std::os::unix::fs::MetadataExt; - let md = std::fs::metadata(path) - .unwrap_or_else(|e| panic!("stat {}: {e}", path.display())); + let md = std::fs::metadata(path).unwrap_or_else(|e| panic!("stat {}: {e}", path.display())); (md.dev(), md.ino()) } @@ -460,8 +459,7 @@ fn apply_in_pnpm_project_emits_layout_note() { let root = tempfile::tempdir().unwrap(); let fx = setup_two_pnpm_projects(root.path()); - let (_stdout, stderr) = - assert_run_ok(&fx.proj_a, &["get", NPM_UUID], "socket-patch get"); + let (_stdout, stderr) = assert_run_ok(&fx.proj_a, &["get", NPM_UUID], "socket-patch get"); // The exact phrasing is a stable contract. A bare `contains("pnpm")` // is worthless here — every pnpm store path printed on stderr diff --git a/crates/socket-patch-cli/tests/e2e_safety_unlock.rs b/crates/socket-patch-cli/tests/e2e_safety_unlock.rs index 91783dd..d8e751e 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_unlock.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_unlock.rs @@ -65,9 +65,7 @@ fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { for var in SOCKET_ENV_VARS { cmd.env_remove(var); } - let out = cmd - .output() - .expect("failed to execute socket-patch binary"); + let out = cmd.output().expect("failed to execute socket-patch binary"); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stderr = String::from_utf8_lossy(&out.stderr).to_string(); @@ -191,7 +189,10 @@ fn unlock_reports_held_when_lock_actively_held() { "control precondition: the lock file must persist across the release" ); let (code2, stdout2, stderr2) = run(dir.path(), &["unlock", "--json"]); - assert_eq!(code2, 0, "free after release: stdout={stdout2}\nstderr={stderr2}"); + assert_eq!( + code2, 0, + "free after release: stdout={stdout2}\nstderr={stderr2}" + ); let env2 = parse_json_envelope(&stdout2); assert_eq!( json_string(&env2, "status"), @@ -374,7 +375,10 @@ fn unlock_human_mode_release_reports_removed_when_leftover() { !lower.contains("no lock file to remove"), "a real removal must not emit the no-op wording, got:\n{stdout}" ); - assert!(!lock_file.exists(), "--release should have deleted the file"); + assert!( + !lock_file.exists(), + "--release should have deleted the file" + ); } /// Human-mode `--release` against a clean `.socket/` (no pre-existing diff --git a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs index f8092e9..44c7a30 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs @@ -84,10 +84,8 @@ fn make_yarn_berry_project(cwd: &Path) { r#"{"name":"yarn-berry-fixture","version":"0.0.0","private":true}"#, ) .expect("write package.json"); - std::fs::write(cwd.join(".pnp.cjs"), b"// stub PnP loader\n") - .expect("write .pnp.cjs"); - std::fs::create_dir_all(cwd.join(".yarn").join("cache")) - .expect("create .yarn/cache"); + std::fs::write(cwd.join(".pnp.cjs"), b"// stub PnP loader\n").expect("write .pnp.cjs"); + std::fs::create_dir_all(cwd.join(".yarn").join("cache")).expect("create .yarn/cache"); } /// Manifest-only helper for the `list`-discovery guard test. The @@ -328,7 +326,9 @@ fn npm_layout_does_not_trigger_yarn_pnp_refusal() { "npm layout apply should report success.\nenvelope: {env}" ); assert_eq!( - env.get("summary").and_then(|s| s.get("applied")).and_then(|v| v.as_u64()), + env.get("summary") + .and_then(|s| s.get("applied")) + .and_then(|v| v.as_u64()), Some(1), "npm layout apply should patch exactly the one staged file.\nenvelope: {env}" ); diff --git a/crates/socket-patch-cli/tests/e2e_scan.rs b/crates/socket-patch-cli/tests/e2e_scan.rs index e39c4eb..14b358e 100644 --- a/crates/socket-patch-cli/tests/e2e_scan.rs +++ b/crates/socket-patch-cli/tests/e2e_scan.rs @@ -48,8 +48,7 @@ const BEFORE_HASH: &str = "311f1e893e6eac502693fad8617dcf5353a043ccc0f7b4ba9fe38 /// 64-hex-char placeholder used for orphan-blob fixtures. Not a real /// blob hash — picked so it can't accidentally collide with anything /// the API would return. -const FAKE_ORPHAN_HASH: &str = - "0000000000000000000000000000000000000000000000000000000000000000"; +const FAKE_ORPHAN_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; /// Fake UUID we plant in the manifest to force `scan --apply` into the /// `"updated"` branch. @@ -153,8 +152,8 @@ fn parse_scan_json(stdout: &str) -> serde_json::Value { /// message if it doesn't exist or is malformed. fn read_manifest_file(cwd: &Path) -> serde_json::Value { let path = cwd.join(".socket/manifest.json"); - let content = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let content = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); serde_json::from_str(&content) .unwrap_or_else(|e| panic!("manifest is not valid JSON: {e}\n{content}")) } @@ -232,7 +231,9 @@ fn test_scan_apply_json_adds_new_patch() { "API must have returned at least one free patch; got {}", v["freePatches"] ); - let patches = v["apply"]["patches"].as_array().expect("apply.patches array"); + let patches = v["apply"]["patches"] + .as_array() + .expect("apply.patches array"); let minimist = patches .iter() .find(|p| p["purl"] == NPM_PURL) @@ -283,14 +284,12 @@ fn test_scan_apply_json_skips_existing() { "first run should have patched the file", ); - let (stdout, _) = assert_run_ok( - cwd, - &["scan", "--json", "--apply", "--yes"], - "second run", - ); + let (stdout, _) = assert_run_ok(cwd, &["scan", "--json", "--apply", "--yes"], "second run"); let v = parse_scan_json(&stdout); - let patches = v["apply"]["patches"].as_array().expect("apply.patches array"); + let patches = v["apply"]["patches"] + .as_array() + .expect("apply.patches array"); let minimist = patches .iter() .find(|p| p["purl"] == NPM_PURL) @@ -325,7 +324,9 @@ fn test_scan_apply_json_updates_existing() { ); let v = parse_scan_json(&stdout); - let patches = v["apply"]["patches"].as_array().expect("apply.patches array"); + let patches = v["apply"]["patches"] + .as_array() + .expect("apply.patches array"); let minimist = patches .iter() .find(|p| p["purl"] == NPM_PURL) @@ -450,7 +451,11 @@ fn test_scan_apply_prune_prunes_uninstalled_package() { npm_run(cwd, &["install", "minimist@1.2.2"]); // First run — patch is added (no --prune needed for the apply step). - assert_run_ok(cwd, &["scan", "--json", "--apply", "--yes"], "initial apply"); + assert_run_ok( + cwd, + &["scan", "--json", "--apply", "--yes"], + "initial apply", + ); assert!(cwd.join(".socket/manifest.json").exists()); npm_run(cwd, &["uninstall", "minimist"]); @@ -492,7 +497,11 @@ fn test_scan_apply_default_keeps_uninstalled_entries() { write_package_json(cwd); npm_run(cwd, &["install", "minimist@1.2.2"]); - assert_run_ok(cwd, &["scan", "--json", "--apply", "--yes"], "initial apply"); + assert_run_ok( + cwd, + &["scan", "--json", "--apply", "--yes"], + "initial apply", + ); npm_run(cwd, &["uninstall", "minimist"]); npm_run(cwd, &["install", "left-pad@1.3.0"]); @@ -543,7 +552,11 @@ fn test_scan_apply_prune_cleans_orphan_blobs() { let cwd = dir.path(); write_package_json(cwd); npm_run(cwd, &["install", "minimist@1.2.2"]); - assert_run_ok(cwd, &["scan", "--json", "--apply", "--yes"], "initial apply"); + assert_run_ok( + cwd, + &["scan", "--json", "--apply", "--yes"], + "initial apply", + ); let index_js = cwd.join("node_modules/minimist/index.js"); let patched_hash = git_sha256_file(&index_js); @@ -622,7 +635,11 @@ fn test_scan_dry_run_sync_previews_apply_and_gc() { npm_run(cwd, &["install", "minimist@1.2.2"]); // Set up: apply once to create a manifest, then uninstall + plant // an orphan so there's prune + cleanup work to preview. - assert_run_ok(cwd, &["scan", "--json", "--apply", "--yes"], "initial apply"); + assert_run_ok( + cwd, + &["scan", "--json", "--apply", "--yes"], + "initial apply", + ); npm_run(cwd, &["uninstall", "minimist"]); npm_run(cwd, &["install", "left-pad@1.3.0"]); @@ -682,7 +699,11 @@ fn test_scan_json_no_gc_field_without_prune() { let cwd = dir.path(); write_package_json(cwd); npm_run(cwd, &["install", "minimist@1.2.2"]); - assert_run_ok(cwd, &["scan", "--json", "--apply", "--yes"], "initial apply"); + assert_run_ok( + cwd, + &["scan", "--json", "--apply", "--yes"], + "initial apply", + ); npm_run(cwd, &["uninstall", "minimist"]); npm_run(cwd, &["install", "left-pad@1.3.0"]); @@ -735,7 +756,9 @@ fn test_scan_sync_yes_full_lifecycle() { .as_array() .expect("first sync should populate apply.patches"); assert!( - patches.iter().any(|p| p["purl"] == NPM_PURL && p["action"] == "added"), + patches + .iter() + .any(|p| p["purl"] == NPM_PURL && p["action"] == "added"), "first sync should add the minimist patch" ); assert_eq!(v1["status"], "success"); @@ -743,7 +766,9 @@ fn test_scan_sync_yes_full_lifecycle() { // result, not the `{"skipped": true}` short-circuit (which `is_object()` // would also accept), and on this first run there is nothing installed-then- // uninstalled, so it must prune nothing. - let gc1 = v1["gc"].as_object().expect("gc must be emitted under --sync"); + let gc1 = v1["gc"] + .as_object() + .expect("gc must be emitted under --sync"); assert!( gc1.get("skipped") != Some(&serde_json::Value::Bool(true)), "GC must not be skipped on a --sync run that scanned packages; got {:?}", diff --git a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs index 3210a51..645c0e4 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs @@ -162,11 +162,7 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { // `--save-text-lockfile` guarantees the text bun.lock vendor wires // (bun 1.3.x already defaults to it; the flag future-proofs the test). let cache = tmp.path().join("bun-cache"); - let install = bun( - &proj, - &["install", "--save-text-lockfile"], - &cache, - ); + let install = bun(&proj, &["install", "--save-text-lockfile"], &cache); if !install.status.success() { println!( "SKIP e2e_vendor_bun_build: fixture `bun install` failed (registry \ @@ -212,9 +208,18 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { // 3. Vendor (offline). let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); @@ -225,13 +230,21 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { .iter() .find(|e| e["action"] == "applied" && e["purl"] == purl.as_str()) .unwrap_or_else(|| panic!("expected an applied event for {purl}: {env}")); - assert!(applied.get("errorCode").is_none(), "clean apply event: {applied}"); + assert!( + applied.get("errorCode").is_none(), + "clean apply event: {applied}" + ); let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); - assert!(proj.join(&tgz_rel).is_file(), "vendored tarball missing at {tgz_rel}"); assert!( - proj.join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) - .is_file(), + proj.join(&tgz_rel).is_file(), + "vendored tarball missing at {tgz_rel}" + ); + assert!( + proj.join(format!( + ".socket/vendor/npm/{UUID}/socket-patch.vendor.json" + )) + .is_file(), "informational vendor marker missing" ); assert!( @@ -306,9 +319,18 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { let lock_wired = std::fs::read(&lock_path).unwrap(); let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let env2 = parse_envelope(&stdout); assert_eq!(env2["summary"]["failed"], 0, "re-run must not fail: {env2}"); assert_eq!( @@ -320,9 +342,19 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { // 6. REVERT PROOF: bun.lock restored byte-for-byte, artifacts gone. let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--revert", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--revert", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let renv = parse_envelope(&stdout); assert_eq!(renv["status"], "success", "revert envelope: {renv}"); assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs index 6616be2..12e1b1a 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs @@ -192,7 +192,11 @@ fn stage_fixture(tmp: &Path) -> Option<(PathBuf, PathBuf, String, PathBuf)> { ), ) .unwrap(); - std::fs::write(proj.join("src/main.rs"), "fn main() { println!(\"baseline\"); }\n").unwrap(); + std::fs::write( + proj.join("src/main.rs"), + "fn main() { println!(\"baseline\"); }\n", + ) + .unwrap(); let build = cargo(&proj, &["build", "-q"], &cargo_home); if !build.status.success() { @@ -207,9 +211,11 @@ fn stage_fixture(tmp: &Path) -> Option<(PathBuf, PathBuf, String, PathBuf)> { let lock_text = std::fs::read_to_string(proj.join("Cargo.lock")).unwrap(); let version = locked_version(&lock_text, DEP) .unwrap_or_else(|| panic!("Cargo.lock must lock {DEP}:\n{lock_text}")); - let crate_dir = find_registry_crate(&cargo_home, &format!("{DEP}-{version}")) - .unwrap_or_else(|| { - panic!("{DEP}-{version} must be extracted under /registry/src after the build") + let crate_dir = + find_registry_crate(&cargo_home, &format!("{DEP}-{version}")).unwrap_or_else(|| { + panic!( + "{DEP}-{version} must be extracted under /registry/src after the build" + ) }); Some((proj, cargo_home, version, crate_dir)) } @@ -240,10 +246,19 @@ fn cargo_vendor_fresh_checkout_locked_offline_build_and_revert() { // Vendor (offline; blob staged locally). let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], &cargo_home, ); - assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); @@ -271,8 +286,10 @@ fn cargo_vendor_fresh_checkout_locked_offline_build_and_revert() { "registry source must stay pristine" ); assert!( - proj.join(format!(".socket/vendor/cargo/{UUID}/socket-patch.vendor.json")) - .is_file(), + proj.join(format!( + ".socket/vendor/cargo/{UUID}/socket-patch.vendor.json" + )) + .is_file(), "informational vendor marker missing" ); @@ -332,7 +349,11 @@ fn cargo_vendor_fresh_checkout_locked_offline_build_and_revert() { let fresh_home = tmp.path().join("fresh-cargo-home"); std::fs::create_dir_all(&fresh_home).unwrap(); - let build = cargo(&fresh, &["build", "-q", "--locked", "--offline"], &fresh_home); + let build = cargo( + &fresh, + &["build", "-q", "--locked", "--offline"], + &fresh_home, + ); assert!( build.status.success(), "fresh-checkout `cargo build --locked --offline` (empty CARGO_HOME) must succeed.\nstdout:\n{}\nstderr:\n{}", @@ -359,10 +380,19 @@ fn cargo_vendor_fresh_checkout_locked_offline_build_and_revert() { let lock_wired = std::fs::read(&lock_path).unwrap(); let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], &cargo_home, ); - assert_eq!(code, 0, "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); assert_eq!( std::fs::read(&lock_path).unwrap(), lock_wired, @@ -372,10 +402,19 @@ fn cargo_vendor_fresh_checkout_locked_offline_build_and_revert() { // REVERT PROOF. let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--revert", "--json", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--revert", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], &cargo_home, ); - assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); let renv = parse_envelope(&stdout); assert_eq!(renv["status"], "success", "revert envelope: {renv}"); assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); @@ -427,10 +466,19 @@ fn cargo_vendor_reports_applied_event() { let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], &cargo_home, ); - assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); let env = parse_envelope(&stdout); assert_eq!( env["summary"]["applied"], 1, diff --git a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs index 5e24e71..8a7dfef 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs @@ -97,11 +97,24 @@ fn stage(tmp: &Path) -> (PathBuf, PathBuf, String) { let pxv = tmp.join("proxy").join(UMOD).join("@v"); std::fs::create_dir_all(&pxv).unwrap(); - std::fs::write(pxv.join(format!("{UVER}.info")), format!("{{\"Version\":\"{UVER}\"}}")).unwrap(); - std::fs::write(pxv.join(format!("{UVER}.mod")), format!("module {UMOD}\n\ngo 1.21\n")).unwrap(); + std::fs::write( + pxv.join(format!("{UVER}.info")), + format!("{{\"Version\":\"{UVER}\"}}"), + ) + .unwrap(); + std::fs::write( + pxv.join(format!("{UVER}.mod")), + format!("module {UMOD}\n\ngo 1.21\n"), + ) + .unwrap(); let zip_out = pxv.join(format!("{UVER}.zip")); let zip_status = Command::new("zip") - .args(["-q", "-r", zip_out.to_str().unwrap(), &format!("{UMOD}@{UVER}")]) + .args([ + "-q", + "-r", + zip_out.to_str().unwrap(), + &format!("{UMOD}@{UVER}"), + ]) .current_dir(tmp.join("stage")) .status() .expect("run zip"); @@ -127,7 +140,11 @@ fn stage(tmp: &Path) -> (PathBuf, PathBuf, String) { .unwrap(); let env = go_env(modcache.to_str().unwrap(), &proxy_url); - let dl = go(&consumer, &["mod", "download", &format!("{UMOD}@{UVER}")], &env); + let dl = go( + &consumer, + &["mod", "download", &format!("{UMOD}@{UVER}")], + &env, + ); assert!( dl.status.success(), "go mod download (file proxy) failed: {}", @@ -159,7 +176,9 @@ fn write_patch(consumer: &Path) { ) .unwrap(); std::fs::write( - socket.join("blobs").join(git_sha256(PATCHED_LIB.as_bytes())), + socket + .join("blobs") + .join(git_sha256(PATCHED_LIB.as_bytes())), PATCHED_LIB, ) .unwrap(); @@ -239,10 +258,19 @@ fn go_vendor_fresh_checkout_offline_build_and_revert() { write_patch(&consumer); let (code, stdout, stderr) = run_socket( &consumer, - &["vendor", "--json", "--offline", "--cwd", consumer.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + consumer.to_str().unwrap(), + ], &modcache, ); - assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); @@ -253,9 +281,8 @@ fn go_vendor_fresh_checkout_offline_build_and_revert() { // The replace directive points at the uuid copy, with the mandatory // `./` prefix (a bare path fails go.mod parsing — spike claim 6). - let expected_replace = format!( - "replace {UMOD} {UVER} => ./.socket/vendor/golang/{UUID}/{UMOD}@{UVER}" - ); + let expected_replace = + format!("replace {UMOD} {UVER} => ./.socket/vendor/golang/{UUID}/{UMOD}@{UVER}"); let gomod = std::fs::read_to_string(&gomod_path).unwrap(); assert!( gomod.lines().any(|l| l.trim() == expected_replace), @@ -271,7 +298,9 @@ fn go_vendor_fresh_checkout_offline_build_and_revert() { ); assert!( consumer - .join(format!(".socket/vendor/golang/{UUID}/socket-patch.vendor.json")) + .join(format!( + ".socket/vendor/golang/{UUID}/socket-patch.vendor.json" + )) .is_file(), "informational vendor marker missing" ); @@ -316,7 +345,9 @@ fn go_vendor_fresh_checkout_offline_build_and_revert() { "fresh-checkout `go build` (GOPROXY=off, empty GOMODCACHE) must succeed.\nstderr:\n{}", String::from_utf8_lossy(&build.stderr) ); - let app = Command::new(fresh.join("app")).output().expect("run fresh app"); + let app = Command::new(fresh.join("app")) + .output() + .expect("run fresh app"); assert!( String::from_utf8_lossy(&app.stdout).contains("OUT: PATCHED"), "fresh build must link the PATCHED module: {}", @@ -332,10 +363,19 @@ fn go_vendor_fresh_checkout_offline_build_and_revert() { // REVERT PROOF. let (code, stdout, stderr) = run_socket( &consumer, - &["vendor", "--revert", "--json", "--cwd", consumer.to_str().unwrap()], + &[ + "vendor", + "--revert", + "--json", + "--cwd", + consumer.to_str().unwrap(), + ], &modcache, ); - assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); let renv = parse_envelope(&stdout); assert_eq!(renv["status"], "success", "revert envelope: {renv}"); assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); @@ -381,7 +421,10 @@ fn go_apply_vendor_interplay_takeover_and_yield() { &["apply", "--offline", "--ecosystems", "golang", "--cwd", cs], &modcache, ); - assert_eq!(code, 0, "apply failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "apply failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); let go_patches_copy = consumer.join(format!(".socket/go-patches/{UMOD}@{UVER}")); assert_eq!( std::fs::read(go_patches_copy.join("lib.go")).unwrap(), @@ -400,7 +443,10 @@ fn go_apply_vendor_interplay_takeover_and_yield() { &["vendor", "--json", "--offline", "--cwd", cs], &modcache, ); - assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "takeover is a success: {env}"); assert!( @@ -409,9 +455,8 @@ fn go_apply_vendor_interplay_takeover_and_yield() { ); let gomod = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); - let expected_replace = format!( - "replace {UMOD} {UVER} => ./.socket/vendor/golang/{UUID}/{UMOD}@{UVER}" - ); + let expected_replace = + format!("replace {UMOD} {UVER} => ./.socket/vendor/golang/{UUID}/{UMOD}@{UVER}"); assert!( gomod.lines().any(|l| l.trim() == expected_replace), "takeover must repoint the replace at the vendor copy:\n{gomod}" @@ -426,10 +471,9 @@ fn go_apply_vendor_interplay_takeover_and_yield() { ); // The ledger records the takeover so revert can warn about the handoff. - let state: serde_json::Value = serde_json::from_slice( - &std::fs::read(consumer.join(".socket/vendor/state.json")).unwrap(), - ) - .unwrap(); + let state: serde_json::Value = + serde_json::from_slice(&std::fs::read(consumer.join(".socket/vendor/state.json")).unwrap()) + .unwrap(); assert_eq!( state["entries"][UPURL]["tookOverGoPatches"], true, "state.json must record tookOverGoPatches: {state}" @@ -447,31 +491,57 @@ fn go_apply_vendor_interplay_takeover_and_yield() { // never repointing the replace back at go-patches. let (code, stdout, stderr) = run_socket( &consumer, - &["apply", "--json", "--offline", "--ecosystems", "golang", "--cwd", cs], + &[ + "apply", + "--json", + "--offline", + "--ecosystems", + "golang", + "--cwd", + cs, + ], &modcache, ); - assert_eq!(code, 0, "apply on a vendored module must exit 0.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "apply on a vendored module must exit 0.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); let aenv = parse_envelope(&stdout); assert_eq!(aenv["status"], "success", "apply envelope: {aenv}"); - let yielded = find_event(&aenv, "skipped", "vendored") - .unwrap_or_else(|| panic!("apply must skip the vendored purl with errorCode `vendored`: {aenv}")); - assert_eq!(yielded["purl"], UPURL, "the vendored purl is the one skipped: {aenv}"); + let yielded = find_event(&aenv, "skipped", "vendored").unwrap_or_else(|| { + panic!("apply must skip the vendored purl with errorCode `vendored`: {aenv}") + }); + assert_eq!( + yielded["purl"], UPURL, + "the vendored purl is the one skipped: {aenv}" + ); let gomod_after_apply = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); assert!( - gomod_after_apply.lines().any(|l| l.trim() == expected_replace), + gomod_after_apply + .lines() + .any(|l| l.trim() == expected_replace), "apply must leave the vendor replace untouched:\n{gomod_after_apply}" ); assert!( - !consumer.join(".socket/go-patches").join(format!("{UMOD}@{UVER}")).exists() + !consumer + .join(".socket/go-patches") + .join(format!("{UMOD}@{UVER}")) + .exists() && !gomod_after_apply.contains("go-patches"), "apply must not re-create the go-patches redirect for a vendored module" ); // 4. Revert: the taken-over redirect is NOT restored — surfaced via // `takeover_not_restored` — and a fresh `apply` restores it. - let (code, stdout, stderr) = - run_socket(&consumer, &["vendor", "--revert", "--json", "--cwd", cs], &modcache); - assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let (code, stdout, stderr) = run_socket( + &consumer, + &["vendor", "--revert", "--json", "--cwd", cs], + &modcache, + ); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); let renv = parse_envelope(&stdout); assert!( find_event(&renv, "skipped", "takeover_not_restored").is_some(), @@ -524,10 +594,19 @@ fn go_vendor_reports_applied_event() { let (code, stdout, stderr) = run_socket( &consumer, - &["vendor", "--json", "--offline", "--cwd", consumer.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + consumer.to_str().unwrap(), + ], &modcache, ); - assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); let env = parse_envelope(&stdout); assert_eq!( env["summary"]["applied"], 1, diff --git a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs index 7a6456c..f74f62b 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs @@ -195,9 +195,18 @@ fn npm_vendor_fresh_checkout_npm_ci_and_revert() { // 3. Vendor (offline: blob staged locally → zero network). let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); @@ -208,7 +217,10 @@ fn npm_vendor_fresh_checkout_npm_ci_and_revert() { .iter() .find(|e| e["action"] == "applied" && e["purl"] == purl.as_str()) .unwrap_or_else(|| panic!("expected an applied event for {purl}: {env}")); - assert!(applied.get("errorCode").is_none(), "clean apply event: {applied}"); + assert!( + applied.get("errorCode").is_none(), + "clean apply event: {applied}" + ); // Artifact: deterministic tarball + informational marker in the uuid dir. let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); @@ -217,8 +229,10 @@ fn npm_vendor_fresh_checkout_npm_ci_and_revert() { "vendored tarball missing at {tgz_rel}" ); assert!( - proj.join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) - .is_file(), + proj.join(format!( + ".socket/vendor/npm/{UUID}/socket-patch.vendor.json" + )) + .is_file(), "informational vendor marker missing" ); assert!( @@ -250,7 +264,9 @@ fn npm_vendor_fresh_checkout_npm_ci_and_revert() { let pkg_json: serde_json::Value = serde_json::from_slice(&std::fs::read(proj.join("package.json")).unwrap()).unwrap(); assert_eq!( - pkg_json["dependencies"][DEP].as_str().map(|s| s.contains("file:")), + pkg_json["dependencies"][DEP] + .as_str() + .map(|s| s.contains("file:")), Some(false), "package.json dependency spec must stay registry-form" ); @@ -297,9 +313,18 @@ fn npm_vendor_fresh_checkout_npm_ci_and_revert() { let lock_wired = std::fs::read(&lock_path).unwrap(); let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let env2 = parse_envelope(&stdout); assert_eq!(env2["summary"]["failed"], 0, "re-run must not fail: {env2}"); assert_eq!( @@ -311,9 +336,18 @@ fn npm_vendor_fresh_checkout_npm_ci_and_revert() { // 6. REVERT PROOF: lock restored byte-for-byte, artifacts gone. let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--revert", "--json", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--revert", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let renv = parse_envelope(&stdout); assert_eq!(renv["status"], "success", "revert envelope: {renv}"); assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs index 88bdb23..00bbf94 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -225,9 +225,18 @@ fn run_pnpm_capstone(pm: &str) { // 3. Vendor (offline). let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "vendor failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}"); let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); @@ -238,13 +247,21 @@ fn run_pnpm_capstone(pm: &str) { .iter() .find(|e| e["action"] == "applied" && e["purl"] == purl.as_str()) .unwrap_or_else(|| panic!("expected an applied event for {purl}: {env}")); - assert!(applied.get("errorCode").is_none(), "clean apply event: {applied}"); + assert!( + applied.get("errorCode").is_none(), + "clean apply event: {applied}" + ); let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); - assert!(proj.join(&tgz_rel).is_file(), "vendored tarball missing at {tgz_rel}"); assert!( - proj.join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) - .is_file(), + proj.join(&tgz_rel).is_file(), + "vendored tarball missing at {tgz_rel}" + ); + assert!( + proj.join(format!( + ".socket/vendor/npm/{UUID}/socket-patch.vendor.json" + )) + .is_file(), "informational vendor marker missing" ); assert!( @@ -333,9 +350,18 @@ fn run_pnpm_capstone(pm: &str) { let pkg_wired = std::fs::read(&pkg_path).unwrap(); let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "re-vendor failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "re-vendor failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}"); let env2 = parse_envelope(&stdout); assert_eq!(env2["summary"]["failed"], 0, "re-run must not fail: {env2}"); assert_eq!( @@ -352,9 +378,19 @@ fn run_pnpm_capstone(pm: &str) { // 6. REVERT PROOF: package.json AND pnpm-lock.yaml restored byte-for-byte. let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--revert", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--revert", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "revert failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "revert failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}"); let renv = parse_envelope(&stdout); assert_eq!(renv["status"], "success", "revert envelope: {renv}"); assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs index 2150c16..4470b55 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs @@ -285,9 +285,18 @@ fn uv_vendor_fresh_checkout_frozen_offline_and_revert() { // Vendor (offline; blob staged locally). let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); assert_vendored_applied(&parse_envelope(&stdout)); // Artifact + PAIRED wiring (pyproject AND lock — either half alone is a @@ -336,8 +345,16 @@ fn uv_vendor_fresh_checkout_frozen_offline_and_revert() { let fresh_cache = tmp.path().join("fresh-uv-cache"); let fresh_env: Vec<(&str, &str)> = vec![("UV_CACHE_DIR", fresh_cache.to_str().unwrap())]; - let frozen = tool(&uv, &fresh, &["sync", "--frozen", "--offline", "-q"], &fresh_env); - assert_tool_ok(&frozen, "fresh-checkout `uv sync --frozen --offline` (empty cache)"); + let frozen = tool( + &uv, + &fresh, + &["sync", "--frozen", "--offline", "-q"], + &fresh_env, + ); + assert_tool_ok( + &frozen, + "fresh-checkout `uv sync --frozen --offline` (empty cache)", + ); assert_eq!( python_oracle(&fresh.join(".venv"), &fresh), "1", @@ -352,9 +369,18 @@ fn uv_vendor_fresh_checkout_frozen_offline_and_revert() { // REVERT PROOF: both halves of the pair restored byte-for-byte. let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--revert", "--json", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--revert", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let renv = parse_envelope(&stdout); assert_eq!(renv["status"], "success", "revert envelope: {renv}"); assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); @@ -421,9 +447,18 @@ fn pip_requirements_vendor_fresh_checkout_no_index_and_revert() { // Vendor (offline; blob staged locally). let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); assert_vendored_applied(&parse_envelope(&stdout)); // Artifact + the rewritten pin line (the exact spike-tested shape: @@ -449,7 +484,9 @@ fn pip_requirements_vendor_fresh_checkout_no_index_and_revert() { "the path line must pin the wheel hash (hardens every install): {vendor_line}" ); assert!( - !requirements.lines().any(|l| l.trim_start().starts_with("six==")), + !requirements + .lines() + .any(|l| l.trim_start().starts_with("six==")), "the original registry pin must be gone:\n{requirements}" ); @@ -458,7 +495,11 @@ fn pip_requirements_vendor_fresh_checkout_no_index_and_revert() { // against the CWD in both pip and uv — spike claim 3). let fresh = tmp.path().join("fresh"); std::fs::create_dir_all(&fresh).unwrap(); - std::fs::copy(proj.join("requirements.txt"), fresh.join("requirements.txt")).unwrap(); + std::fs::copy( + proj.join("requirements.txt"), + fresh.join("requirements.txt"), + ) + .unwrap(); copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); let fresh_venv = fresh.join(".venv"); @@ -499,7 +540,14 @@ fn pip_requirements_vendor_fresh_checkout_no_index_and_revert() { let uv_install = tool( &uv, &fresh, - &["pip", "install", "-q", "--no-index", "-r", "requirements.txt"], + &[ + "pip", + "install", + "-q", + "--no-index", + "-r", + "requirements.txt", + ], &envs2, ); assert_tool_ok( @@ -521,9 +569,18 @@ fn pip_requirements_vendor_fresh_checkout_no_index_and_revert() { // REVERT PROOF. let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--revert", "--json", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--revert", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let renv = parse_envelope(&stdout); assert_eq!(renv["status"], "success", "revert envelope: {renv}"); assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs index 6ef1a7e..e715b8f 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs @@ -228,9 +228,18 @@ fn yarn_berry_vendor_fresh_checkout_immutable_check_cache_and_revert() { // 3. Vendor (offline). let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); @@ -241,7 +250,10 @@ fn yarn_berry_vendor_fresh_checkout_immutable_check_cache_and_revert() { .iter() .find(|e| e["action"] == "applied" && e["purl"] == purl.as_str()) .unwrap_or_else(|| panic!("expected an applied event for {purl}: {env}")); - assert!(applied.get("errorCode").is_none(), "clean apply event: {applied}"); + assert!( + applied.get("errorCode").is_none(), + "clean apply event: {applied}" + ); let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); assert!( @@ -249,8 +261,10 @@ fn yarn_berry_vendor_fresh_checkout_immutable_check_cache_and_revert() { "vendored tarball missing at {tgz_rel}" ); assert!( - proj.join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) - .is_file(), + proj.join(format!( + ".socket/vendor/npm/{UUID}/socket-patch.vendor.json" + )) + .is_file(), "informational vendor marker missing" ); assert!( @@ -284,9 +298,15 @@ fn yarn_berry_vendor_fresh_checkout_immutable_check_cache_and_revert() { .lines() .map(str::trim) .find(|l| l.starts_with("checksum: 10c0/")) - .unwrap_or_else(|| panic!("yarn.lock must carry a `checksum: 10c0/` line:\n{lock_after}")); + .unwrap_or_else(|| { + panic!("yarn.lock must carry a `checksum: 10c0/` line:\n{lock_after}") + }); let checksum_hex = checksum_line.trim_start_matches("checksum: 10c0/"); - assert_eq!(checksum_hex.len(), 128, "sha512 hex is 128 chars: {checksum_line}"); + assert_eq!( + checksum_hex.len(), + 128, + "sha512 hex is 128 chars: {checksum_line}" + ); assert!( checksum_hex.bytes().all(|b| b.is_ascii_hexdigit()), "checksum body must be hex: {checksum_line}" @@ -348,9 +368,18 @@ fn yarn_berry_vendor_fresh_checkout_immutable_check_cache_and_revert() { let pkg_wired = std::fs::read(&pkg_path).unwrap(); let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let env2 = parse_envelope(&stdout); assert_eq!(env2["summary"]["failed"], 0, "re-run must not fail: {env2}"); assert_eq!( @@ -367,9 +396,19 @@ fn yarn_berry_vendor_fresh_checkout_immutable_check_cache_and_revert() { // 6. REVERT PROOF: package.json AND yarn.lock restored byte-for-byte. let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--revert", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--revert", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let renv = parse_envelope(&stdout); assert_eq!(renv["status"], "success", "revert envelope: {renv}"); assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs index d74bab9..a581b34 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs @@ -224,9 +224,18 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() { // 3. Vendor (offline: blob staged locally → zero network). let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); @@ -237,7 +246,10 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() { .iter() .find(|e| e["action"] == "applied" && e["purl"] == purl.as_str()) .unwrap_or_else(|| panic!("expected an applied event for {purl}: {env}")); - assert!(applied.get("errorCode").is_none(), "clean apply event: {applied}"); + assert!( + applied.get("errorCode").is_none(), + "clean apply event: {applied}" + ); // Artifact: deterministic tarball + informational marker in the uuid dir. let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); @@ -246,8 +258,10 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() { "vendored tarball missing at {tgz_rel}" ); assert!( - proj.join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) - .is_file(), + proj.join(format!( + ".socket/vendor/npm/{UUID}/socket-patch.vendor.json" + )) + .is_file(), "informational vendor marker missing" ); assert!( @@ -334,9 +348,18 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() { let lock_wired = std::fs::read(&lock_path).unwrap(); let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let env2 = parse_envelope(&stdout); assert_eq!(env2["summary"]["failed"], 0, "re-run must not fail: {env2}"); assert_eq!( @@ -348,9 +371,19 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() { // 6. REVERT PROOF: lock restored byte-for-byte, artifacts gone. let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--revert", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--revert", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let renv = parse_envelope(&stdout); assert_eq!(renv["status"], "success", "revert envelope: {renv}"); assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); diff --git a/crates/socket-patch-cli/tests/e2e_vex.rs b/crates/socket-patch-cli/tests/e2e_vex.rs index 23cc879..b08db19 100644 --- a/crates/socket-patch-cli/tests/e2e_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_vex.rs @@ -169,8 +169,7 @@ fn no_verify_emits_valid_openvex() { ); let stdout = String::from_utf8(out.stdout).unwrap(); - let doc: Value = serde_json::from_str(&stdout) - .expect("vex stdout must be valid JSON"); + let doc: Value = serde_json::from_str(&stdout).expect("vex stdout must be valid JSON"); assert_eq!(doc["@context"], "https://openvex.dev/ns/v0.2.0"); assert_eq!(doc["@id"], "urn:uuid:fixed-test-id"); @@ -473,17 +472,15 @@ fn auto_detect_uses_package_json() { write_manifest(cwd, &manifest); let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--no-verify", - ]) + .args(["vex", "--cwd", cwd.to_str().unwrap(), "--no-verify"]) .output() .expect("invoke vex"); assert!(out.status.success()); let doc: Value = serde_json::from_slice(&out.stdout).unwrap(); - assert_eq!(doc["statements"][0]["products"][0]["@id"], "pkg:npm/my-app@7.7.7"); + assert_eq!( + doc["statements"][0]["products"][0]["@id"], + "pkg:npm/my-app@7.7.7" + ); } // ────────────────────────────────────────────────────────────────────── @@ -875,8 +872,9 @@ fn json_envelope_partial_failure_on_mixed_verify() { let events = env["events"].as_array().unwrap(); // The applied patch surfaces as a `verified` event keyed by its PURL. assert!( - events.iter().any(|e| e["action"] == "verified" - && e["purl"] == "pkg:npm/applied-pkg@1.0.0"), + events + .iter() + .any(|e| e["action"] == "verified" && e["purl"] == "pkg:npm/applied-pkg@1.0.0"), "expected a verified event for the applied package. events:\n{events:#?}" ); // The omitted patch surfaces as a `skipped` event whose `errorCode` @@ -895,7 +893,11 @@ fn json_envelope_partial_failure_on_mixed_verify() { let vex_text = std::fs::read_to_string(&vex_path).unwrap(); let doc: Value = serde_json::from_str(&vex_text).unwrap(); let stmts = doc["statements"].as_array().unwrap(); - assert_eq!(stmts.len(), 1, "only the applied patch is attested. doc:\n{vex_text}"); + assert_eq!( + stmts.len(), + 1, + "only the applied patch is attested. doc:\n{vex_text}" + ); assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-applied"); assert!( !vex_text.contains("GHSA-unapplied"), @@ -1022,8 +1024,8 @@ fn maybe_validate_with_vexctl(vex_text: &str) { String::from_utf8_lossy(&out.stdout) ); // Sanity: the merge output must itself be valid OpenVEX JSON. - let _: Value = serde_json::from_slice(&out.stdout) - .expect("vexctl merge output must be valid JSON"); + let _: Value = + serde_json::from_slice(&out.stdout).expect("vexctl merge output must be valid JSON"); } /// Stdlib-only `PATH` lookup for `vexctl`. Returns `None` if missing. diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index 2134a6f..84dfe10 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -174,7 +174,13 @@ fn vendored_purl_attested_with_no_installed_tree() { let mut manifest = PatchManifest::new(); manifest.patches.insert( purl.to_string(), - make_record(UUID, "src/lib.rs", &after_hash, "GHSA-vend-aaaa", &["CVE-2024-1"]), + make_record( + UUID, + "src/lib.rs", + &after_hash, + "GHSA-vend-aaaa", + &["CVE-2024-1"], + ), ); write_manifest(cwd, &manifest, true); @@ -227,7 +233,13 @@ fn tampered_vendored_artifact_omitted_with_vendor_hash_mismatch() { let mut manifest = PatchManifest::new(); manifest.patches.insert( purl.to_string(), - make_record(UUID, "src/lib.rs", &after_hash, "GHSA-vend-bbbb", &["CVE-2024-2"]), + make_record( + UUID, + "src/lib.rs", + &after_hash, + "GHSA-vend-bbbb", + &["CVE-2024-2"], + ), ); write_manifest(cwd, &manifest, true); @@ -267,7 +279,10 @@ fn tampered_vendored_artifact_omitted_with_vendor_hash_mismatch() { skipped["errorCode"], "vendor_hash_mismatch", "the vendor verification reason must land in errorCode. event:\n{skipped}" ); - assert!(!vex_path.exists(), "no VEX doc may be written when nothing attests"); + assert!( + !vex_path.exists(), + "no VEX doc may be written when nothing attests" + ); } // ────────────────────────────────────────────────────────────────────── @@ -303,7 +318,13 @@ fn property7_vendored_purl_bypasses_setup_manual_filter() { let mut manifest = PatchManifest::new(); manifest.patches.insert( vendored_purl.to_string(), - make_record(UUID, "src/lib.rs", &after_hash, "GHSA-vend-cccc", &["CVE-2024-3"]), + make_record( + UUID, + "src/lib.rs", + &after_hash, + "GHSA-vend-cccc", + &["CVE-2024-3"], + ), ); manifest.patches.insert( "pkg:npm/applied-pkg@1.0.0".to_string(), diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index 66ed204..90157bd 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -527,7 +527,8 @@ fn assert_rollback_restored(cwd: &Path, ecosystem: &str, fixture: &RollbackFixtu ) }); assert_eq!( - restored, ORIGINAL, + restored, + ORIGINAL, "rollback --ecosystems={ecosystem}: file at {} was not restored to its original bytes", fixture.verify_file.display() ); @@ -608,7 +609,8 @@ fn fixture_pypi(root: &Path) -> RollbackFixture { }; std::fs::create_dir_all(sp.join("__rollback_dispatch__-1.0.0.dist-info")).unwrap(); std::fs::write( - sp.join("__rollback_dispatch__-1.0.0.dist-info").join("METADATA"), + sp.join("__rollback_dispatch__-1.0.0.dist-info") + .join("METADATA"), "Name: __rollback_dispatch__\nVersion: 1.0.0\n\n", ) .unwrap(); @@ -704,7 +706,11 @@ fn rollback_dispatch_branch_cargo() { "[package]\nname = \"__rollback_dispatch__\"\nversion = \"1.0.0\"\n", ) .unwrap(); - std::fs::write(crate_dir.join(".cargo-checksum.json"), r#"{"files":{},"package":"x"}"#).unwrap(); + std::fs::write( + crate_dir.join(".cargo-checksum.json"), + r#"{"files":{},"package":"x"}"#, + ) + .unwrap(); let verify_file = crate_dir.join("src").join("lib.rs"); std::fs::write(&verify_file, PATCHED).unwrap(); write_rollback_manifest(root, purl, "src/lib.rs"); @@ -755,11 +761,7 @@ fn rollback_dispatch_branch_maven() { write_root_package_json(root); std::fs::write(root.join("pom.xml"), "\n").unwrap(); let repo = root.join("m2repo"); - let artifact_dir = repo - .join("org") - .join("example") - .join("foo") - .join("1.0.0"); + let artifact_dir = repo.join("org").join("example").join("foo").join("1.0.0"); std::fs::create_dir_all(&artifact_dir).unwrap(); // The Maven crawler verifies a coordinate dir by the presence of a .pom. std::fs::write(artifact_dir.join("foo-1.0.0.pom"), "").unwrap(); diff --git a/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs b/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs index 80c1374..5c41d3c 100644 --- a/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs @@ -60,7 +60,12 @@ const SOCKET_ENV_VARS: &[&str] = &[ /// Run `socket-patch get ` with `--json --save-only --yes` /// against `api_url` (authenticated mode). Returns (code, stdout, stderr). -fn run_get_auth(cwd: &Path, api_url: &str, identifier: &str, extra: &[&str]) -> (i32, String, String) { +fn run_get_auth( + cwd: &Path, + api_url: &str, + identifier: &str, + extra: &[&str], +) -> (i32, String, String) { let mut args = vec![ "get", identifier, @@ -80,9 +85,7 @@ fn run_get_auth(cwd: &Path, api_url: &str, identifier: &str, extra: &[&str]) -> for var in SOCKET_ENV_VARS { cmd.env_remove(var); } - let out = cmd - .output() - .expect("run socket-patch"); + let out = cmd.output().expect("run socket-patch"); ( out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).to_string(), @@ -109,7 +112,9 @@ async fn get_by_purl_with_multiple_patches_emits_selection_required() { let encoded = "pkg%3Anpm%2Fmultipatch%401.0.0"; Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}"))) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [ { @@ -140,8 +145,7 @@ async fn get_by_purl_with_multiple_patches_emits_selection_required() { code, 1, "multi free-patch in JSON mode must exit 1; stdout={stdout}" ); - let v: serde_json::Value = - serde_json::from_str(stdout.trim()).expect("valid JSON envelope"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON envelope"); assert_eq!( v["status"], "selection_required", "must surface selection_required; got {}", @@ -162,8 +166,10 @@ async fn get_by_purl_with_multiple_patches_emits_selection_required() { // Each option must carry the full disambiguation payload — tier, the // human description, and the publish timestamp — so a degenerate // "just the uuid" shape (which would make the prompt useless) fails. - let descriptions: HashSet<&str> = - opts.iter().filter_map(|o| o["description"].as_str()).collect(); + let descriptions: HashSet<&str> = opts + .iter() + .filter_map(|o| o["description"].as_str()) + .collect(); assert!( descriptions.contains("Patch A") && descriptions.contains("Patch B"), "options must echo each patch description; got {descriptions:?}" @@ -290,7 +296,9 @@ async fn get_uuid_returning_500_emits_error() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON error envelope"); assert_eq!(v["status"], "error", "5xx must surface as error"); - let err = v["error"].as_str().expect("error envelope must carry an error string"); + let err = v["error"] + .as_str() + .expect("error envelope must carry an error string"); assert!( err.contains("500"), "error must surface the HTTP status code; got {err:?}" @@ -305,9 +313,7 @@ async fn get_uuid_returning_malformed_json_emits_error() { let mock = MockServer::start().await; Mock::given(method("GET")) .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{UUID_A}"))) - .respond_with( - ResponseTemplate::new(200).set_body_string("{ this is not json"), - ) + .respond_with(ResponseTemplate::new(200).set_body_string("{ this is not json")) .expect(1) .mount(&mock) .await; @@ -318,7 +324,9 @@ async fn get_uuid_returning_malformed_json_emits_error() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON error envelope"); assert_eq!(v["status"], "error", "parse failure must surface as error"); - let err = v["error"].as_str().expect("error envelope must carry an error string"); + let err = v["error"] + .as_str() + .expect("error envelope must carry an error string"); assert!( err.to_lowercase().contains("parse"), "error must describe a parse failure; got {err:?}" @@ -346,9 +354,11 @@ async fn get_by_cve_with_no_patches_emits_no_match() { .await; let tmp = tempfile::tempdir().expect("tempdir"); - let (code, stdout, _stderr) = - run_get_auth(tmp.path(), &mock.uri(), "CVE-2099-9999", &[]); - assert_eq!(code, 0, "empty CVE search is a clean no-op; stdout={stdout}"); + let (code, stdout, _stderr) = run_get_auth(tmp.path(), &mock.uri(), "CVE-2099-9999", &[]); + assert_eq!( + code, 0, + "empty CVE search is a clean no-op; stdout={stdout}" + ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!( v["status"], "not_found", @@ -379,9 +389,11 @@ async fn get_by_ghsa_with_no_patches_emits_no_match() { .await; let tmp = tempfile::tempdir().expect("tempdir"); - let (code, stdout, _stderr) = - run_get_auth(tmp.path(), &mock.uri(), "GHSA-xxxx-xxxx-xxxx", &[]); - assert_eq!(code, 0, "empty GHSA search is a clean no-op; stdout={stdout}"); + let (code, stdout, _stderr) = run_get_auth(tmp.path(), &mock.uri(), "GHSA-xxxx-xxxx-xxxx", &[]); + assert_eq!( + code, 0, + "empty GHSA search is a clean no-op; stdout={stdout}" + ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!( v["status"], "not_found", diff --git a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs index be54ba1..3d49015 100644 --- a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs +++ b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs @@ -71,7 +71,9 @@ async fn get_with_id_flag_selects_specific_patch() { let encoded = "pkg%3Anpm%2Fmulti%401.0.0"; Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}"))) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [ { @@ -143,9 +145,16 @@ async fn get_with_id_flag_selects_specific_patch() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout={stdout}"); assert_eq!(v["found"], 1, "exactly one patch fetched; stdout={stdout}"); - assert_eq!(v["downloaded"], 1, "the patch must be downloaded; stdout={stdout}"); + assert_eq!( + v["downloaded"], 1, + "the patch must be downloaded; stdout={stdout}" + ); let patches = v["patches"].as_array().expect("patches array"); - assert_eq!(patches.len(), 1, "exactly one patch record; stdout={stdout}"); + assert_eq!( + patches.len(), + 1, + "exactly one patch record; stdout={stdout}" + ); // The crux: --id must select UUID_B specifically, not the // first patch (UUID_A) that the by-package listing would surface. assert_eq!( @@ -165,7 +174,9 @@ async fn get_with_id_flag_selects_specific_patch() { // dedup/sort to UUID_B; the request log is what makes this airtight. let paths = received_paths(&mock).await; assert!( - paths.iter().any(|p| p.ends_with(&format!("/patches/view/{UUID_B}"))), + paths + .iter() + .any(|p| p.ends_with(&format!("/patches/view/{UUID_B}"))), "--id must fetch view/{UUID_B} directly; recorded paths={paths:?}" ); assert!( @@ -173,7 +184,9 @@ async fn get_with_id_flag_selects_specific_patch() { "--id must NOT consult the by-package listing; recorded paths={paths:?}" ); assert!( - !paths.iter().any(|p| p.ends_with(&format!("/patches/view/{UUID_A}"))), + !paths + .iter() + .any(|p| p.ends_with(&format!("/patches/view/{UUID_A}"))), "--id must not fetch the non-selected UUID_A; recorded paths={paths:?}" ); } @@ -185,7 +198,9 @@ async fn get_with_no_matching_purl_emits_not_found() { let encoded = "pkg%3Anpm%2Fempty-result%401.0.0"; Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}"))) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [], "canAccessPaidPatches": false, @@ -230,7 +245,9 @@ async fn get_with_no_matching_purl_emits_not_found() { // short-circuit that never queried the API at all. let paths = received_paths(&mock).await; assert!( - paths.iter().any(|p| p.contains(&format!("/by-package/{encoded}"))), + paths + .iter() + .any(|p| p.contains(&format!("/by-package/{encoded}"))), "the by-package endpoint must actually be queried; recorded paths={paths:?}" ); } @@ -284,8 +301,14 @@ async fn get_by_package_with_single_paid_patch_emits_paid_required() { // but success". The patch must NOT have been downloaded. assert_eq!(v["status"], "paid_required", "stdout={stdout}"); assert_eq!(v["found"], 1, "the paid patch was found; stdout={stdout}"); - assert_eq!(v["downloaded"], 0, "must not download a paid patch; stdout={stdout}"); - assert_eq!(v["applied"], 0, "must not apply a paid patch; stdout={stdout}"); + assert_eq!( + v["downloaded"], 0, + "must not download a paid patch; stdout={stdout}" + ); + assert_eq!( + v["applied"], 0, + "must not apply a paid patch; stdout={stdout}" + ); let patches = v["patches"].as_array().expect("patches array"); assert_eq!(patches.len(), 1, "stdout={stdout}"); assert_eq!(patches[0]["uuid"], UUID_A, "stdout={stdout}"); @@ -294,7 +317,9 @@ async fn get_by_package_with_single_paid_patch_emits_paid_required() { // must NOT have attempted to download the paid blob via any view endpoint. let paths = received_paths(&mock).await; assert!( - paths.iter().any(|p| p.contains(&format!("/patch/by-package/{encoded}"))), + paths + .iter() + .any(|p| p.contains(&format!("/patch/by-package/{encoded}"))), "the public proxy by-package endpoint must be queried; recorded paths={paths:?}" ); assert!( @@ -359,7 +384,10 @@ async fn get_with_invalid_search_purl_falls_through() { assert_ne!(v["status"], "success", "stdout={stdout}"); // The mock returns 500; if the binary had queried it the run would have // surfaced an error status instead of no_packages. - assert_ne!(v["status"], "error", "should not have reached the API; stdout={stdout}"); + assert_ne!( + v["status"], "error", + "should not have reached the API; stdout={stdout}" + ); // The strongest guarantee: the binary must short-circuit BEFORE any // network call on an empty workspace. Inspecting the status alone is a // disjoint-outcome loophole (a broken impl could hit the 500 mock and @@ -435,7 +463,9 @@ async fn get_uuid_returns_paid_patch_with_token_succeeds() { // (bypassing the public proxy), proving the download was a real fetch. let paths = received_paths(&mock).await; assert!( - paths.iter().any(|p| p.ends_with(&format!("/v0/orgs/{ORG_SLUG}/patches/view/{UUID_A}"))), + paths + .iter() + .any(|p| p.ends_with(&format!("/v0/orgs/{ORG_SLUG}/patches/view/{UUID_A}"))), "authenticated paid fetch must hit the org-scoped view endpoint; recorded paths={paths:?}" ); } @@ -448,7 +478,14 @@ fn get_help_lists_all_identifier_flags() { .expect("run"); assert_eq!(out.status.code(), Some(0)); let stdout = String::from_utf8_lossy(&out.stdout); - for flag in ["--id", "--cve", "--ghsa", "--package", "--save-only", "--one-off"] { + for flag in [ + "--id", + "--cve", + "--ghsa", + "--package", + "--save-only", + "--one-off", + ] { assert!( stdout.contains(flag), "get --help missing flag {flag}; got: {stdout}" diff --git a/crates/socket-patch-cli/tests/get_invariants.rs b/crates/socket-patch-cli/tests/get_invariants.rs index 10e4c19..c22e70b 100644 --- a/crates/socket-patch-cli/tests/get_invariants.rs +++ b/crates/socket-patch-cli/tests/get_invariants.rs @@ -119,7 +119,10 @@ async fn get_by_uuid_not_found_emits_envelope() { let tmp = tempfile::tempdir().expect("tempdir"); let (code, stdout, stderr) = run_get(tmp.path(), &mock.uri(), UUID, &[]); - assert_eq!(code, 0, "not_found is a clean (non-error) outcome; stderr={stderr}"); + assert_eq!( + code, 0, + "not_found is a clean (non-error) outcome; stderr={stderr}" + ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "not_found"); assert_eq!(v["found"], 0); @@ -316,7 +319,9 @@ async fn get_by_purl_returns_matching_patches() { let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}"))) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, @@ -359,7 +364,9 @@ async fn get_multiple_patches_in_json_mode_returns_selection_required() { let uuid_b = "22222222-2222-4222-8222-222222222222"; Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}"))) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [ { @@ -473,8 +480,10 @@ async fn get_uuid_paid_patch_via_public_proxy_emits_paid_required_envelope() { let stdout = String::from_utf8_lossy(&out.stdout); let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { - panic!("invalid JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{}", - String::from_utf8_lossy(&out.stderr)) + panic!( + "invalid JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ) }); assert_eq!( v["status"], "paid_required", @@ -549,14 +558,27 @@ async fn get_paid_patch_via_public_proxy_returns_paid_required() { v["status"], "paid_required", "paid patch without token must emit paid_required; got: {v}" ); - assert_eq!(v["found"], 1, "the one paid patch must be counted as found; got {v}"); - assert_eq!(v["downloaded"], 0, "paid patch must not be downloaded; got {v}"); + assert_eq!( + v["found"], 1, + "the one paid patch must be counted as found; got {v}" + ); + assert_eq!( + v["downloaded"], 0, + "paid patch must not be downloaded; got {v}" + ); assert_eq!(v["applied"], 0, "paid patch must not be applied; got {v}"); let patches = v["patches"].as_array().expect("patches array"); - assert_eq!(patches.len(), 1, "exactly the one paid patch must be reported; got {v}"); + assert_eq!( + patches.len(), + 1, + "exactly the one paid patch must be reported; got {v}" + ); assert_eq!(patches[0]["purl"], purl); assert_eq!(patches[0]["uuid"], UUID); - assert_eq!(patches[0]["tier"], "paid", "reported patch must be flagged paid; got {v}"); + assert_eq!( + patches[0]["tier"], "paid", + "reported patch must be flagged paid; got {v}" + ); // Nothing was downloaded, so no manifest may be written. assert!( !tmp.path().join(".socket/manifest.json").exists(), diff --git a/crates/socket-patch-cli/tests/global_packages_e2e.rs b/crates/socket-patch-cli/tests/global_packages_e2e.rs index ee5fcad..f79cad1 100644 --- a/crates/socket-patch-cli/tests/global_packages_e2e.rs +++ b/crates/socket-patch-cli/tests/global_packages_e2e.rs @@ -156,7 +156,10 @@ fn assert_rollback_noop(stdout: &str) { assert_eq!(v["failed"], 0, "envelope={v}"); assert_eq!(v["dryRun"], false, "envelope={v}"); assert_eq!( - v["results"].as_array().expect("results must be an array").len(), + v["results"] + .as_array() + .expect("results must be an array") + .len(), 0, "no package was patched, so results must be empty; envelope={v}" ); @@ -192,13 +195,7 @@ fn rollback_global_resolves_real_npm_prefix() { write_manifest(&tmp.path(), "pkg:npm/__rollback_global__@1.0.0"); let out = Command::new(binary()) - .args([ - "rollback", - "--global", - "--offline", - "--json", - "--silent", - ]) + .args(["rollback", "--global", "--offline", "--json", "--silent"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") .output() @@ -249,7 +246,10 @@ fn apply_global_prefix_uses_explicit_path() { .env_remove("SOCKET_API_TOKEN") .output() .expect("run socket-patch"); - (out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).to_string()) + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + ) }; // Negative: empty prefix → nothing to patch. @@ -295,7 +295,10 @@ fn rollback_global_prefix_uses_explicit_path() { .env_remove("SOCKET_API_TOKEN") .output() .expect("run socket-patch"); - (out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).to_string()) + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + ) }; // Negative: empty prefix → no package, empty results. @@ -363,7 +366,10 @@ fn apply_global_with_empty_path_handles_missing_npm() { .expect("run socket-patch"); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - assert_eq!(code, 1, "missing npm → exit 1, not a crash; stdout={stdout}"); + assert_eq!( + code, 1, + "missing npm → exit 1, not a crash; stdout={stdout}" + ); assert_apply_not_installed(&stdout, "pkg:npm/__missing_npm__@1.0.0"); } @@ -373,13 +379,7 @@ fn rollback_global_with_empty_path_handles_missing_npm() { write_manifest(&tmp.path(), "pkg:npm/__missing_npm__@1.0.0"); let out = Command::new(binary()) - .args([ - "rollback", - "--global", - "--offline", - "--json", - "--silent", - ]) + .args(["rollback", "--global", "--offline", "--json", "--silent"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") .env("PATH", "/nonexistent-dir-for-test") @@ -452,7 +452,10 @@ fn apply_global_with_stub_npm_root_resolves_path() { .expect("run socket-patch"); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - assert_eq!(code, 0, "stubbed npm root resolves seeded pkg → exit 0; stdout={stdout}"); + assert_eq!( + code, 0, + "stubbed npm root resolves seeded pkg → exit 0; stdout={stdout}" + ); assert_apply_applied(&stdout, "pkg:npm/__stubbed_npm__@1.0.0"); assert!( marker.exists(), @@ -475,7 +478,10 @@ fn apply_global_with_empty_npm_root_output_handles_error() { write_stub( &stub_dir, "npm", - &format!("#!/bin/sh\necho invoked > \"{}\"\nexit 0\n", marker.display()), + &format!( + "#!/bin/sh\necho invoked > \"{}\"\nexit 0\n", + marker.display() + ), ); write_manifest(tmp.path(), "pkg:npm/__empty_npm__@1.0.0"); @@ -505,7 +511,10 @@ fn apply_global_with_failing_npm_handles_error() { write_stub( &stub_dir, "npm", - &format!("#!/bin/sh\necho invoked > \"{}\"\nexit 1\n", marker.display()), + &format!( + "#!/bin/sh\necho invoked > \"{}\"\nexit 1\n", + marker.display() + ), ); write_manifest(tmp.path(), "pkg:npm/__failing_npm__@1.0.0"); diff --git a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs index 108d697..8391769 100644 --- a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs +++ b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs @@ -192,8 +192,8 @@ async fn pnpm_install_then_apply_patches_file() { // — if pnpm ever produced a hoisted (non-symlinked) layout instead, // we would not be exercising the symlink-following path and must know. let ms_dir = tmp.path().join("node_modules/ms"); - let ms_meta = std::fs::symlink_metadata(&ms_dir) - .expect("node_modules/ms must exist after pnpm install"); + let ms_meta = + std::fs::symlink_metadata(&ms_dir).expect("node_modules/ms must exist after pnpm install"); assert!( ms_meta.file_type().is_symlink(), "pnpm test premise broken: node_modules/ms is not a symlink ({:?}); \ 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 6f66bb2..81ee468 100644 --- a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs @@ -81,7 +81,10 @@ edition = "2021" // Find the crate's src/lib.rs under CARGO_HOME/registry/src//cfg-if-1.0.0/src/lib.rs let src_root = cargo_home.join("registry/src"); - for entry in std::fs::read_dir(&src_root).expect("registry/src").flatten() { + for entry in std::fs::read_dir(&src_root) + .expect("registry/src") + .flatten() + { let candidate = entry .path() .join(format!("{CRATE_NAME}-{CRATE_VERSION}")) @@ -122,7 +125,9 @@ async fn setup_cargo_apply_mock( .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": purl, @@ -194,7 +199,10 @@ async fn cargo_fetch_scan_sync_patches_real_file() { // Sanity: the fixture must actually change the file, otherwise the // "marker present" assertion below would be vacuously satisfiable. - assert_ne!(original, patched, "patched fixture must differ from original"); + assert_ne!( + original, patched, + "patched fixture must differ from original" + ); assert_ne!(before_hash, after_hash, "before/after hashes must differ"); // Pristine pre-check: the marker must NOT already be on disk, so its // later presence can only come from a real apply writing `patched`. @@ -254,8 +262,7 @@ async fn cargo_fetch_scan_sync_patches_real_file() { .expect("wiremock should record requests"); let purl = format!("pkg:cargo/{CRATE_NAME}@{CRATE_VERSION}"); let hit_batch = requests.iter().any(|r| { - r.url.path().ends_with("/patches/batch") - && String::from_utf8_lossy(&r.body).contains(&purl) + r.url.path().ends_with("/patches/batch") && String::from_utf8_lossy(&r.body).contains(&purl) }); let hit_view = requests .iter() @@ -360,8 +367,7 @@ async fn cargo_apply_refuses_on_before_hash_mismatch() { .expect("wiremock should record requests"); let purl = format!("pkg:cargo/{CRATE_NAME}@{CRATE_VERSION}"); let hit_batch = requests.iter().any(|r| { - r.url.path().ends_with("/patches/batch") - && String::from_utf8_lossy(&r.body).contains(&purl) + r.url.path().ends_with("/patches/batch") && String::from_utf8_lossy(&r.body).contains(&purl) }); assert!(hit_batch, "crawler never sent cfg-if to the batch endpoint"); diff --git a/crates/socket-patch-cli/tests/in_process_edge_cases.rs b/crates/socket-patch-cli/tests/in_process_edge_cases.rs index cf37dce..37ba11a 100644 --- a/crates/socket-patch-cli/tests/in_process_edge_cases.rs +++ b/crates/socket-patch-cli/tests/in_process_edge_cases.rs @@ -130,12 +130,7 @@ async fn apply_overwrites_read_only_file() { r#"{"name":"r","version":"0.0.0"}"#, ) .unwrap(); - write_npm_pkg( - tmp.path(), - "ro-target", - "1.0.0", - &[("index.js", original)], - ); + write_npm_pkg(tmp.path(), "ro-target", "1.0.0", &[("index.js", original)]); // Make the package file read-only — apply must make it writable to // overwrite. This mimics the cargo-registry-source layout. let file = tmp.path().join("node_modules/ro-target/index.js"); @@ -302,12 +297,7 @@ async fn apply_blob_after_hash_mismatch_reports_failure() { let claimed_after_hash = git_sha256(b"different content"); // mismatched let actual_blob_bytes = b"this is what's on disk\n"; // doesn't hash to claimed_after_hash let before_hash = git_sha256(original); - write_npm_pkg( - tmp.path(), - "mismatch", - "1.0.0", - &[("index.js", original)], - ); + write_npm_pkg(tmp.path(), "mismatch", "1.0.0", &[("index.js", original)]); let socket = tmp.path().join(".socket"); write_manifest( @@ -353,7 +343,11 @@ async fn apply_blob_after_hash_mismatch_reports_failure() { actual_blob_bytes.as_slice(), "unverified blob bytes must never reach the target file" ); - assert_eq!(post.as_slice(), original, "file must remain the pristine original"); + assert_eq!( + post.as_slice(), + original, + "file must remain the pristine original" + ); } // --------------------------------------------------------------------------- @@ -373,12 +367,7 @@ async fn apply_twice_second_run_is_idempotent() { let patched = b"patched\n"; let before_hash = git_sha256(original); let after_hash = git_sha256(patched); - write_npm_pkg( - tmp.path(), - "idempotent", - "1.0.0", - &[("index.js", original)], - ); + write_npm_pkg(tmp.path(), "idempotent", "1.0.0", &[("index.js", original)]); let socket = tmp.path().join(".socket"); write_manifest( @@ -417,7 +406,10 @@ async fn apply_twice_second_run_is_idempotent() { // allocates a fresh inode, so a lost short-circuit fails loudly here. assert_eq!(apply_run(default_apply(tmp.path())).await, 0); let after = std::fs::read(&target).unwrap(); - assert_eq!(after, patched, "idempotent re-apply preserves patched content"); + assert_eq!( + after, patched, + "idempotent re-apply preserves patched content" + ); #[cfg(unix)] assert_eq!( file_identity(&target), @@ -472,7 +464,10 @@ async fn apply_with_missing_target_file_reports_failure() { assert!(!target.exists(), "precondition: target file must be absent"); let code = apply_run(default_apply(tmp.path())).await; - assert_eq!(code, 1, "missing target file (non-empty beforeHash) must fail"); + assert_eq!( + code, 1, + "missing target file (non-empty beforeHash) must fail" + ); // The non-force failure path must not have conjured the file either. assert!( !target.exists(), @@ -553,7 +548,7 @@ async fn rollback_already_original_short_circuits() { global: false, global_prefix: None, org: None, - api_token: None, + api_token: None, ecosystems: Some(vec!["npm".to_string()]), json: true, verbose: false, diff --git a/crates/socket-patch-cli/tests/in_process_gem_apply.rs b/crates/socket-patch-cli/tests/in_process_gem_apply.rs index f165897..63461cd 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_apply.rs @@ -47,7 +47,11 @@ fn ruby_version() -> Option { return None; } let v = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if v.is_empty() { None } else { Some(v) } + if v.is_empty() { + None + } else { + Some(v) + } } /// Install a small gem into `/vendor/bundle/ruby//` and @@ -116,7 +120,9 @@ async fn setup_gem_apply_mock( .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": purl, @@ -207,7 +213,10 @@ async fn gem_install_scan_sync_patches_real_file() { vex: Default::default(), }; let code = scan_run(args).await; - assert_eq!(code, 0, "scan --sync should succeed when the patch applies cleanly"); + assert_eq!( + code, 0, + "scan --sync should succeed when the patch applies cleanly" + ); // The apply must have driven the REAL code path end to end: // crawler discovers the gem -> POSTs its purl to /batch -> fetches the @@ -234,7 +243,10 @@ async fn gem_install_scan_sync_patches_real_file() { assert!( view_hits >= 1, "view endpoint never fetched — apply short-circuited (paths seen: {:?})", - requests.iter().map(|r| r.url.path().to_string()).collect::>() + requests + .iter() + .map(|r| r.url.path().to_string()) + .collect::>() ); // Verify the file on disk is EXACTLY the patched fixture, byte-for-byte. @@ -319,8 +331,7 @@ async fn gem_crawler_finds_real_installed_gem() { .expect("mock server recorded requests"); let batch_path = format!("/v0/orgs/{ORG}/patches/batch"); let discovered = requests.iter().any(|r| { - r.url.path() == batch_path - && String::from_utf8_lossy(&r.body).contains(purl.as_str()) + r.url.path() == batch_path && String::from_utf8_lossy(&r.body).contains(purl.as_str()) }); assert!( discovered, @@ -332,7 +343,8 @@ async fn gem_crawler_finds_real_installed_gem() { // patches behind the user's back during a read-only pass. let after_scan = std::fs::read(&lib_file).expect("read colorize.rb after scan"); assert_eq!( - after_scan, before_scan, + after_scan, + before_scan, "read-only scan mutated the installed gem file at {}", lib_file.display() ); 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 cd4fe3c..d6c5029 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 @@ -134,7 +134,9 @@ async fn setup_mock( .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [ { "uuid": UUID_INSTALLED, "purl": qualified(PLATFORM_INSTALLED), @@ -259,9 +261,12 @@ fn manifest_record(cwd: &Path, purl: &str) -> serde_json::Value { let raw = std::fs::read_to_string(&path) .unwrap_or_else(|_| panic!("manifest not found at {}", path.display())); let v: serde_json::Value = serde_json::from_str(&raw).expect("manifest json"); - let rec = v["patches"] - .get(purl) - .unwrap_or_else(|| panic!("no manifest record for {purl}; have {:?}", manifest_keys(cwd))); + let rec = v["patches"].get(purl).unwrap_or_else(|| { + panic!( + "no manifest record for {purl}; have {:?}", + manifest_keys(cwd) + ) + }); rec.clone() } @@ -393,7 +398,10 @@ async fn broad_scan_keeps_all_platforms() { keys.sort(); let mut expected = vec![qualified(PLATFORM_INSTALLED), qualified(PLATFORM_OTHER)]; expected.sort(); - assert_eq!(keys, expected, "broad scan must store every platform variant"); + assert_eq!( + keys, expected, + "broad scan must store every platform variant" + ); // Each stored variant must carry its OWN distribution's patch data — // proving broad scan genuinely fetched and stored both variants, not just diff --git a/crates/socket-patch-cli/tests/in_process_get.rs b/crates/socket-patch-cli/tests/in_process_get.rs index 367521c..7c822c8 100644 --- a/crates/socket-patch-cli/tests/in_process_get.rs +++ b/crates/socket-patch-cli/tests/in_process_get.rs @@ -26,7 +26,7 @@ fn default_args(identifier: &str, cwd: &Path) -> GetArgs { org: Some(ORG.to_string()), cwd: cwd.to_path_buf(), yes: true, - api_token: Some("fake-token-for-tests".to_string()), + api_token: Some("fake-token-for-tests".to_string()), global: false, global_prefix: None, json: true, @@ -67,7 +67,14 @@ async fn make_view_mock(server: &MockServer, uuid: &str, purl: &str, tier: &str) .await; } -async fn make_search_mock_one(server: &MockServer, kind: &str, key: &str, uuid: &str, purl: &str, tier: &str) { +async fn make_search_mock_one( + server: &MockServer, + kind: &str, + key: &str, + uuid: &str, + purl: &str, + tier: &str, +) { let url_path = format!("/v0/orgs/{ORG}/patches/{kind}/{key}"); Mock::given(method("GET")) .and(path(url_path)) @@ -221,7 +228,10 @@ async fn get_by_uuid_404_emits_not_found() { args.common.api_url = url; let code = run(args).await; - assert_eq!(code, 0, "not_found is reported via JSON, not via exit code 1"); + assert_eq!( + code, 0, + "not_found is reported via JSON, not via exit code 1" + ); assert!( !tmp.path().join(".socket/manifest.json").exists(), "no manifest must be written on 404" @@ -363,7 +373,10 @@ async fn get_by_purl_multi_patch_in_json_mode_errors() { // prompt non-interactively. The previous `0 || 1` accepted the broken // case where the CLI silently auto-picks one and reports success — the // exact behavior this test exists to forbid. - assert_eq!(code, 1, "ambiguous multi-patch selection in --json must exit 1"); + assert_eq!( + code, 1, + "ambiguous multi-patch selection in --json must exit 1" + ); // And it must NOT have downloaded/saved an arbitrarily-chosen patch. assert_no_manifest(tmp.path()); } @@ -466,7 +479,10 @@ async fn get_with_explicit_package_no_install_short_circuits() { assert!( requests.is_empty(), "no_packages short-circuit must make zero API calls, saw: {:?}", - requests.iter().map(|r| r.url.path().to_string()).collect::>() + requests + .iter() + .map(|r| r.url.path().to_string()) + .collect::>() ); } @@ -505,11 +521,15 @@ async fn get_with_explicit_package_flag_resolves_installed_and_saves() { let requests = server.received_requests().await.unwrap(); let paths: Vec = requests.iter().map(|r| r.url.path().to_string()).collect(); assert!( - paths.iter().any(|p| p == &format!("/v0/orgs/{ORG}/patches/by-package/{encoded}")), + paths + .iter() + .any(|p| p == &format!("/v0/orgs/{ORG}/patches/by-package/{encoded}")), "must search by the resolved PURL, saw: {paths:?}" ); assert!( - paths.iter().any(|p| p == &format!("/v0/orgs/{ORG}/patches/view/{UUID}")), + paths + .iter() + .any(|p| p == &format!("/v0/orgs/{ORG}/patches/view/{UUID}")), "must fetch the selected patch's view, saw: {paths:?}" ); } diff --git a/crates/socket-patch-cli/tests/in_process_get_update_count.rs b/crates/socket-patch-cli/tests/in_process_get_update_count.rs index 3d3ae15..a2101ae 100644 --- a/crates/socket-patch-cli/tests/in_process_get_update_count.rs +++ b/crates/socket-patch-cli/tests/in_process_get_update_count.rs @@ -101,7 +101,10 @@ async fn failed_update_fetch_is_not_counted_as_updated() { assert_eq!(code, 1, "a failed detail fetch must exit 1; json={json}"); assert_eq!(json["status"], "partial_failure", "json={json}"); - assert_eq!(json["failed"], 1, "the fetch failure must be counted; json={json}"); + assert_eq!( + json["failed"], 1, + "the fetch failure must be counted; json={json}" + ); assert_eq!( json["updated"], 0, "a patch that never downloaded must not be counted as updated; json={json}" @@ -152,7 +155,10 @@ async fn successful_update_is_counted_once() { // save_only => no apply step => clean success. assert_eq!(code, 0, "save-only update should succeed; json={json}"); assert_eq!(json["status"], "success", "json={json}"); - assert_eq!(json["updated"], 1, "the replacement must be counted once; json={json}"); + assert_eq!( + json["updated"], 1, + "the replacement must be counted once; json={json}" + ); assert_eq!(json["downloaded"], 1, "json={json}"); assert_eq!(json["failed"], 0, "json={json}"); @@ -165,5 +171,8 @@ async fn successful_update_is_counted_once() { // The manifest record was actually replaced with the new uuid. let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); let manifest: serde_json::Value = serde_json::from_str(&body).unwrap(); - assert_eq!(manifest["patches"][PURL]["uuid"], NEW_UUID, "manifest={manifest}"); + assert_eq!( + manifest["patches"][PURL]["uuid"], NEW_UUID, + "manifest={manifest}" + ); } 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 54f6511..e8aab80 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs @@ -154,7 +154,9 @@ async fn setup_pypi_apply_mock( .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": purl, @@ -350,7 +352,10 @@ async fn pypi_scan_then_apply_force_patches_real_file() { vex: Default::default(), }; let apply_code = apply_run(apply_args).await; - assert_eq!(apply_code, 0, "apply --offline --force should succeed (exit 0)"); + assert_eq!( + apply_code, 0, + "apply --offline --force should succeed (exit 0)" + ); // The apply step (not scan) must have re-patched the reverted file // to exactly the served blob. @@ -415,7 +420,10 @@ async fn pypi_apply_dry_run_does_not_modify_file() { // is ever reached) would leave the file untouched and let this test // pass without ever exercising the dry-run apply logic it guards. let dry_code = scan_run(scan_args).await; - assert_eq!(dry_code, 0, "scan --apply --dry-run should succeed (exit 0)"); + assert_eq!( + dry_code, 0, + "scan --apply --dry-run should succeed (exit 0)" + ); let after = std::fs::read(&six_path).expect("read after dry-run"); assert_eq!( @@ -435,10 +443,7 @@ async fn pypi_apply_dry_run_does_not_modify_file() { // six and queried the batch endpoint with its PURL — the same // observable proof of discovery used by the crawler sanity test. let purl = format!("pkg:pypi/{PYPI_PACKAGE}@{PYPI_VERSION}"); - let requests = server - .received_requests() - .await - .expect("recording enabled"); + let requests = server.received_requests().await.expect("recording enabled"); let batch_bodies: Vec = requests .iter() .filter(|r| r.url.path() == format!("/v0/orgs/{ORG}/patches/batch")) @@ -476,11 +481,7 @@ async fn pypi_crawler_finds_real_installed_six() { let has_dist_info = std::fs::read_dir(&site_packages) .expect("site-packages") .flatten() - .any(|e| { - e.file_name() - .to_string_lossy() - .starts_with("six-1.16.0") - }); + .any(|e| e.file_name().to_string_lossy().starts_with("six-1.16.0")); assert!(has_dist_info, "six-1.16.0.dist-info should be present"); // Now run scan and assert discovery via mock. @@ -530,10 +531,7 @@ async fn pypi_crawler_finds_real_installed_six() { // alone does not prove the crawler found six. Verify the crawler // actually sent six's PURL to the batch endpoint — that is the // observable proof of discovery. - let requests = server - .received_requests() - .await - .expect("recording enabled"); + let requests = server.received_requests().await.expect("recording enabled"); let batch_bodies: Vec = requests .iter() .filter(|r| r.url.path() == format!("/v0/orgs/{ORG}/patches/batch")) 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 295d81c..0ae54e4 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 @@ -175,7 +175,9 @@ async fn setup_multi_release_mock(server: &MockServer, installed_before_hash: &s // --- by-package: all three qualified variants ------------------------- Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ // Same deliberate ordering: installed variant LAST (see batch). "patches": [ 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 91cb2d1..9245ac0 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -155,10 +155,7 @@ async fn pypi_venv_python312_layout_discovered() { let server = MockServer::start().await; mock_batch_empty(&server).await; assert_eq!(scan_run(default_args(tmp.path(), server.uri())).await, 0); - assert_discovered( - &batch_bodies(&server).await, - "pkg:pypi/venv-pkg-312@1.0.0", - ); + assert_discovered(&batch_bodies(&server).await, "pkg:pypi/venv-pkg-312@1.0.0"); } // --------------------------------------------------------------------------- @@ -176,10 +173,7 @@ async fn pypi_venv_python313_layout_discovered() { let server = MockServer::start().await; mock_batch_empty(&server).await; assert_eq!(scan_run(default_args(tmp.path(), server.uri())).await, 0); - assert_discovered( - &batch_bodies(&server).await, - "pkg:pypi/venv-pkg-313@1.0.0", - ); + assert_discovered(&batch_bodies(&server).await, "pkg:pypi/venv-pkg-313@1.0.0"); } // --------------------------------------------------------------------------- @@ -258,10 +252,7 @@ async fn pypi_virtual_env_env_var_override() { // `custom-venv` is not one of the standard scanned dir names, so the // package can only be found by honoring $VIRTUAL_ENV. Discovery of its // PURL is the proof that the override path actually ran. - assert_discovered( - &batch_bodies(&server).await, - "pkg:pypi/venv-override@1.0.0", - ); + assert_discovered(&batch_bodies(&server).await, "pkg:pypi/venv-override@1.0.0"); } // --------------------------------------------------------------------------- @@ -375,10 +366,7 @@ async fn pypi_empty_site_packages_safe() { // ...and it must be the ONLY pypi PURL shipped. An empty site-packages // must invent no phantom packages; the exact-count check fails if the // crawler conjures anything from the empty `.venv`. - let total_pypi_purls: usize = bodies - .iter() - .map(|b| b.matches("pkg:pypi/").count()) - .sum(); + let total_pypi_purls: usize = bodies.iter().map(|b| b.matches("pkg:pypi/").count()).sum(); assert_eq!( total_pypi_purls, 1, "exactly one pypi PURL (the control) expected; empty site-packages \ 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 6e7cd37..eecdfa8 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 @@ -131,7 +131,9 @@ async fn setup_apply_mock( .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": uuid, "purl": purl, @@ -178,9 +180,7 @@ async fn golang_handcrafted_install_apply_patches_file() { // GOMODCACHE layout: @/. // For `github.com/gin-gonic/gin@v1.9.1`, the encoded module path is // the same string (no uppercase letters to escape). - let module_dir = tmp - .path() - .join("github.com/gin-gonic/gin@v1.9.1"); + let module_dir = tmp.path().join("github.com/gin-gonic/gin@v1.9.1"); std::fs::create_dir_all(&module_dir).unwrap(); let gin_file = module_dir.join("gin.go"); let original = b"package gin\n\nfunc Version() string { return \"1.9.1\" }\n"; @@ -209,15 +209,20 @@ async fn golang_handcrafted_install_apply_patches_file() { // A single free patch that downloads + applies cleanly must exit 0. // `download_and_apply_patches` only returns 1 when a patch fails to // download or apply, so 1 here means the apply path silently broke. - assert_eq!(code, 0, "scan --sync should fully apply the golang patch (exit 0)"); + assert_eq!( + code, 0, + "scan --sync should fully apply the golang patch (exit 0)" + ); // Golden check: the file must equal the EXACT patched bytes the mock // served, not merely contain the marker substring (a corrupting apply // could append the marker while mangling the rest). let after = std::fs::read(&gin_file).expect("read after"); assert_eq!( - after, patched, - "patched {} bytes do not match the served blob exactly", gin_file.display() + after, + patched, + "patched {} bytes do not match the served blob exactly", + gin_file.display() ); std::env::remove_var("GOMODCACHE"); @@ -234,8 +239,7 @@ async fn maven_handcrafted_install_apply_patches_file() { let tmp = tempfile::tempdir().expect("tempdir"); // m2 layout: $repo/org/apache/commons/commons-lang3/3.12.0/ let repo = tmp.path().join("m2-repo"); - let version_dir = repo - .join("org/apache/commons/commons-lang3/3.12.0"); + let version_dir = repo.join("org/apache/commons/commons-lang3/3.12.0"); std::fs::create_dir_all(&version_dir).unwrap(); // The maven crawler verifies presence of a .pom file. Without it, // the version dir is ignored. @@ -273,12 +277,17 @@ async fn maven_handcrafted_install_apply_patches_file() { let args = default_scan_args(tmp.path(), "maven", server.uri()); let code = scan_run(args).await; - assert_eq!(code, 0, "scan --sync should fully apply the maven patch (exit 0)"); + assert_eq!( + code, 0, + "scan --sync should fully apply the maven patch (exit 0)" + ); let after = std::fs::read(&payload_file).expect("read after"); assert_eq!( - after, patched, - "patched {} bytes do not match the served blob exactly", payload_file.display() + after, + patched, + "patched {} bytes do not match the served blob exactly", + payload_file.display() ); std::env::remove_var("MAVEN_REPO_LOCAL"); @@ -345,7 +354,9 @@ async fn maven_multi_classifier_patches_every_present_jar() { .mount(&server) .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [ { "uuid": uuid_a, "purl": purl_a, "publishedAt": "2024-01-01T00:00:00Z", @@ -461,12 +472,17 @@ async fn composer_handcrafted_install_apply_patches_file() { let mut args = default_scan_args(tmp.path(), "composer", server.uri()); args.common.global = false; let code = scan_run(args).await; - assert_eq!(code, 0, "scan --sync should fully apply the composer patch (exit 0)"); + assert_eq!( + code, 0, + "scan --sync should fully apply the composer patch (exit 0)" + ); let after = std::fs::read(&payload).expect("read after"); assert_eq!( - after, patched, - "patched {} bytes do not match the served blob exactly", payload.display() + after, + patched, + "patched {} bytes do not match the served blob exactly", + payload.display() ); } @@ -518,12 +534,17 @@ async fn nuget_handcrafted_install_apply_patches_file() { let args = default_scan_args(tmp.path(), "nuget", server.uri()); let code = scan_run(args).await; - assert_eq!(code, 0, "scan --sync should fully apply the nuget patch (exit 0)"); + assert_eq!( + code, 0, + "scan --sync should fully apply the nuget patch (exit 0)" + ); let after = std::fs::read(&payload).expect("read after"); assert_eq!( - after, patched, - "patched {} bytes do not match the served blob exactly", payload.display() + after, + patched, + "patched {} bytes do not match the served blob exactly", + payload.display() ); std::env::remove_var("NUGET_PACKAGES"); diff --git a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs index f471e12..2b65359 100644 --- a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs +++ b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs @@ -25,7 +25,11 @@ fn git_sha256(content: &[u8]) -> String { } fn write_root(cwd: &Path) { - std::fs::write(cwd.join("package.json"), r#"{"name":"r","version":"0.0.0"}"#).unwrap(); + std::fs::write( + cwd.join("package.json"), + r#"{"name":"r","version":"0.0.0"}"#, + ) + .unwrap(); } fn write_npm_pkg(cwd: &Path, name: &str, version: &str, file: &str, content: &[u8]) { @@ -174,7 +178,11 @@ async fn remove_by_uuid_finds_correct_purl() { .unwrap(); let patches = m["patches"].as_object().unwrap(); // Exactly the uuid-matched purl is gone; the decoy survives intact. - assert_eq!(patches.len(), 1, "only the uuid-matched entry must be removed"); + assert_eq!( + patches.len(), + 1, + "only the uuid-matched entry must be removed" + ); assert!( !patches.contains_key("pkg:npm/uuid-remove@1.0.0"), "the entry whose uuid matched the identifier must be removed" @@ -226,7 +234,11 @@ async fn remove_no_matching_purl_exits_not_found() { serde_json::from_str(&std::fs::read_to_string(socket.join("manifest.json")).unwrap()) .unwrap(); let patches = m["patches"].as_object().unwrap(); - assert_eq!(patches.len(), 1, "a non-matching identifier must remove nothing"); + assert_eq!( + patches.len(), + 1, + "a non-matching identifier must remove nothing" + ); assert!(patches.contains_key("pkg:npm/bystander@1.0.0")); } @@ -502,7 +514,10 @@ async fn repair_file_mode_downloads_individual_blobs() { // Content-addressed: the stored blob must contain exactly the served // bytes, and re-hashing it must reproduce the manifest's afterHash. let stored = std::fs::read(&blob_path).unwrap(); - assert_eq!(stored, blob_content, "stored blob bytes must match served body"); + assert_eq!( + stored, blob_content, + "stored blob bytes must match served body" + ); assert_eq!( git_sha256(&stored), after_hash, @@ -589,7 +604,11 @@ async fn repair_dry_run_does_not_download() { .await .unwrap() .into_iter() - .filter(|r| r.url.path().starts_with(&format!("/v0/orgs/{ORG}/patches/"))) + .filter(|r| { + r.url + .path() + .starts_with(&format!("/v0/orgs/{ORG}/patches/")) + }) .count(); assert_eq!( hits, 0, diff --git a/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs b/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs index c30c5ed..f07f04b 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs @@ -90,7 +90,7 @@ fn default_rollback_args(cwd: &Path, eco: &str) -> RollbackArgs { global: false, global_prefix: None, org: None, - api_token: None, + api_token: None, ecosystems: Some(vec![eco.to_string()]), json: true, verbose: false, @@ -234,10 +234,7 @@ async fn rollback_pypi_restores_original_content() { let code = rollback_run(default_rollback_args(tmp.path(), "pypi")).await; assert_eq!(code, 0, "pypi rollback must report success (exit 0)"); let after = std::fs::read(pkg_dir.join("__init__.py")).unwrap(); - assert_eq!( - after, original, - "pypi rollback must restore original bytes" - ); + assert_eq!(after, original, "pypi rollback must restore original bytes"); } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index 498e93b..d40eb02 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -26,7 +26,7 @@ fn default_args(cwd: &Path) -> ScanArgs { yes: true, global: false, global_prefix: None, - api_token: Some("fake".to_string()), + api_token: Some("fake".to_string()), ecosystems: None, download_mode: "diff".to_string(), dry_run: false, @@ -100,7 +100,9 @@ async fn mock_batch_one(server: &MockServer) { async fn mock_by_package(server: &MockServer) { Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": PURL, @@ -153,7 +155,9 @@ fn batch_posts(reqs: &[wiremock::Request]) -> Vec<&wiremock::Request> { fn by_package_gets(reqs: &[wiremock::Request]) -> usize { reqs.iter() - .filter(|r| format!("{}", r.method) == "GET" && r.url.path().contains("/patches/by-package/")) + .filter(|r| { + format!("{}", r.method) == "GET" && r.url.path().contains("/patches/by-package/") + }) .count() } @@ -284,7 +288,10 @@ async fn scan_apply_wet_writes_manifest_and_blob() { // partial_failure (exit 1): the on-disk "package/index.js" doesn't // match the fixture's beforeHash, so the patch can't be applied. The // download stage still ran, though — that's what we verify. - assert_eq!(code, 1, "apply over a hash-mismatched file must partial-fail"); + assert_eq!( + code, 1, + "apply over a hash-mismatched file must partial-fail" + ); // The view endpoint (which carries the blob) must have been hit. let reqs = recorded(&server).await; @@ -359,7 +366,11 @@ async fn scan_prune_only_dry_run_reports_orphans() { let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); let manifest: serde_json::Value = serde_json::from_str(&body).unwrap(); let patches = manifest["patches"].as_object().unwrap(); - assert_eq!(patches.len(), 1, "dry-run prune must not mutate the manifest"); + assert_eq!( + patches.len(), + 1, + "dry-run prune must not mutate the manifest" + ); assert!( patches.contains_key("pkg:npm/stale@1.0.0"), "stale entry must be preserved by a dry-run prune; got {manifest}" @@ -446,7 +457,10 @@ async fn scan_sync_full_cycle_against_clean_project() { let code = run(args).await; // --sync == --apply --prune; apply over the hash-mismatched fixture file // deterministically partial-fails (exit 1) just like the apply-wet case. - assert_eq!(code, 1, "sync over a hash-mismatched file must partial-fail"); + assert_eq!( + code, 1, + "sync over a hash-mismatched file must partial-fail" + ); // The full apply pipeline ran: view fetched, manifest written with the // package, and the after-blob persisted with the exact decoded bytes. @@ -507,14 +521,20 @@ async fn scan_small_batch_size_chunks_requests() { .iter() .filter(|n| body.contains(*n)) .count(); - assert_eq!(hits, 1, "each chunk must carry exactly one package; body={body}"); + assert_eq!( + hits, 1, + "each chunk must carry exactly one package; body={body}" + ); for (i, n) in ["pkg-a", "pkg-b", "pkg-c"].iter().enumerate() { if body.contains(n) { covered[i] = true; } } } - assert!(covered.iter().all(|c| *c), "all three packages must be queried"); + assert!( + covered.iter().all(|c| *c), + "all three packages must be queried" + ); } // --------------------------------------------------------------------------- @@ -651,7 +671,11 @@ async fn scan_api_500_does_not_panic() { // Real path actually executed: the batch endpoint was queried (and 500'd) // and no spurious manifest was written. let reqs = recorded(&server).await; - assert_eq!(batch_posts(&reqs).len(), 1, "the batch endpoint must be queried"); + assert_eq!( + batch_posts(&reqs).len(), + 1, + "the batch endpoint must be queried" + ); assert!( !tmp.path().join(".socket/manifest.json").exists(), "a fully-failed scan must not write a manifest" @@ -690,7 +714,6 @@ async fn scan_unreachable_api_does_not_panic() { ); } - // --------------------------------------------------------------------------- // Regression: --batch-size 0 must not panic // --------------------------------------------------------------------------- @@ -718,7 +741,11 @@ async fn scan_batch_size_zero_does_not_panic() { assert_eq!(run(args).await, 0); let reqs = recorded(&server).await; let posts = batch_posts(&reqs); - assert_eq!(posts.len(), 1, "batch must still be queried with a clamped size"); + assert_eq!( + posts.len(), + 1, + "batch must still be queried with a clamped size" + ); assert!( req_body(posts[0]).contains(PURL), "the discovered purl must be sent even with --batch-size 0" @@ -804,7 +831,11 @@ async fn scan_prune_with_ecosystem_filter_keeps_other_ecosystem() { patches.contains_key("pkg:gem/live-gem@2.0.0"), "an installed package of a filtered-OUT ecosystem must NOT be pruned; got {m}" ); - assert_eq!(patches.len(), 2, "exactly the orphan should be removed; got {m}"); + assert_eq!( + patches.len(), + 2, + "exactly the orphan should be removed; got {m}" + ); } // --------------------------------------------------------------------------- @@ -851,7 +882,10 @@ async fn scan_non_json_dry_run_does_not_mutate() { // Manifest is byte-for-byte unchanged: neither the apply nor the prune // GC touched it. let after = std::fs::read_to_string(socket.join("manifest.json")).unwrap(); - assert_eq!(after, before, "non-JSON dry-run must not mutate the manifest"); + assert_eq!( + after, before, + "non-JSON dry-run must not mutate the manifest" + ); assert!( !socket.join("blobs").exists(), "non-JSON dry-run must not download/write blobs" diff --git a/crates/socket-patch-cli/tests/in_process_variant_apply_failure.rs b/crates/socket-patch-cli/tests/in_process_variant_apply_failure.rs index 0b22f8e..54650d5 100644 --- a/crates/socket-patch-cli/tests/in_process_variant_apply_failure.rs +++ b/crates/socket-patch-cli/tests/in_process_variant_apply_failure.rs @@ -365,7 +365,11 @@ fn partial_multi_variant_failure_fails_the_command() { 1, "expected exactly one applied variant: {out}" ); - assert_eq!(failed.len(), 1, "expected exactly one failed variant: {out}"); + assert_eq!( + failed.len(), + 1, + "expected exactly one failed variant: {out}" + ); assert_eq!(applied[0]["purl"], serde_json::Value::String(variant_a)); assert_eq!(failed[0]["purl"], serde_json::Value::String(variant_b)); diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 09c4967..3173533 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -79,8 +79,9 @@ impl NpmFixture { self.root().join(rel_tgz()) } fn marker_path(&self) -> PathBuf { - self.root() - .join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) + self.root().join(format!( + ".socket/vendor/npm/{UUID}/socket-patch.vendor.json" + )) } fn state_path(&self) -> PathBuf { self.root().join(".socket/vendor/state.json") @@ -228,7 +229,13 @@ fn run_cli(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> (i32, Strin /// `vendor --json --offline --cwd ` through the binary, /// returning `(exit_code, parsed envelope)`. fn vendor_cli(cwd: &Path, extra: &[&str]) -> (i32, Value) { - let mut args = vec!["vendor", "--json", "--offline", "--cwd", cwd.to_str().unwrap()]; + let mut args = vec![ + "vendor", + "--json", + "--offline", + "--cwd", + cwd.to_str().unwrap(), + ]; args.extend_from_slice(extra); let (code, stdout, stderr) = run_cli(cwd, &args, &[]); let env: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { @@ -246,10 +253,7 @@ fn events(envelope: &Value) -> &Vec { fn find_event<'a>(envelope: &'a Value, action: &str, error_code: Option<&str>) -> &'a Value { events(envelope) .iter() - .find(|e| { - e["action"] == action - && error_code.is_none_or(|c| e["errorCode"] == c) - }) + .find(|e| e["action"] == action && error_code.is_none_or(|c| e["errorCode"] == c)) .unwrap_or_else(|| { panic!("expected a `{action}` event (errorCode={error_code:?}) in:\n{envelope:#}") }) @@ -347,7 +351,10 @@ async fn rerun_is_idempotent() { let (code, env) = vendor_cli(fx.root(), &[]); assert_eq!(code, 0, "re-run must exit 0: {env:#}"); assert_eq!(env["status"], "success"); - assert_eq!(env["summary"]["applied"], 0, "nothing newly applied: {env:#}"); + assert_eq!( + env["summary"]["applied"], 0, + "nothing newly applied: {env:#}" + ); assert_eq!(env["summary"]["failed"], 0); assert_eq!(env["summary"]["skipped"], 1); // The in-sync re-run synthesizes its result against the vendored @@ -485,15 +492,14 @@ async fn unsupported_ecosystem_purl_is_currently_dropped_silently() { assert_eq!(env["summary"]["applied"], 1); // The compiled-out purl vanishes without a trace (the gap being pinned). assert!( - !events(&env).iter().any(|e| { - e["purl"].as_str().is_some_and(|p| p.contains("nuget")) - }), + !events(&env) + .iter() + .any(|e| { e["purl"].as_str().is_some_and(|p| p.contains("nuget")) }), "current behavior: no event for the compiled-out nuget purl: {env:#}" ); assert!(fx.tgz_path().is_file(), "the npm patch still vendors"); } - // ───────────────────────────────────────────────────────────────────── // 7. package not installed // ───────────────────────────────────────────────────────────────────── @@ -505,7 +511,10 @@ async fn package_not_installed_fails() { // failure (exit 1), surfaced as a skipped event with the stable code. let fx = npm_fixture_with_purls(&["pkg:npm/ghost-pkg@9.9.9"]); let (code, env) = vendor_cli(fx.root(), &[]); - assert_eq!(code, 1, "an unsatisfiable manifest entry must exit 1: {env:#}"); + assert_eq!( + code, 1, + "an unsatisfiable manifest entry must exit 1: {env:#}" + ); assert_eq!(env["status"], "partialFailure"); let skipped = find_event(&env, "skipped", Some("package_not_installed")); assert_eq!(skipped["purl"], "pkg:npm/ghost-pkg@9.9.9"); @@ -771,7 +780,10 @@ fn lock_contention_exits_lock_held() { let (code, env) = vendor_cli(fx.root(), &["--lock-timeout", "1"]); assert_eq!(code, 1, "contended vendor must exit 1: {env:#}"); - assert_eq!(env["command"], "vendor", "the failure envelope is vendor's own"); + assert_eq!( + env["command"], "vendor", + "the failure envelope is vendor's own" + ); assert_eq!(env["status"], "error"); assert_eq!(env["error"]["code"], "lock_held"); assert!( @@ -780,7 +792,10 @@ fn lock_contention_exits_lock_held() { ); // Nothing happened while contended. - assert!(!fx.vendor_dir().exists(), "no vendor writes under contention"); + assert!( + !fx.vendor_dir().exists(), + "no vendor writes under contention" + ); assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); } @@ -815,7 +830,10 @@ fn json_envelope_shape() { "removed", "verified", ] { - assert!(summary.contains_key(field), "summary.{field} present: {env:#}"); + assert!( + summary.contains_key(field), + "summary.{field} present: {env:#}" + ); } assert_eq!(env["summary"]["applied"], 1); assert_eq!(env["summary"]["failed"], 0); diff --git a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs index f3744f1..77ccb3a 100644 --- a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs +++ b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs @@ -122,12 +122,7 @@ fn setup_interactive_y_proceeds_with_update() { // Without --yes, setup prompts "Proceed with these changes? (y/N): ". // Sending "y\n" should make it proceed with the update. - let (code, output) = run_in_pty( - &["setup"], - tmp.path(), - "y\n", - Duration::from_secs(15), - ); + let (code, output) = run_in_pty(&["setup"], tmp.path(), "y\n", Duration::from_secs(15)); assert_eq!(code, 0, "setup with 'y' must succeed"); // The interactive prompt MUST have actually run — otherwise this test @@ -165,12 +160,7 @@ fn setup_interactive_n_aborts_without_update() { "#; std::fs::write(tmp.path().join("package.json"), original).unwrap(); - let (code, output) = run_in_pty( - &["setup"], - tmp.path(), - "n\n", - Duration::from_secs(15), - ); + let (code, output) = run_in_pty(&["setup"], tmp.path(), "n\n", Duration::from_secs(15)); assert_eq!(code, 0, "setup with 'n' must exit cleanly"); // The interactive prompt MUST have run, then aborted. assert!( @@ -204,12 +194,7 @@ fn setup_interactive_default_no_aborts() { "#; std::fs::write(tmp.path().join("package.json"), original).unwrap(); - let (code, output) = run_in_pty( - &["setup"], - tmp.path(), - "\n", - Duration::from_secs(15), - ); + let (code, output) = run_in_pty(&["setup"], tmp.path(), "\n", Duration::from_secs(15)); assert_eq!(code, 0); // The prompt MUST have run; bare Enter must hit the default-N abort. // Without these, the test passes vacuously if setup never prompts and @@ -264,7 +249,11 @@ fn remove_interactive_y_proceeds() { write_remove_manifest(tmp.path()); let (code, output) = run_in_pty( - &["remove", "pkg:npm/__interactive_remove__@1.0.0", "--skip-rollback"], + &[ + "remove", + "pkg:npm/__interactive_remove__@1.0.0", + "--skip-rollback", + ], tmp.path(), "y\n", Duration::from_secs(15), @@ -296,7 +285,10 @@ fn remove_interactive_y_proceeds() { let patches = manifest["patches"] .as_object() .unwrap_or_else(|| panic!("manifest must keep a 'patches' object; got: {body}")); - assert!(patches.is_empty(), "remove 'y' must drop the entry; got: {body}"); + assert!( + patches.is_empty(), + "remove 'y' must drop the entry; got: {body}" + ); } #[test] @@ -305,7 +297,11 @@ fn remove_interactive_n_cancels() { write_remove_manifest(tmp.path()); let (code, output) = run_in_pty( - &["remove", "pkg:npm/__interactive_remove__@1.0.0", "--skip-rollback"], + &[ + "remove", + "pkg:npm/__interactive_remove__@1.0.0", + "--skip-rollback", + ], tmp.path(), "n\n", Duration::from_secs(15), @@ -359,12 +355,7 @@ fn remove_interactive_n_cancels() { #[test] fn apply_in_pty_with_no_manifest_prints_friendly_message() { let tmp = tempfile::tempdir().unwrap(); - let (code, output) = run_in_pty( - &["apply"], - tmp.path(), - "", - Duration::from_secs(15), - ); + let (code, output) = run_in_pty(&["apply"], tmp.path(), "", Duration::from_secs(15)); assert_eq!(code, 0); // Assert the full message, not either half of it. The `||` previously // let a truncated/garbled message ("...skipping...") pass. diff --git a/crates/socket-patch-cli/tests/output_helpers_e2e.rs b/crates/socket-patch-cli/tests/output_helpers_e2e.rs index a68677f..f26d5a0 100644 --- a/crates/socket-patch-cli/tests/output_helpers_e2e.rs +++ b/crates/socket-patch-cli/tests/output_helpers_e2e.rs @@ -44,7 +44,10 @@ fn format_severity_low_wraps_in_cyan() { fn format_severity_unknown_passes_through_unwrapped() { // The `_` arm returns the input verbatim — no ANSI wrapper. let out = format_severity("nonsense", true); - assert!(!out.contains("\x1b["), "unknown severity must not wrap: {out:?}"); + assert!( + !out.contains("\x1b["), + "unknown severity must not wrap: {out:?}" + ); assert_eq!(out, "nonsense"); } @@ -112,7 +115,10 @@ fn select_one_empty_options_does_not_yield_out_of_bounds_index() { // any stdin read, so this is safe under both TTY and non-TTY. let empty: Vec = Vec::new(); assert!( - matches!(select_one("pick", &empty, false), Err(SelectError::Cancelled)), + matches!( + select_one("pick", &empty, false), + Err(SelectError::Cancelled) + ), "empty non-JSON select must be Cancelled" ); // JSON mode is still decided first. diff --git a/crates/socket-patch-cli/tests/output_modes_e2e.rs b/crates/socket-patch-cli/tests/output_modes_e2e.rs index ea1e6e5..e4c2850 100644 --- a/crates/socket-patch-cli/tests/output_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/output_modes_e2e.rs @@ -110,16 +110,11 @@ fn apply_non_json_prints_human_readable_summary() { "non-JSON apply should print the patch-count summary; got: {stdout}" ); assert!( - stdout.contains("Patched packages:") - && stdout.contains("pkg:npm/non-json-target@1.0.0"), + stdout.contains("Patched packages:") && stdout.contains("pkg:npm/non-json-target@1.0.0"), "non-JSON apply should list the patched PURL; got: {stdout}" ); // The summary is only honest if the file was actually rewritten. - let patched = std::fs::read( - tmp.path() - .join("node_modules/non-json-target/index.js"), - ) - .unwrap(); + let patched = std::fs::read(tmp.path().join("node_modules/non-json-target/index.js")).unwrap(); assert_eq!( patched, after, "apply must rewrite the target file to the patched content" @@ -163,8 +158,7 @@ fn apply_verbose_prints_per_file_details() { ); // The verbose block must describe real work: confirm the file was // actually rewritten, so a no-op apply that merely prints the block fails. - let patched = - std::fs::read(tmp.path().join("node_modules/verbose-target/index.js")).unwrap(); + let patched = std::fs::read(tmp.path().join("node_modules/verbose-target/index.js")).unwrap(); assert_eq!( patched, after, "--verbose apply must still rewrite the target file" @@ -294,11 +288,7 @@ fn list_empty_manifest_non_json() { let tmp = tempfile::tempdir().unwrap(); let socket = tmp.path().join(".socket"); std::fs::create_dir_all(&socket).unwrap(); - std::fs::write( - socket.join("manifest.json"), - r#"{"patches":{}}"#, - ) - .unwrap(); + std::fs::write(socket.join("manifest.json"), r#"{"patches":{}}"#).unwrap(); let out = Command::new(binary()) .args(["list"]) @@ -383,7 +373,10 @@ fn repair_non_json_no_orphans_prints_summary() { let before_blob = blobs.join(git_sha256(b"a")); let after_blob = blobs.join(git_sha256(b"b")); std::fs::remove_file(&before_blob).unwrap(); - assert!(after_blob.exists(), "fixture precondition: afterHash blob present"); + assert!( + after_blob.exists(), + "fixture precondition: afterHash blob present" + ); let out = Command::new(binary()) .args(["repair", "--offline"]) @@ -424,7 +417,10 @@ fn repair_non_json_with_orphans_prints_cleanup_summary() { std::fs::write(&orphan, b"orphan").unwrap(); // The in-use blob that MUST survive the cleanup: the afterHash content. let after_blob = blobs.join(git_sha256(b"b")); - assert!(after_blob.exists(), "fixture precondition: afterHash blob present"); + assert!( + after_blob.exists(), + "fixture precondition: afterHash blob present" + ); let out = Command::new(binary()) .args(["repair", "--offline"]) @@ -470,7 +466,12 @@ fn remove_non_json_prints_what_will_be_removed() { write_manifest(tmp.path(), "pkg:npm/remove-target@1.0.0", b"a", b"b"); let out = Command::new(binary()) - .args(["remove", "pkg:npm/remove-target@1.0.0", "--yes", "--skip-rollback"]) + .args([ + "remove", + "pkg:npm/remove-target@1.0.0", + "--yes", + "--skip-rollback", + ]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") .output() @@ -484,8 +485,7 @@ fn remove_non_json_prints_what_will_be_removed() { ); // The confirmation is only meaningful if the manifest was actually // rewritten to drop the patch. - let manifest = - std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); + let manifest = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); assert!( !manifest.contains("pkg:npm/remove-target@1.0.0"), "remove must delete the patch from the manifest; got: {manifest}" @@ -554,8 +554,7 @@ fn rollback_verbose_prints_per_file_details() { // The detail block must reflect real work: the file must actually be // restored to its pre-patch ("before") content, so a no-op rollback that // only prints the block fails here. - let restored = - std::fs::read(tmp.path().join("node_modules/rb-verbose/index.js")).unwrap(); + let restored = std::fs::read(tmp.path().join("node_modules/rb-verbose/index.js")).unwrap(); assert_eq!( restored, before, "verbose rollback must restore the file to its pre-patch content" @@ -596,7 +595,10 @@ fn get_non_json_invalid_uuid_falls_through_to_package_search() { // including the binary mis-routing to a vuln lookup. Assert the // fall-through actually happened: with no installed packages it // short-circuits cleanly (exit 0) after announcing the search. - assert_eq!(code, 0, "package-name fall-through should exit cleanly; stdout={stdout}"); + assert_eq!( + code, 0, + "package-name fall-through should exit cleanly; stdout={stdout}" + ); assert!( stdout.contains("as a package name search"), "get with a bare identifier must fall through to package-name search; got: {stdout}" @@ -634,7 +636,10 @@ fn get_with_explicit_cve_flag_works() { let stdout = String::from_utf8_lossy(&out.stdout); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("must emit parseable JSON"); - assert_eq!(v["status"], "error", "must report a structured error; got: {stdout}"); + assert_eq!( + v["status"], "error", + "must report a structured error; got: {stdout}" + ); let err = v["error"].as_str().unwrap_or_default(); assert!( err.contains("by-cve/CVE-2099-99999"), @@ -704,7 +709,10 @@ fn get_with_explicit_package_flag_works() { // emits the structured "no_packages" JSON. The old `0 || 1` would have // accepted a crash or a misrouted vuln lookup. let code = out.status.code().unwrap_or(-1); - assert_eq!(code, 0, "package search with no packages should exit cleanly"); + assert_eq!( + code, 0, + "package search with no packages should exit cleanly" + ); let stdout = String::from_utf8_lossy(&out.stdout); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("must emit parseable JSON"); @@ -830,11 +838,19 @@ fn each_subcommand_help_prints_usage() { #[test] fn top_level_help_prints_all_subcommands() { - let out = Command::new(binary()).args(["--help"]).output().expect("run"); + let out = Command::new(binary()) + .args(["--help"]) + .output() + .expect("run"); assert_eq!(out.status.code(), Some(0)); let stdout = String::from_utf8_lossy(&out.stdout); - for sub in ["apply", "rollback", "get", "scan", "list", "remove", "setup", "repair"] { - assert!(stdout.contains(sub), "top-level help missing {sub}; got: {stdout}"); + for sub in [ + "apply", "rollback", "get", "scan", "list", "remove", "setup", "repair", + ] { + assert!( + stdout.contains(sub), + "top-level help missing {sub}; got: {stdout}" + ); } // `gc` is the visible alias. assert!(stdout.contains("gc"), "top-level help missing `gc` alias"); @@ -842,7 +858,10 @@ fn top_level_help_prints_all_subcommands() { #[test] fn version_flag_prints_version() { - let out = Command::new(binary()).args(["--version"]).output().expect("run"); + let out = Command::new(binary()) + .args(["--version"]) + .output() + .expect("run"); assert_eq!(out.status.code(), Some(0)); let stdout = String::from_utf8_lossy(&out.stdout); // Derive the expected version from the crate metadata at compile time diff --git a/crates/socket-patch-cli/tests/remove_invariants.rs b/crates/socket-patch-cli/tests/remove_invariants.rs index cc568db..fe2a593 100644 --- a/crates/socket-patch-cli/tests/remove_invariants.rs +++ b/crates/socket-patch-cli/tests/remove_invariants.rs @@ -106,7 +106,10 @@ fn remove_with_unknown_identifier_emits_not_found() { assert_eq!(v["status"], "notFound"); assert_eq!(v["error"]["code"], "not_found"); if let Some(summary) = v.get("summary") { - assert_eq!(summary["removed"], 0, "a not-found remove must report 0 removed"); + assert_eq!( + summary["removed"], 0, + "a not-found remove must report 0 removed" + ); } // A no-match remove must leave BOTH existing entries in place and must @@ -118,7 +121,10 @@ fn remove_with_unknown_identifier_emits_not_found() { assert!(patches.contains_key("pkg:npm/__remove_test_a__@1.0.0")); assert!(patches.contains_key("pkg:npm/__remove_test_b__@2.0.0")); let after = std::fs::read(socket.join("manifest.json")).expect("read after"); - assert_eq!(before, after, "a no-op remove must not rewrite the manifest file"); + assert_eq!( + before, after, + "a no-op remove must not rewrite the manifest file" + ); } #[test] @@ -136,7 +142,9 @@ fn remove_with_invalid_manifest_emits_error() { // A parse failure must be distinguished from a missing manifest, otherwise // a broken loader could silently treat corrupt JSON as "not found". assert_eq!(v["error"]["code"], "manifest_unreadable"); - let msg = v["error"]["message"].as_str().expect("error message string"); + let msg = v["error"]["message"] + .as_str() + .expect("error message string"); assert!( msg.contains("parse") || msg.contains("JSON"), "error message should explain the parse failure; got: {msg}" @@ -282,7 +290,10 @@ fn remove_without_skip_rollback_fails_closed_and_keeps_manifest() { v["error"]["code"], "rollback_failed", "remove must surface the rollback failure, not a generic error" ); - assert_eq!(v["summary"]["removed"], 0, "nothing removed when rollback fails"); + assert_eq!( + v["summary"]["removed"], 0, + "nothing removed when rollback fails" + ); // The crucial invariant: the manifest is byte-for-byte unchanged. The // entry the user asked to remove is still present because its files could @@ -364,8 +375,14 @@ fn remove_blob_sweep_does_not_inflate_removed_count() { // B's afterHash blob is still referenced, so it must survive on disk; // A's must be gone. - assert!(!blobs.join(AFTER_A).exists(), "A's orphaned blob must be swept"); - assert!(blobs.join(AFTER_B).exists(), "B's referenced blob must remain"); + assert!( + !blobs.join(AFTER_A).exists(), + "A's orphaned blob must be swept" + ); + assert!( + blobs.join(AFTER_B).exists(), + "B's referenced blob must remain" + ); } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/remove_network.rs b/crates/socket-patch-cli/tests/remove_network.rs index eca9973..a9fb9bd 100644 --- a/crates/socket-patch-cli/tests/remove_network.rs +++ b/crates/socket-patch-cli/tests/remove_network.rs @@ -188,7 +188,9 @@ async fn remove_online_downloads_missing_before_blob_then_removes() { fetched >= 1, "online remove must fetch the missing beforeHash blob ({blob_path}); \ observed request paths={:?}", - reqs.iter().map(|r| r.url.path().to_string()).collect::>() + reqs.iter() + .map(|r| r.url.path().to_string()) + .collect::>() ); } diff --git a/crates/socket-patch-cli/tests/repair_invariants.rs b/crates/socket-patch-cli/tests/repair_invariants.rs index e6cff40..2c9239c 100644 --- a/crates/socket-patch-cli/tests/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair_invariants.rs @@ -100,8 +100,7 @@ const MANIFEST_JSON: &str = r#"{ } }"#; -const REFERENCED_HASH: &str = - "1111111111111111111111111111111111111111111111111111111111111111"; +const REFERENCED_HASH: &str = "1111111111111111111111111111111111111111111111111111111111111111"; fn make_socket_dir(root: &Path) -> PathBuf { let socket = root.join(".socket"); @@ -138,8 +137,7 @@ fn repair_with_no_manifest_emits_manifest_not_found_envelope() { let tmp = tempfile::tempdir().expect("tempdir"); let (code, stdout) = run_repair(tmp.path(), &[]); assert_eq!(code, 1, "expected exit 1; stdout=\n{stdout}"); - let v: serde_json::Value = - serde_json::from_str(&stdout).expect("envelope must be valid JSON"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("envelope must be valid JSON"); assert_eq!(v["command"], "repair"); assert_eq!(v["status"], "error"); assert_eq!(v["error"]["code"], "manifest_not_found"); @@ -196,8 +194,7 @@ fn repair_offline_and_download_only_are_mutually_exclusive() { "expected exit 2 for invalid flag combo; stdout=\n{}", String::from_utf8_lossy(&out.stdout), ); - let v: serde_json::Value = - serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); + let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); assert_eq!(v["status"], "error"); assert_eq!(v["error"]["code"], "invalid_args"); assert!( @@ -365,7 +362,13 @@ fn repair_download_only_skips_cleanup() { write_blob(&socket, &orphan_hash, b"orphaned content"); let out = socket_cmd(tmp.path()) - .args(["repair", "--json", "--download-only", "--download-mode", "file"]) + .args([ + "repair", + "--json", + "--download-only", + "--download-mode", + "file", + ]) .output() .expect("run socket-patch"); let code = out.status.code().unwrap_or(-1); @@ -416,8 +419,7 @@ fn gc_alias_behaves_identically_to_repair() { .output() .expect("run socket-patch"); assert_eq!(out.status.code(), Some(0)); - let v: serde_json::Value = - serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); + let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); // The envelope's `command` field reports the canonical name, not the alias. assert_eq!(v["command"], "repair"); assert_eq!(v["status"], "success"); @@ -532,7 +534,10 @@ async fn repair_online_downloads_missing_blob() { 1, "repair must issue exactly one GET to {blob_endpoint}; saw {} request(s): {:?}", requests.len(), - requests.iter().map(|r| r.url.path().to_string()).collect::>(), + requests + .iter() + .map(|r| r.url.path().to_string()) + .collect::>(), ); assert_eq!(format!("{}", blob_hits[0].method), "GET"); } @@ -556,8 +561,7 @@ fn repair_honors_manifest_path_override() { ctrl_code, 1, "control: repair without override must fail; stdout=\n{ctrl_stdout}" ); - let cv: serde_json::Value = - serde_json::from_str(&ctrl_stdout).expect("control envelope JSON"); + let cv: serde_json::Value = serde_json::from_str(&ctrl_stdout).expect("control envelope JSON"); assert_eq!(cv["error"]["code"], "manifest_not_found"); let out = socket_cmd(tmp.path()) @@ -577,8 +581,7 @@ fn repair_honors_manifest_path_override() { String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr), ); - let v: serde_json::Value = - serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); + let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); assert_eq!(v["command"], "repair"); assert_eq!(v["status"], "success"); // The override manifest references one blob with no blob on disk, but diff --git a/crates/socket-patch-cli/tests/rollback_invariants.rs b/crates/socket-patch-cli/tests/rollback_invariants.rs index 7576369..e165060 100644 --- a/crates/socket-patch-cli/tests/rollback_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback_invariants.rs @@ -130,7 +130,10 @@ fn rollback_one_off_without_identifier_errors() { // Without one, rollback bails with an error envelope. let tmp = tempfile::tempdir().expect("tempdir"); let (code, stdout) = run(tmp.path(), &["--json", "--one-off"]); - assert_eq!(code, 1, "--one-off w/o identifier must exit 1; stdout=\n{stdout}"); + assert_eq!( + code, 1, + "--one-off w/o identifier must exit 1; stdout=\n{stdout}" + ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "error"); let err = v["error"].as_str().expect("error message string"); @@ -146,8 +149,14 @@ fn rollback_one_off_with_identifier_reports_not_implemented() { // implemented". We pin it here so a real implementation can't land // silently without updating the contract. let tmp = tempfile::tempdir().expect("tempdir"); - let (code, stdout) = - run(tmp.path(), &["--json", "--one-off", "33333333-3333-4333-8333-333333333333"]); + let (code, stdout) = run( + tmp.path(), + &[ + "--json", + "--one-off", + "33333333-3333-4333-8333-333333333333", + ], + ); assert_eq!(code, 1, "one-off mode must exit 1 today; stdout=\n{stdout}"); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "error"); @@ -198,7 +207,10 @@ fn rollback_offline_with_missing_before_blob_partial_failure() { // aborts before crawling, so `failed` stays 0 and `results` is empty even // though the run did not succeed. Pin that exact shape so the bail can't // silently morph into either a real failure count or a spurious success. - assert_eq!(v["failed"], 0, "contentless bail records no per-package failure"); + assert_eq!( + v["failed"], 0, + "contentless bail records no per-package failure" + ); assert_eq!( v["results"].as_array().expect("results array").len(), 0, @@ -222,7 +234,10 @@ fn rollback_with_no_installed_packages_succeeds_quietly() { std::fs::write(blobs.join(before_hash), b"original content").unwrap(); let (code, stdout) = run(tmp.path(), &["--json"]); - assert_eq!(code, 0, "no installed packages must exit 0; stdout=\n{stdout}"); + assert_eq!( + code, 0, + "no installed packages must exit 0; stdout=\n{stdout}" + ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "success"); assert_eq!(v["rolledBack"], 0); @@ -340,14 +355,18 @@ fn rollback_restores_file_to_before_content() { let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); assert_eq!( - code, 0, + code, + 0, "rollback must succeed; stdout={stdout}; stderr={}", String::from_utf8_lossy(&out.stderr) ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success"); assert_eq!(v["rolledBack"], 1); - assert_eq!(v["failed"], 0, "no file should fail to roll back; stdout={stdout}"); + assert_eq!( + v["failed"], 0, + "no file should fail to roll back; stdout={stdout}" + ); assert_eq!(v["alreadyOriginal"], 0, "file was patched, not original"); assert_eq!(v["dryRun"], false, "live rollback, not dry-run"); // The single result must name our package and actually list the restored file. @@ -441,7 +460,10 @@ fn rollback_already_original_skips_work() { assert_eq!(v["status"], "success", "stdout={stdout}"); assert_eq!(v["alreadyOriginal"], 1); assert_eq!(v["rolledBack"], 0); - assert_eq!(v["failed"], 0, "no-op must not record a failure; stdout={stdout}"); + assert_eq!( + v["failed"], 0, + "no-op must not record a failure; stdout={stdout}" + ); assert_eq!(v["dryRun"], false); // The package must actually be discovered and reported as already-original, @@ -455,12 +477,17 @@ fn rollback_already_original_skips_work() { assert_eq!(entry["success"], true); // Nothing was rewritten, so filesRolledBack must be empty... assert_eq!( - entry["filesRolledBack"].as_array().expect("filesRolledBack array").len(), + entry["filesRolledBack"] + .as_array() + .expect("filesRolledBack array") + .len(), 0, "already-original package must roll back zero files; stdout={stdout}" ); // ...and the file must be verified as already at its original state. - let verified = entry["filesVerified"].as_array().expect("filesVerified array"); + let verified = entry["filesVerified"] + .as_array() + .expect("filesVerified array"); let file = verified .iter() .find(|f| f["file"] == "package/index.js") @@ -553,7 +580,10 @@ fn rollback_dry_run_does_not_modify_file() { .iter() .find(|r| r["purl"] == "pkg:npm/dry-target@1.0.0") .unwrap_or_else(|| panic!("dry-run must discover the installed package; stdout={stdout}")); - assert_eq!(entry["success"], true, "discovered package entry must be success"); + assert_eq!( + entry["success"], true, + "discovered package entry must be success" + ); let verified = entry["filesVerified"] .as_array() .expect("filesVerified array"); diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index 627d49a..8b3045e 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -25,9 +25,7 @@ const ORG_SLUG: &str = "test-org"; fn write_npm_package(root: &Path, name: &str, version: &str) { let pkg_dir = root.join("node_modules").join(name); std::fs::create_dir_all(&pkg_dir).expect("create pkg dir"); - let pkg_json = format!( - r#"{{ "name": "{name}", "version": "{version}" }}"# - ); + let pkg_json = format!(r#"{{ "name": "{name}", "version": "{version}" }}"#); std::fs::write(pkg_dir.join("package.json"), pkg_json).expect("write pkg json"); } @@ -367,7 +365,10 @@ async fn scan_without_prune_omits_gc_field() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &[]); - assert_eq!(code, 0, "scan must succeed; stdout={stdout}; stderr={stderr}"); + assert_eq!( + code, 0, + "scan must succeed; stdout={stdout}; stderr={stderr}" + ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success"); assert!( @@ -434,11 +435,8 @@ async fn scan_apply_dry_run_with_empty_manifest_emits_added_action() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2"); - let (code, stdout, stderr) = run_scan( - tmp.path(), - &mock.uri(), - &["--apply", "--dry-run", "--yes"], - ); + let (code, stdout, stderr) = + run_scan(tmp.path(), &mock.uri(), &["--apply", "--dry-run", "--yes"]); assert_eq!( code, 0, "scan --apply --dry-run must succeed; stdout={stdout}; stderr={stderr}" @@ -548,11 +546,7 @@ async fn scan_apply_dry_run_with_existing_uuid_emits_skipped_action() { ) .unwrap(); - let (code, stdout, _) = run_scan( - tmp.path(), - &mock.uri(), - &["--apply", "--dry-run", "--yes"], - ); + let (code, stdout, _) = run_scan(tmp.path(), &mock.uri(), &["--apply", "--dry-run", "--yes"]); assert_eq!(code, 0); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); let apply = &v["apply"]; @@ -641,11 +635,7 @@ async fn scan_apply_dry_run_with_different_uuid_emits_updated_action() { ) .unwrap(); - let (code, stdout, _) = run_scan( - tmp.path(), - &mock.uri(), - &["--apply", "--dry-run", "--yes"], - ); + let (code, stdout, _) = run_scan(tmp.path(), &mock.uri(), &["--apply", "--dry-run", "--yes"]); assert_eq!(code, 0); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); let apply = &v["apply"]; @@ -709,16 +699,13 @@ async fn scan_prune_dry_run_reports_prunable_manifest_entries() { ) .unwrap(); - let (code, stdout, stderr) = run_scan( - tmp.path(), - &mock.uri(), - &["--prune", "--dry-run", "--yes"], - ); + let (code, stdout, stderr) = + run_scan(tmp.path(), &mock.uri(), &["--prune", "--dry-run", "--yes"]); assert_eq!(code, 0, "expected exit 0; stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - let gc = v["gc"].as_object().unwrap_or_else(|| { - panic!("--prune must emit gc field; full envelope was: {v}") - }); + let gc = v["gc"] + .as_object() + .unwrap_or_else(|| panic!("--prune must emit gc field; full envelope was: {v}")); // Dry-run uses the *prunable*/* orphan* preview field names per the // CLI contract. let prunable = gc["prunableManifestEntries"] @@ -976,10 +963,9 @@ async fn scan_prune_removes_withdrawn_patch_entry() { let (code, _stdout, _stderr) = run_scan(tmp.path(), &mock.uri(), &["--prune", "--yes"]); assert_eq!(code, 0); - let manifest: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(socket.join("manifest.json")).unwrap(), - ) - .unwrap(); + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(socket.join("manifest.json")).unwrap()) + .unwrap(); assert_eq!( manifest["patches"].as_object().unwrap().len(), 0, @@ -1063,10 +1049,9 @@ async fn scan_detects_update_without_touching_existing_blobs() { // Critical: scan is read-only. The manifest still records the OLD // UUID and the marker blob is byte-for-byte unchanged. - let manifest: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(socket.join("manifest.json")).unwrap(), - ) - .unwrap(); + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(socket.join("manifest.json")).unwrap()) + .unwrap(); assert_eq!( manifest["patches"]["pkg:npm/lodash@4.17.20"]["uuid"], OLD_UUID, "scan without --apply must not rewrite the manifest" diff --git a/crates/socket-patch-cli/tests/scan_sync_e2e.rs b/crates/socket-patch-cli/tests/scan_sync_e2e.rs index 753ab51..51aad84 100644 --- a/crates/socket-patch-cli/tests/scan_sync_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_sync_e2e.rs @@ -80,7 +80,9 @@ async fn scan_sync_against_clean_project_adds_and_applies_patch() { .await; // Per-package search (scan --apply uses it) Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}"))) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, @@ -169,11 +171,24 @@ async fn scan_sync_against_clean_project_adds_and_applies_patch() { // and classify it as new (not skipped/updated). Without these a regression // that double-counts, re-uses a stale cache, or mislabels the action stays // green on `applied == 1` alone. - assert_eq!(apply["downloaded"], 1, "the new patch must be downloaded; apply={apply:?}"); - assert_eq!(apply["skipped"], 0, "nothing to skip on a fresh add; apply={apply:?}"); - assert_eq!(apply["updated"], 0, "no manifest entry existed to update; apply={apply:?}"); + assert_eq!( + apply["downloaded"], 1, + "the new patch must be downloaded; apply={apply:?}" + ); + assert_eq!( + apply["skipped"], 0, + "nothing to skip on a fresh add; apply={apply:?}" + ); + assert_eq!( + apply["updated"], 0, + "no manifest entry existed to update; apply={apply:?}" + ); let patches = apply["patches"].as_array().expect("apply.patches array"); - assert_eq!(patches.len(), 1, "exactly one patch record; apply={apply:?}"); + assert_eq!( + patches.len(), + 1, + "exactly one patch record; apply={apply:?}" + ); assert_eq!(patches[0]["purl"], purl); assert_eq!(patches[0]["uuid"], UUID); assert_eq!( @@ -184,7 +199,10 @@ async fn scan_sync_against_clean_project_adds_and_applies_patch() { // The manifest must exist AND record this exact patch/uuid. let manifest_path = tmp.path().join(".socket/manifest.json"); - assert!(manifest_path.exists(), "scan --sync must write the manifest"); + assert!( + manifest_path.exists(), + "scan --sync must write the manifest" + ); let manifest: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()) .expect("valid manifest JSON"); @@ -268,7 +286,9 @@ async fn scan_apply_with_existing_blob_uses_local_cache() { .mount(&mock) .await; Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}"))) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{encoded}" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, @@ -373,16 +393,28 @@ async fn scan_apply_with_existing_blob_uses_local_cache() { .as_object() .unwrap_or_else(|| panic!("scan --apply must emit an apply sub-object; envelope={v}")); assert_eq!(apply["found"], 1, "apply.found; apply={apply:?}"); - assert_eq!(apply["skipped"], 1, "patch must be skipped; apply={apply:?}"); - assert_eq!(apply["applied"], 0, "nothing applied on a skip; apply={apply:?}"); + assert_eq!( + apply["skipped"], 1, + "patch must be skipped; apply={apply:?}" + ); + assert_eq!( + apply["applied"], 0, + "nothing applied on a skip; apply={apply:?}" + ); assert_eq!(apply["failed"], 0, "apply.failed; apply={apply:?}"); // The defining claim of this test ("skip the blob download / use the cached // one"): a known UUID with a cached blob must NOT trigger a blob download // and must NOT update the manifest. The original test asserted neither, so // a regression that re-downloads/re-writes on every run stayed green on // `skipped == 1` alone. - assert_eq!(apply["downloaded"], 0, "a cached/known patch must not be downloaded; apply={apply:?}"); - assert_eq!(apply["updated"], 0, "a skipped patch must not update the manifest; apply={apply:?}"); + assert_eq!( + apply["downloaded"], 0, + "a cached/known patch must not be downloaded; apply={apply:?}" + ); + assert_eq!( + apply["updated"], 0, + "a skipped patch must not update the manifest; apply={apply:?}" + ); let patches = apply["patches"].as_array().expect("apply.patches array"); assert_eq!(patches.len(), 1, "apply={apply:?}"); assert_eq!(patches[0]["uuid"], UUID); @@ -413,14 +445,17 @@ async fn scan_apply_with_existing_blob_uses_local_cache() { // A skip must leave the manifest byte-identical: exactly the one pre-staged // entry under its purl with the same UUID — not duplicated, replaced, or // augmented with a second record. - let manifest_after: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(socket.join("manifest.json")).unwrap(), - ) - .expect("valid manifest JSON after skip"); + let manifest_after: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(socket.join("manifest.json")).unwrap()) + .expect("valid manifest JSON after skip"); let entries = manifest_after["patches"] .as_object() .expect("manifest patches object"); - assert_eq!(entries.len(), 1, "skip must not add/duplicate manifest entries; manifest={manifest_after}"); + assert_eq!( + entries.len(), + 1, + "skip must not add/duplicate manifest entries; manifest={manifest_after}" + ); assert_eq!( manifest_after["patches"][purl]["uuid"], UUID, "skip must preserve the original manifest UUID; manifest={manifest_after}" diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index a0ea9e0..3c6e221 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -97,8 +97,15 @@ fn setup_ecosystems_filter_scopes_work_to_named_ecosystem() { let original_requirements = "requests==2.31.0\n"; write(&proj.path().join("requirements.txt"), original_requirements); - let (code, stdout) = run(proj.path(), home.path(), &["setup", "--json", "--yes", "--ecosystems", "npm"]); - assert_eq!(code, 0, "scoped setup should still succeed; stdout=\n{stdout}"); + let (code, stdout) = run( + proj.path(), + home.path(), + &["setup", "--json", "--yes", "--ecosystems", "npm"], + ); + assert_eq!( + code, 0, + "scoped setup should still succeed; stdout=\n{stdout}" + ); // The npm side IS in scope and must be configured (proves the run happened). assert!( @@ -147,7 +154,10 @@ fn setup_check_detects_unapplied_manifest_patch() { let patched = b"patched\n"; let on_disk = b"DRIFTED-not-the-patched-content\n"; let pkg = proj.path().join("node_modules/badpkg"); - write(&pkg.join("package.json"), r#"{ "name": "badpkg", "version": "1.0.0" }"#); + write( + &pkg.join("package.json"), + r#"{ "name": "badpkg", "version": "1.0.0" }"#, + ); write(&pkg.join("index.js"), &String::from_utf8_lossy(on_disk)); write( diff --git a/crates/socket-patch-cli/tests/setup_invariants.rs b/crates/socket-patch-cli/tests/setup_invariants.rs index c5ae3a3..334220d 100644 --- a/crates/socket-patch-cli/tests/setup_invariants.rs +++ b/crates/socket-patch-cli/tests/setup_invariants.rs @@ -237,7 +237,10 @@ fn setup_detects_pnpm_from_lockfile() { r#"{ "name": "test-proj", "version": "1.0.0" } "#, ); - write(&tmp.path().join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + write( + &tmp.path().join("pnpm-lock.yaml"), + "lockfileVersion: '9.0'\n", + ); let (code, stdout) = run_setup(tmp.path(), &["--yes"]); assert_eq!(code, 0, "setup should succeed; stdout=\n{stdout}"); @@ -396,7 +399,10 @@ fn setup_malformed_package_json_reports_error_and_exits_nonzero() { write(&tmp.path().join("package.json"), "not valid json!!!"); let (code, stdout) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(code, 1, "a malformed package.json must exit non-zero; stdout=\n{stdout}"); + assert_eq!( + code, 1, + "a malformed package.json must exit non-zero; stdout=\n{stdout}" + ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!( v["status"], "error", @@ -421,7 +427,11 @@ fn setup_malformed_does_not_claim_already_configured_in_human_mode() { .output() .expect("run socket-patch"); let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!(out.status.code(), Some(1), "human mode must exit 1; stdout=\n{stdout}"); + assert_eq!( + out.status.code(), + Some(1), + "human mode must exit 1; stdout=\n{stdout}" + ); assert!( !stdout.contains("already configured with socket-patch"), "must not falsely claim everything is already configured; stdout=\n{stdout}" @@ -449,7 +459,10 @@ fn setup_dry_run_with_error_exits_nonzero() { write(&tmp.path().join("packages/a/package.json"), "{bad json"); let (code, stdout) = run_setup(tmp.path(), &["--dry-run"]); - assert_eq!(code, 1, "dry-run with an error must exit non-zero; stdout=\n{stdout}"); + assert_eq!( + code, 1, + "dry-run with an error must exit non-zero; stdout=\n{stdout}" + ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "dry_run"); assert_eq!(v["errors"], 1); @@ -457,7 +470,10 @@ fn setup_dry_run_with_error_exits_nonzero() { // dry-run must not have written anything. let root = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - assert!(!root.contains("socket-patch"), "dry-run must not modify files"); + assert!( + !root.contains("socket-patch"), + "dry-run must not modify files" + ); } #[test] @@ -473,7 +489,10 @@ fn setup_partial_failure_exits_nonzero_when_applying() { write(&tmp.path().join("packages/a/package.json"), "{bad json"); let (code, stdout) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(code, 1, "partial failure must exit non-zero; stdout=\n{stdout}"); + assert_eq!( + code, 1, + "partial failure must exit non-zero; stdout=\n{stdout}" + ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "partial_failure"); assert_eq!(v["updated"], 1); @@ -481,7 +500,10 @@ fn setup_partial_failure_exits_nonzero_when_applying() { // The valid root file should have been written. let root = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - assert!(root.contains("socket-patch"), "valid file should still be updated"); + assert!( + root.contains("socket-patch"), + "valid file should still be updated" + ); } // --------------------------------------------------------------------------- @@ -498,13 +520,19 @@ fn setup_check_configured_project_exits_zero() { assert_eq!(c, 0); let (code, stdout) = run_setup(tmp.path(), &["--check"]); - assert_eq!(code, 0, "configured project should pass --check; stdout=\n{stdout}"); + assert_eq!( + code, 0, + "configured project should pass --check; stdout=\n{stdout}" + ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "configured"); assert_eq!(v["needsConfiguration"], 0); assert_eq!(v["errors"], 0); // The package.json must be counted as configured, not silently absent. - assert_eq!(v["configured"], 1, "the lone manifest must be counted; stdout=\n{stdout}"); + assert_eq!( + v["configured"], 1, + "the lone manifest must be counted; stdout=\n{stdout}" + ); let files = v["files"].as_array().expect("files array"); assert_eq!(files.len(), 1); assert_eq!(files[0]["status"], "configured"); @@ -513,10 +541,16 @@ fn setup_check_configured_project_exits_zero() { #[test] fn setup_check_unconfigured_project_exits_nonzero() { let tmp = tempfile::tempdir().expect("tempdir"); - write(&tmp.path().join("package.json"), r#"{ "name": "x", "scripts": { "build": "tsc" } }"#); + write( + &tmp.path().join("package.json"), + r#"{ "name": "x", "scripts": { "build": "tsc" } }"#, + ); let (code, stdout) = run_setup(tmp.path(), &["--check"]); - assert_eq!(code, 1, "unconfigured project must fail --check; stdout=\n{stdout}"); + assert_eq!( + code, 1, + "unconfigured project must fail --check; stdout=\n{stdout}" + ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "needs_configuration"); assert_eq!(v["needsConfiguration"], 1); @@ -533,7 +567,10 @@ fn setup_check_no_files_exits_zero() { // (CLI_CONTRACT "Setup command contract") — the summary counts are // always-present, zero-valued fields, NOT dropped. A consumer reading // `.needsConfiguration` must see 0, not null. - assert_eq!(v["configured"], 0, "missing/`null` configured; stdout=\n{stdout}"); + assert_eq!( + v["configured"], 0, + "missing/`null` configured; stdout=\n{stdout}" + ); assert_eq!( v["needsConfiguration"], 0, "missing/`null` needsConfiguration; stdout=\n{stdout}" @@ -572,7 +609,10 @@ fn setup_check_does_not_modify_file() { // 1) — discarding the outcome would let a no-op binary pass the // "didn't write" assertion vacuously. let (code, stdout) = run_setup(tmp.path(), &["--check"]); - assert_eq!(code, 1, "unconfigured --check must exit 1; stdout=\n{stdout}"); + assert_eq!( + code, 1, + "unconfigured --check must exit 1; stdout=\n{stdout}" + ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "needs_configuration"); assert_eq!( @@ -605,7 +645,10 @@ fn setup_remove_round_trips_and_preserves_other_scripts() { assert_eq!(v["removed"], 1); let after = std::fs::read_to_string(&pkg).unwrap(); - assert!(!after.contains("socket-patch"), "socket-patch must be gone; got:\n{after}"); + assert!( + !after.contains("socket-patch"), + "socket-patch must be gone; got:\n{after}" + ); let parsed: serde_json::Value = serde_json::from_str(&after).expect("valid JSON"); // Full revert: lifecycle keys gone, sibling script preserved. assert_eq!(parsed["scripts"]["build"], "tsc"); @@ -643,10 +686,16 @@ fn setup_remove_dry_run_does_not_modify_file() { #[test] fn setup_remove_nothing_to_remove_exits_zero() { let tmp = tempfile::tempdir().expect("tempdir"); - write(&tmp.path().join("package.json"), r#"{ "name": "x", "scripts": { "build": "tsc" } }"#); + write( + &tmp.path().join("package.json"), + r#"{ "name": "x", "scripts": { "build": "tsc" } }"#, + ); let (code, stdout) = run_setup(tmp.path(), &["--remove", "--yes"]); - assert_eq!(code, 0, "nothing to remove should exit 0; stdout=\n{stdout}"); + assert_eq!( + code, 0, + "nothing to remove should exit 0; stdout=\n{stdout}" + ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "not_configured"); assert_eq!(v["removed"], 0); @@ -669,7 +718,11 @@ fn remove_human_mode_surfaces_unprocessable_file_error() { .output() .expect("run socket-patch"); let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!(out.status.code(), Some(1), "a malformed manifest must exit 1; stdout=\n{stdout}"); + assert_eq!( + out.status.code(), + Some(1), + "a malformed manifest must exit 1; stdout=\n{stdout}" + ); // The "(see errors above)" trailer is only honest if the error was actually // printed above it. @@ -740,10 +793,14 @@ fn setup_writes_only_inside_repo() { write(&pkg, r#"{ "name": "x", "version": "1.0.0" }"#); // Sentinel HOME starts empty; setup must leave it empty. - assert!(files_under(home.path()).is_empty(), "sentinel HOME must start empty"); + assert!( + files_under(home.path()).is_empty(), + "sentinel HOME must start empty" + ); let mut cmd = Command::new(binary()); - cmd.args(["setup", "--json", "--yes"]).current_dir(proj.path()); + cmd.args(["setup", "--json", "--yes"]) + .current_dir(proj.path()); for var in SOCKET_ENV_VARS { cmd.env_remove(var); } @@ -753,7 +810,11 @@ fn setup_writes_only_inside_repo() { cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); let out = cmd.output().expect("run socket-patch"); let stderr = String::from_utf8_lossy(&out.stderr); - assert_eq!(out.status.code(), Some(0), "setup should succeed; stderr=\n{stderr}"); + assert_eq!( + out.status.code(), + Some(0), + "setup should succeed; stderr=\n{stderr}" + ); // Nothing was written outside the repo. assert!( @@ -770,7 +831,9 @@ fn setup_writes_only_inside_repo() { ); // Not vacuous: it really did wire the hook into that in-repo file. assert!( - std::fs::read_to_string(&pkg).unwrap().contains("socket-patch"), + std::fs::read_to_string(&pkg) + .unwrap() + .contains("socket-patch"), "setup must have edited the in-repo package.json" ); } @@ -784,7 +847,10 @@ fn setup_writes_only_inside_repo() { #[test] fn setup_state_is_clone_portable() { let a = tempfile::tempdir().expect("a"); - write(&a.path().join("package.json"), r#"{ "name": "x", "version": "1.0.0" }"#); + write( + &a.path().join("package.json"), + r#"{ "name": "x", "version": "1.0.0" }"#, + ); let (c, _) = run_setup(a.path(), &["--yes"]); assert_eq!(c, 0, "initial setup must succeed"); @@ -795,7 +861,10 @@ fn setup_state_is_clone_portable() { let before = std::fs::read_to_string(b.path().join("package.json")).unwrap(); let (code, stdout) = run_setup(b.path(), &["--check"]); - assert_eq!(code, 0, "the clone must already be configured; stdout=\n{stdout}"); + assert_eq!( + code, 0, + "the clone must already be configured; stdout=\n{stdout}" + ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "configured"); assert_eq!(v["needsConfiguration"], 0); @@ -907,9 +976,18 @@ fn setup_configures_gem_alongside_npm() { .iter() .filter_map(|f| f["kind"].as_str()) .collect(); - assert!(kinds.contains("package_json"), "npm entry missing; kinds={kinds:?}"); - assert!(kinds.contains("gemfile"), "gem Gemfile entry missing; kinds={kinds:?}"); - assert!(kinds.contains("gem_plugin"), "gem plugin entry missing; kinds={kinds:?}"); + assert!( + kinds.contains("package_json"), + "npm entry missing; kinds={kinds:?}" + ); + assert!( + kinds.contains("gemfile"), + "gem Gemfile entry missing; kinds={kinds:?}" + ); + assert!( + kinds.contains("gem_plugin"), + "gem plugin entry missing; kinds={kinds:?}" + ); // On disk: both manifests are wired. assert!(std::fs::read_to_string(tmp.path().join("Gemfile")) diff --git a/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs b/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs index c1807fa..4d1a2e7 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs @@ -81,7 +81,9 @@ fn matrix_path() -> PathBuf { /// Host mode runs the driver against host-installed toolchains instead /// of a container. Mirrors the `docker_e2e_*` convention. fn host_mode() -> bool { - std::env::var("SOCKET_PATCH_TEST_HOST").map(|v| v == "1").unwrap_or(false) + std::env::var("SOCKET_PATCH_TEST_HOST") + .map(|v| v == "1") + .unwrap_or(false) } fn docker_on_path() -> bool { @@ -96,7 +98,11 @@ fn docker_on_path() -> bool { fn image_present(image: &str) -> bool { Command::new("docker") - .args(["image", "inspect", &format!("socket-patch-test-{image}:latest")]) + .args([ + "image", + "inspect", + &format!("socket-patch-test-{image}:latest"), + ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -153,8 +159,14 @@ impl Case { ("SM_PM".into(), self.pm.clone()), ("SM_SCENARIO".into(), self.scenario.clone()), ("SM_PATCHSET".into(), self.patchset.clone()), - ("SM_RUN_SETUP".into(), if self.run_setup { "1" } else { "0" }.into()), - ("SM_EXPECT_APPLIED".into(), if self.expect_applied { "1" } else { "0" }.into()), + ( + "SM_RUN_SETUP".into(), + if self.run_setup { "1" } else { "0" }.into(), + ), + ( + "SM_EXPECT_APPLIED".into(), + if self.expect_applied { "1" } else { "0" }.into(), + ), ("SM_PACKAGE".into(), self.package.clone()), ("SM_VERSION".into(), self.version.clone()), ("SM_PURL".into(), self.purl.clone()), @@ -180,15 +192,18 @@ fn load_section( ecosystem: &str, pm: &str, ) -> Vec { - let text = std::fs::read_to_string(matrix_path()) - .unwrap_or_else(|e| panic!("read matrix.json: {e}")); - let spec: serde_json::Value = - serde_json::from_str(&text).expect("parse matrix.json"); + let text = + std::fs::read_to_string(matrix_path()).unwrap_or_else(|e| panic!("read matrix.json: {e}")); + let spec: serde_json::Value = serde_json::from_str(&text).expect("parse matrix.json"); let marker = spec["marker"].as_str().unwrap_or("").to_string(); let alt_marker = spec["alt_marker"].as_str().unwrap_or("").to_string(); let known_regressions: std::collections::HashSet = spec["known_regressions"] .as_array() - .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) .unwrap_or_default(); let target = spec[targets_key] @@ -258,8 +273,8 @@ fn run_case(case: &Case) -> RunResult { } cmd.output().expect("spawn bash driver") } else { - let script = std::fs::read_to_string(&driver) - .unwrap_or_else(|e| panic!("read driver: {e}")); + let script = + std::fs::read_to_string(&driver).unwrap_or_else(|e| panic!("read driver: {e}")); let mut cmd = Command::new("docker"); cmd.args(["run", "--rm"]); for (k, v) in &env { @@ -320,7 +335,13 @@ pub fn run_pm(ecosystem: &str, pm: &str) { pub fn run_workspace_pm(ecosystem: &str, pm: &str) { run_cases( &format!("{ecosystem}/{pm} [workspace]"), - load_section("workspace_targets", "workspace_scenarios", "workspace", ecosystem, pm), + load_section( + "workspace_targets", + "workspace_scenarios", + "workspace", + ecosystem, + pm, + ), ); } @@ -328,7 +349,13 @@ pub fn run_workspace_pm(ecosystem: &str, pm: &str) { pub fn run_monorepo() { run_cases( "monorepo", - load_section("monorepo_targets", "monorepo_scenarios", "monorepo", "monorepo", "mono"), + load_section( + "monorepo_targets", + "monorepo_scenarios", + "monorepo", + "monorepo", + "mono", + ), ); } @@ -418,7 +445,11 @@ fn run_cases(label: &str, cases: Vec) { }; failures.push(format!( " - {}: expected applied={}, got {} [{}]\n{}", - case.id, case.expect_applied, applied, tag, indent(&res.raw) + case.id, + case.expect_applied, + applied, + tag, + indent(&res.raw) )); } } @@ -534,10 +565,14 @@ fn round_trip_failure(case: &Case, res: &RunResult) -> Option { )); } if check_setup != Some(0) { - problems.push(format!("check-after-setup exit={check_setup:?} (want 0; configured)")); + problems.push(format!( + "check-after-setup exit={check_setup:?} (want 0; configured)" + )); } if remove != Some(0) { - problems.push(format!("remove exit={remove:?} (want 0; remove must succeed)")); + problems.push(format!( + "remove exit={remove:?} (want 0; remove must succeed)" + )); } if !matches!(check_remove, Some(n) if n != 0) { problems.push(format!( @@ -557,5 +592,8 @@ fn round_trip_failure(case: &Case, res: &RunResult) -> Option { } fn indent(s: &str) -> String { - s.lines().map(|l| format!(" {l}")).collect::>().join("\n") + s.lines() + .map(|l| format!(" {l}")) + .collect::>() + .join("\n") } diff --git a/crates/socket-patch-cli/tests/setup_matrix_composer.rs b/crates/socket-patch-cli/tests/setup_matrix_composer.rs index 629ae0d..2211210 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_composer.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_composer.rs @@ -90,8 +90,9 @@ mod host_guard { /// non-JSON / multi-line dump means the command did not run the path we /// think it did. fn parse_obj(stdout: &str, who: &str) -> serde_json::Value { - serde_json::from_str(stdout.trim()) - .unwrap_or_else(|e| panic!("{who}: stdout was not a single JSON object ({e}):\n{stdout}")) + serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{who}: stdout was not a single JSON object ({e}):\n{stdout}") + }) } /// Assert the parsed result is a genuine clean no-op for an unsupported @@ -218,7 +219,10 @@ mod host_guard { assert_manifest_pristine(root, "after check (post-setup)"); // ── remove: also a clean no-op, manifest still pristine ─────────────── - let (code, out, err) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes", "--json"]); + let (code, out, err) = run( + root, + &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], + ); assert_eq!( code, 0, "setup --remove on a composer-only project must exit 0.\nstdout:\n{out}\nstderr:\n{err}" @@ -241,19 +245,34 @@ mod host_guard { std::fs::write(root.join("composer.json"), COMPOSER_JSON).unwrap(); let root_s = root.to_str().unwrap(); - let status = |v: &serde_json::Value| v.get("status").and_then(|s| s.as_str()).map(str::to_string); + let status = + |v: &serde_json::Value| v.get("status").and_then(|s| s.as_str()).map(str::to_string); // ── check (pristine): not wired yet → needs_configuration / exit 1 ── let (code, out, _) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); assert_eq!(code, 1, "pre-setup check must fail:\n{out}"); - assert_eq!(status(&parse_obj(&out, "check (pristine)")).as_deref(), Some("needs_configuration")); + assert_eq!( + status(&parse_obj(&out, "check (pristine)")).as_deref(), + Some("needs_configuration") + ); // ── setup: wires the hook into composer.json → success / updated=1 ── let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!(code, 0, "composer setup must succeed.\nstdout:\n{out}\nstderr:\n{err}"); + assert_eq!( + code, 0, + "composer setup must succeed.\nstdout:\n{out}\nstderr:\n{err}" + ); let v = parse_obj(&out, "setup"); - assert_eq!(status(&v).as_deref(), Some("success"), "setup must report success:\n{out}"); - assert_eq!(v.get("updated").and_then(|n| n.as_i64()), Some(1), "exactly the composer.json updated:\n{out}"); + assert_eq!( + status(&v).as_deref(), + Some("success"), + "setup must report success:\n{out}" + ); + assert_eq!( + v.get("updated").and_then(|n| n.as_i64()), + Some(1), + "exactly the composer.json updated:\n{out}" + ); // Exactly one `composer`-kind file entry, status `updated`. let files = v["files"].as_array().expect("files array"); assert_eq!(files.len(), 1, "one composer file entry:\n{out}"); @@ -263,34 +282,59 @@ mod host_guard { let on_disk = std::fs::read_to_string(root.join("composer.json")).unwrap(); let cj: serde_json::Value = serde_json::from_str(&on_disk).unwrap(); for event in ["post-install-cmd", "post-update-cmd"] { - let arr = cj["scripts"][event].as_array().unwrap_or_else(|| panic!("{event} missing:\n{on_disk}")); + let arr = cj["scripts"][event] + .as_array() + .unwrap_or_else(|| panic!("{event} missing:\n{on_disk}")); assert!( - arr.iter().any(|c| c.as_str().is_some_and(|s| s.contains("socket-patch apply"))), + arr.iter() + .any(|c| c.as_str().is_some_and(|s| s.contains("socket-patch apply"))), "{event} must carry the re-apply command:\n{on_disk}" ); } - assert!(cj["require"]["monolog/monolog"] == "3.5.0", "user require preserved:\n{on_disk}"); + assert!( + cj["require"]["monolog/monolog"] == "3.5.0", + "user require preserved:\n{on_disk}" + ); // ── idempotent re-setup: already_configured, no change ── let (code, out, _) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); assert_eq!(code, 0); - assert_eq!(status(&parse_obj(&out, "re-setup")).as_deref(), Some("already_configured"), "{out}"); + assert_eq!( + status(&parse_obj(&out, "re-setup")).as_deref(), + Some("already_configured"), + "{out}" + ); // ── check (post-setup): configured / exit 0 ── let (code, out, _) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); assert_eq!(code, 0, "post-setup check must pass:\n{out}"); - assert_eq!(status(&parse_obj(&out, "check (post-setup)")).as_deref(), Some("configured")); + assert_eq!( + status(&parse_obj(&out, "check (post-setup)")).as_deref(), + Some("configured") + ); // ── remove: strips the hook, restoring composer.json byte-for-byte ── - let (code, out, err) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes", "--json"]); - assert_eq!(code, 0, "composer remove must succeed.\nstdout:\n{out}\nstderr:\n{err}"); - assert_eq!(status(&parse_obj(&out, "remove")).as_deref(), Some("success")); + let (code, out, err) = run( + root, + &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], + ); + assert_eq!( + code, 0, + "composer remove must succeed.\nstdout:\n{out}\nstderr:\n{err}" + ); + assert_eq!( + status(&parse_obj(&out, "remove")).as_deref(), + Some("success") + ); // The `scripts` object we created is gone and the dir holds only composer.json. assert_manifest_pristine(root, "after remove"); // ── check (post-remove): back to needs_configuration / exit 1 ── let (code, out, _) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); assert_eq!(code, 1, "post-remove check must fail again:\n{out}"); - assert_eq!(status(&parse_obj(&out, "check (post-remove)")).as_deref(), Some("needs_configuration")); + assert_eq!( + status(&parse_obj(&out, "check (post-remove)")).as_deref(), + Some("needs_configuration") + ); } } diff --git a/crates/socket-patch-cli/tests/setup_matrix_deno.rs b/crates/socket-patch-cli/tests/setup_matrix_deno.rs index 8c8a1a1..0226e2d 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_deno.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_deno.rs @@ -127,9 +127,7 @@ mod host_guard { for var in SOCKET_ENV_VARS { cmd.env_remove(var); } - let out = cmd - .output() - .expect("failed to execute socket-patch binary"); + let out = cmd.output().expect("failed to execute socket-patch binary"); ( out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).to_string(), @@ -215,7 +213,10 @@ mod host_guard { // ── setup: must rewrite package.json with a real apply hook ───────── let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!(code, 0, "setup must succeed (exit 0).\nstdout:\n{out}\nstderr:\n{err}"); + assert_eq!( + code, 0, + "setup must succeed (exit 0).\nstdout:\n{out}\nstderr:\n{err}" + ); let v = parse_json(&out, "setup"); assert_eq!( json_str_field(&v, "status", "setup"), @@ -251,7 +252,10 @@ mod host_guard { let pkg = std::fs::read_to_string(root.join("package.json")).unwrap(); let pkg_v: serde_json::Value = serde_json::from_str(&pkg).unwrap(); assert_eq!( - pkg_v.get("dependencies").and_then(|d| d.get("minimist")).and_then(|m| m.as_str()), + pkg_v + .get("dependencies") + .and_then(|d| d.get("minimist")) + .and_then(|m| m.as_str()), Some("1.2.2"), "setup must preserve the project's existing dependencies.\n{pkg}" ); @@ -281,8 +285,14 @@ mod host_guard { ); // ── remove: must delete the hook and succeed ──────────────────────── - let (code, out, err) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes", "--json"]); - assert_eq!(code, 0, "setup --remove must succeed (exit 0).\nstdout:\n{out}\nstderr:\n{err}"); + let (code, out, err) = run( + root, + &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], + ); + assert_eq!( + code, 0, + "setup --remove must succeed (exit 0).\nstdout:\n{out}\nstderr:\n{err}" + ); let v = parse_json(&out, "remove"); assert_eq!( json_str_field(&v, "status", "remove"), diff --git a/crates/socket-patch-cli/tests/setup_matrix_gem.rs b/crates/socket-patch-cli/tests/setup_matrix_gem.rs index b954437..b08b5a7 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_gem.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_gem.rs @@ -133,8 +133,9 @@ mod host_guard { /// promises — a non-JSON / multi-line dump means the command did not /// run the path we think it did. fn parse_json(stdout: &str, who: &str) -> serde_json::Value { - serde_json::from_str(stdout.trim()) - .unwrap_or_else(|e| panic!("{who}: stdout was not a single JSON object ({e}):\n{stdout}")) + serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{who}: stdout was not a single JSON object ({e}):\n{stdout}") + }) } fn json_str(v: &serde_json::Value, key: &str, who: &str) -> String { @@ -179,14 +180,22 @@ mod host_guard { // ── check (pristine): plugin not wired → needs_configuration, exit 1 ─ let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!(code, 1, "check on an unconfigured bundler project must exit 1.\n{out}\n{err}"); + assert_eq!( + code, 1, + "check on an unconfigured bundler project must exit 1.\n{out}\n{err}" + ); let v = parse_json(&out, "check (pristine)"); - assert_eq!(json_str(&v, "status", "check (pristine)"), "needs_configuration"); + assert_eq!( + json_str(&v, "status", "check (pristine)"), + "needs_configuration" + ); // The Gemfile must be among the manifests reported as needing setup. let files = v.get("files").and_then(|f| f.as_array()).expect("files[]"); assert!( - files.iter().any(|f| f.get("kind").and_then(|k| k.as_str()) == Some("gemfile") - && f.get("status").and_then(|s| s.as_str()) == Some("needs_configuration")), + files.iter().any( + |f| f.get("kind").and_then(|k| k.as_str()) == Some("gemfile") + && f.get("status").and_then(|s| s.as_str()) == Some("needs_configuration") + ), "check must report the Gemfile as needs_configuration:\n{v}" ); @@ -195,14 +204,23 @@ mod host_guard { assert_eq!(code, 0, "setup must exit 0.\n{out}\n{err}"); let v = parse_json(&out, "setup"); assert_eq!(json_str(&v, "status", "setup"), "success"); - assert!(json_i64(&v, "updated", "setup") >= 2, "Gemfile + plugin dir updated:\n{v}"); + assert!( + json_i64(&v, "updated", "setup") >= 2, + "Gemfile + plugin dir updated:\n{v}" + ); assert_eq!(json_i64(&v, "errors", "setup"), 0, "setup errors:\n{v}"); // On-disk, via independent probes (NOT a copy of the writer output): // the managed block is appended (original bytes preserved as a prefix), let body = gemfile_body(root); - assert!(body.starts_with(GEMFILE), "setup must only APPEND to the Gemfile:\n{body}"); - assert!(body.contains(MANAGED_MARKER), "managed block marker missing:\n{body}"); + assert!( + body.starts_with(GEMFILE), + "setup must only APPEND to the Gemfile:\n{body}" + ); + assert!( + body.contains(MANAGED_MARKER), + "managed block marker missing:\n{body}" + ); assert!( body.contains("plugin 'socket-patch'"), "Gemfile must reference the socket-patch plugin:\n{body}" @@ -230,9 +248,16 @@ mod host_guard { // ── check (after setup): configured, exit 0 ───────────────────────── let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!(code, 0, "check on a configured project must exit 0.\n{out}\n{err}"); assert_eq!( - json_str(&parse_json(&out, "check (configured)"), "status", "check (configured)"), + code, 0, + "check on a configured project must exit 0.\n{out}\n{err}" + ); + assert_eq!( + json_str( + &parse_json(&out, "check (configured)"), + "status", + "check (configured)" + ), "configured" ); @@ -241,14 +266,24 @@ mod host_guard { assert_eq!(code, 0, "idempotent re-setup must exit 0"); let v = parse_json(&out, "re-setup"); assert_eq!(json_str(&v, "status", "re-setup"), "already_configured"); - assert_eq!(json_i64(&v, "updated", "re-setup"), 0, "re-setup must update nothing:\n{v}"); + assert_eq!( + json_i64(&v, "updated", "re-setup"), + 0, + "re-setup must update nothing:\n{v}" + ); // ── remove: byte-for-byte restore + plugin dir gone ───────────────── - let (code, out, err) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes", "--json"]); + let (code, out, err) = run( + root, + &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], + ); assert_eq!(code, 0, "remove must exit 0.\n{out}\n{err}"); let v = parse_json(&out, "remove"); assert_eq!(json_str(&v, "status", "remove"), "success"); - assert!(json_i64(&v, "removed", "remove") >= 2, "Gemfile + plugin dir removed:\n{v}"); + assert!( + json_i64(&v, "removed", "remove") >= 2, + "Gemfile + plugin dir removed:\n{v}" + ); assert_eq!( gemfile_body(root), GEMFILE, @@ -263,7 +298,11 @@ mod host_guard { let (code, out, _) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); assert_eq!(code, 1, "check after remove must exit 1 again"); assert_eq!( - json_str(&parse_json(&out, "check (removed)"), "status", "check (removed)"), + json_str( + &parse_json(&out, "check (removed)"), + "status", + "check (removed)" + ), "needs_configuration" ); } diff --git a/crates/socket-patch-cli/tests/setup_matrix_maven.rs b/crates/socket-patch-cli/tests/setup_matrix_maven.rs index 9447695..1f2e50a 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_maven.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_maven.rs @@ -141,9 +141,7 @@ mod host_guard { for var in SOCKET_ENV_VARS { cmd.env_remove(var); } - let out = cmd - .output() - .expect("failed to execute socket-patch binary"); + let out = cmd.output().expect("failed to execute socket-patch binary"); ( out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).to_string(), @@ -217,7 +215,13 @@ mod host_guard { // Count fields are optional in the `no_files` envelope, but any that // ARE emitted must be zero — a non-zero count would mean setup thought // it had work to do on a project it does not support. - for key in ["updated", "alreadyConfigured", "errors", "configured", "needsConfiguration"] { + for key in [ + "updated", + "alreadyConfigured", + "errors", + "configured", + "needsConfiguration", + ] { if let Some(n) = v.get(key) { assert_eq!( n.as_i64(), @@ -266,7 +270,10 @@ mod host_guard { assert_pristine(root, "after setup"); // ── setup --remove: nothing was configured, so nothing to remove ──── - let (code, out, err) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes", "--json"]); + let (code, out, err) = run( + root, + &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], + ); assert_eq!( code, 0, "setup --remove on a maven project must exit 0 and do nothing.\nstdout:\n{out}\nstderr:\n{err}" @@ -285,7 +292,13 @@ mod host_guard { std::fs::write(ctrl_root.join("package.json"), PACKAGE_JSON).unwrap(); let (code, out, err) = run( ctrl_root, - &["setup", "--check", "--cwd", ctrl_root.to_str().unwrap(), "--json"], + &[ + "setup", + "--check", + "--cwd", + ctrl_root.to_str().unwrap(), + "--json", + ], ); assert_eq!( code, 1, diff --git a/crates/socket-patch-cli/tests/setup_matrix_monorepo.rs b/crates/socket-patch-cli/tests/setup_matrix_monorepo.rs index c573563..c2314b6 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_monorepo.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_monorepo.rs @@ -153,7 +153,10 @@ fn monorepo_target_routes_through_npm_round_trip() { targets.len() ); let t = &targets[0]; - assert_eq!(t["ecosystem"], "monorepo", "monorepo target ecosystem changed"); + assert_eq!( + t["ecosystem"], "monorepo", + "monorepo target ecosystem changed" + ); assert_eq!(t["pm"], "mono", "monorepo target pm changed"); assert_eq!( t["image"], "npm", @@ -171,7 +174,10 @@ fn monorepo_target_routes_through_npm_round_trip() { "monorepo target purl changed — the patched slice is no longer the npm dependency" ); assert!( - t["manifest_key"].as_str().unwrap_or("").contains("index.js"), + t["manifest_key"] + .as_str() + .unwrap_or("") + .contains("index.js"), "monorepo manifest_key no longer points at the npm package file" ); assert_eq!( diff --git a/crates/socket-patch-cli/tests/setup_matrix_npm.rs b/crates/socket-patch-cli/tests/setup_matrix_npm.rs index 62e5fe9..a38863e 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_npm.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_npm.rs @@ -147,8 +147,9 @@ mod host_guard { /// script, if present and a string. fn lifecycle_script(root: &Path, key: &str) -> Option { let text = std::fs::read_to_string(root.join("package.json")).unwrap(); - let val: serde_json::Value = serde_json::from_str(&text) - .unwrap_or_else(|e| panic!("package.json is not valid JSON after CLI ran: {e}\n{text}")); + let val: serde_json::Value = serde_json::from_str(&text).unwrap_or_else(|e| { + panic!("package.json is not valid JSON after CLI ran: {e}\n{text}") + }); val.get("scripts") .and_then(|s| s.get(key)) .and_then(|v| v.as_str()) @@ -211,7 +212,10 @@ mod host_guard { // ── setup ────────────────────────────────────────────────────────── let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes"]); - assert_eq!(code, 0, "setup must succeed.\nstdout:\n{out}\nstderr:\n{err}"); + assert_eq!( + code, 0, + "setup must succeed.\nstdout:\n{out}\nstderr:\n{err}" + ); // The postinstall hook must now carry the apply command AND the npm // ecosystem filter, run FIRST, and PRESERVE the user's original step. @@ -248,7 +252,10 @@ mod host_guard { // ── remove ────────────────────────────────────────────────────────── let (code, out, err) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes"]); - assert_eq!(code, 0, "setup --remove must succeed.\nstdout:\n{out}\nstderr:\n{err}"); + assert_eq!( + code, 0, + "setup --remove must succeed.\nstdout:\n{out}\nstderr:\n{err}" + ); // The apply command must be gone everywhere, and the user's original // postinstall step restored intact (not left mangled by the removal). diff --git a/crates/socket-patch-cli/tests/setup_matrix_nuget.rs b/crates/socket-patch-cli/tests/setup_matrix_nuget.rs index ee1ae0f..381816d 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_nuget.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_nuget.rs @@ -223,10 +223,18 @@ mod host_guard { // `--check` returning exit 1 here would be wrong (there is nothing to // configure); returning `needs_configuration`/`configured` would mean // the .csproj was mis-detected as an npm/python/cargo manifest. - assert_no_files(root, &["setup", "--check", "--cwd", root_s, "--json"], "check (pristine)"); + assert_no_files( + root, + &["setup", "--check", "--cwd", root_s, "--json"], + "check (pristine)", + ); // ── setup: must be a true no-op (no .csproj mutation, nothing wired) ─ - let v = assert_no_files(root, &["setup", "--cwd", root_s, "--yes", "--json"], "setup"); + let v = assert_no_files( + root, + &["setup", "--cwd", root_s, "--yes", "--json"], + "setup", + ); assert_eq!( v.get("updated").and_then(|n| n.as_i64()), Some(0), @@ -259,7 +267,11 @@ mod host_guard { ); // ── remove: also a no-op on an unsupported project ────────────────── - assert_no_files(root, &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], "remove"); + assert_no_files( + root, + &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], + "remove", + ); // ── final: directory still holds exactly the one file we created ──── // A stray sidecar/hook artifact left behind by any stage would betray diff --git a/crates/socket-patch-cli/tests/setup_matrix_pypi.rs b/crates/socket-patch-cli/tests/setup_matrix_pypi.rs index a83c1de..cff2a90 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_pypi.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_pypi.rs @@ -161,8 +161,9 @@ mod host_guard { /// promises — a non-JSON / multi-line dump means the command did not /// run the path we think it did. fn parse_json(stdout: &str, who: &str) -> serde_json::Value { - serde_json::from_str(stdout.trim()) - .unwrap_or_else(|e| panic!("{who}: stdout was not a single JSON object ({e}):\n{stdout}")) + serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{who}: stdout was not a single JSON object ({e}):\n{stdout}") + }) } fn json_str(v: &serde_json::Value, key: &str, who: &str) -> String { @@ -247,7 +248,11 @@ mod host_guard { "pristine pip project must report needs_configuration:\n{v}" ); assert_eq!( - json_str(&pth_entry(&v, "check (pristine)"), "status", "check (pristine) pth"), + json_str( + &pth_entry(&v, "check (pristine)"), + "status", + "check (pristine) pth" + ), "needs_configuration", "the requirements.txt pth entry must read needs_configuration before setup:\n{v}" ); @@ -256,7 +261,10 @@ mod host_guard { // ── setup: must append the hook dep and report success ────────────── let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!(code, 0, "setup must succeed.\nstdout:\n{out}\nstderr:\n{err}"); + assert_eq!( + code, 0, + "setup must succeed.\nstdout:\n{out}\nstderr:\n{err}" + ); let v = parse_json(&out, "setup"); assert_eq!( json_str(&v, "status", "setup"), @@ -309,14 +317,21 @@ mod host_guard { "after setup the project must report configured:\n{v}" ); assert_eq!( - json_str(&pth_entry(&v, "check (configured)"), "status", "check (configured) pth"), + json_str( + &pth_entry(&v, "check (configured)"), + "status", + "check (configured) pth" + ), "configured", "the requirements.txt pth entry must read configured after setup:\n{v}" ); // ── idempotent re-setup: no further change ────────────────────────── let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!(code, 0, "re-setup must succeed.\nstdout:\n{out}\nstderr:\n{err}"); + assert_eq!( + code, 0, + "re-setup must succeed.\nstdout:\n{out}\nstderr:\n{err}" + ); let v = parse_json(&out, "re-setup"); assert_eq!( json_str(&v, "status", "re-setup"), @@ -332,8 +347,14 @@ mod host_guard { assert_requirements(root, REQ_WITH_HOOK, "after re-setup"); // ── remove: strip the hook dep, restore the original manifest ─────── - let (code, out, err) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes", "--json"]); - assert_eq!(code, 0, "setup --remove must succeed.\nstdout:\n{out}\nstderr:\n{err}"); + let (code, out, err) = run( + root, + &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], + ); + assert_eq!( + code, 0, + "setup --remove must succeed.\nstdout:\n{out}\nstderr:\n{err}" + ); let v = parse_json(&out, "remove"); assert_eq!( json_str(&v, "status", "remove"), diff --git a/crates/socket-patch-cli/tests/setup_pth_invariants.rs b/crates/socket-patch-cli/tests/setup_pth_invariants.rs index 6b9300a..322f403 100644 --- a/crates/socket-patch-cli/tests/setup_pth_invariants.rs +++ b/crates/socket-patch-cli/tests/setup_pth_invariants.rs @@ -82,9 +82,10 @@ fn copy_tree(src: &Path, dst: &Path) { /// is not exactly one. Stops a regression from hiding a wrong/extra entry /// behind a positional `files[0]`. fn file_entry<'a>(v: &'a serde_json::Value, kind: &str) -> &'a serde_json::Value { - let arr = v["files"].as_array().unwrap_or_else(|| panic!("files must be an array: {v}")); - let matches: Vec<&serde_json::Value> = - arr.iter().filter(|f| f["kind"] == kind).collect(); + let arr = v["files"] + .as_array() + .unwrap_or_else(|| panic!("files must be an array: {v}")); + let matches: Vec<&serde_json::Value> = arr.iter().filter(|f| f["kind"] == kind).collect(); assert_eq!( matches.len(), 1, @@ -134,14 +135,20 @@ fn pip_requirements_gets_hook_dep() { assert_eq!(code, 0, "setup should succeed; payload={v}"); assert_eq!(v["status"], "success"); assert_eq!(v["updated"], 1); - assert_eq!(v["alreadyConfigured"], 0, "fresh file is not already-configured"); + assert_eq!( + v["alreadyConfigured"], 0, + "fresh file is not already-configured" + ); assert_eq!(v["errors"], 0); assert_eq!(v["pythonPackageManager"], "pip"); let entry = file_entry(&v, "pth"); assert_eq!(entry["status"], "updated"); assert!( - entry["path"].as_str().unwrap().ends_with("requirements.txt"), + entry["path"] + .as_str() + .unwrap() + .ends_with("requirements.txt"), "pth entry must point at requirements.txt: {entry}" ); assert!(entry["error"].is_null(), "no error expected: {entry}"); @@ -205,7 +212,10 @@ fn uv_pyproject_array_edited_and_format_preserved() { // user's 4-space array indentation is kept, and the file is still parseable // by the same edit path (idempotent re-run reports already-configured, which // proves the array is well-formed enough to be re-detected). - assert!(py.contains("[tool.uv]"), "unrelated tables preserved:\n{py}"); + assert!( + py.contains("[tool.uv]"), + "unrelated tables preserved:\n{py}" + ); assert!(py.contains("name = \"x\""), "scalar keys preserved:\n{py}"); assert!( py.contains(" \"requests\""), @@ -228,13 +238,19 @@ fn idempotent_second_run_reports_already_configured() { let (code1, v1) = run_setup(tmp.path(), &[]); assert_eq!(code1, 0, "first run must succeed: {v1}"); assert_eq!(v1["status"], "success", "first run must configure: {v1}"); - assert_eq!(v1["updated"], 1, "first run updates exactly one manifest: {v1}"); + assert_eq!( + v1["updated"], 1, + "first run updates exactly one manifest: {v1}" + ); let (code, v) = run_setup(tmp.path(), &[]); assert_eq!(code, 0); assert_eq!(v["status"], "already_configured"); assert_eq!(v["updated"], 0, "second run must not re-edit: {v}"); - assert_eq!(v["alreadyConfigured"], 1, "second run sees it configured: {v}"); + assert_eq!( + v["alreadyConfigured"], 1, + "second run sees it configured: {v}" + ); let req = read(&tmp.path().join("requirements.txt")); assert_eq!( req.matches("socket-patch[hook]").count(), @@ -312,7 +328,10 @@ fn polyglot_configures_both_npm_and_python() { assert_eq!(code, 0, "payload={v}"); assert_eq!(v["status"], "success", "payload={v}"); assert_eq!(v["updated"], 2); - assert_eq!(v["alreadyConfigured"], 0, "both manifests start unconfigured: {v}"); + assert_eq!( + v["alreadyConfigured"], 0, + "both manifests start unconfigured: {v}" + ); assert_eq!(v["errors"], 0); let files = v["files"].as_array().unwrap(); @@ -325,8 +344,14 @@ fn polyglot_configures_both_npm_and_python() { // The npm side injects the postinstall hook into package.json. let pkg = read(&tmp.path().join("package.json")); - assert!(pkg.contains("socket-patch"), "package.json must gain the hook:\n{pkg}"); - assert!(pkg.contains("postinstall"), "npm hook is a postinstall script:\n{pkg}"); + assert!( + pkg.contains("socket-patch"), + "package.json must gain the hook:\n{pkg}" + ); + assert!( + pkg.contains("postinstall"), + "npm hook is a postinstall script:\n{pkg}" + ); // The python side adds the dep inside the dependencies array. let py = read(&tmp.path().join("pyproject.toml")); @@ -374,7 +399,10 @@ fn setup_python_writes_only_inside_repo() { let proj = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); write(&proj.path().join("requirements.txt"), "requests\n"); - assert!(files_under(home.path()).is_empty(), "sentinel HOME must start empty"); + assert!( + files_under(home.path()).is_empty(), + "sentinel HOME must start empty" + ); let out = Command::new(binary()) .args(["setup", "--json", "--yes"]) diff --git a/crates/socket-patch-cli/tests/telemetry_e2e.rs b/crates/socket-patch-cli/tests/telemetry_e2e.rs index bea70a0..e3cd19b 100644 --- a/crates/socket-patch-cli/tests/telemetry_e2e.rs +++ b/crates/socket-patch-cli/tests/telemetry_e2e.rs @@ -180,10 +180,15 @@ async fn scan_emits_patch_scanned_telemetry_on_success() { .iter() .filter(|r| { r.method == wiremock::http::Method::POST - && r.url.path().ends_with(&format!("/v0/orgs/{ORG_SLUG}/patches/batch")) + && r.url + .path() + .ends_with(&format!("/v0/orgs/{ORG_SLUG}/patches/batch")) }) .count(); - assert!(batch_hits >= 1, "scan must POST to the patches/batch endpoint"); + assert!( + batch_hits >= 1, + "scan must POST to the patches/batch endpoint" + ); } #[tokio::test] @@ -198,18 +203,29 @@ async fn scan_skips_telemetry_in_airgap_mode() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2"); - let (code, stdout, stderr) = - run_cmd(tmp.path(), &mock.uri(), "scan", &[], &[("SOCKET_OFFLINE", "1")]); + let (code, stdout, stderr) = run_cmd( + tmp.path(), + &mock.uri(), + "scan", + &[], + &[("SOCKET_OFFLINE", "1")], + ); // Guard against a vacuous pass: prove scan actually ran its body (it // crawled node_modules and reported the one package) rather than // crashing before the telemetry-suppression point, which would also // yield zero POSTs. assert_eq!(code, 0, "offline scan must still succeed; stderr={stderr}"); - let v: serde_json::Value = - serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("scan stdout not JSON: {e}\n{stdout}")); - assert_eq!(v["status"], "success", "offline scan status; stdout={stdout}"); - assert_eq!(v["scannedPackages"], 1, "offline scan must crawl the one package; stdout={stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("scan stdout not JSON: {e}\n{stdout}")); + assert_eq!( + v["status"], "success", + "offline scan status; stdout={stdout}" + ); + assert_eq!( + v["scannedPackages"], 1, + "offline scan must crawl the one package; stdout={stdout}" + ); let count = telemetry_post_count(&mock, None).await; assert_eq!( @@ -245,13 +261,7 @@ async fn get_emits_patch_fetched_telemetry_on_uuid_lookup_success() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "lodash", "4.17.20"); - let (code, stdout, stderr) = run_cmd( - tmp.path(), - &mock.uri(), - "get", - &["--id", UUID], - &[], - ); + let (code, stdout, stderr) = run_cmd(tmp.path(), &mock.uri(), "get", &["--id", UUID], &[]); // The mock serves the patch on the real `patches/view/{uuid}` endpoint, // so this is a genuine SUCCESS: get must fire exactly one @@ -282,13 +292,18 @@ async fn get_emits_patch_fetched_telemetry_on_uuid_lookup_success() { .iter() .filter(|r| { r.method == wiremock::http::Method::GET - && r.url.path().contains(&format!("/v0/orgs/{ORG_SLUG}/patches/view/")) + && r.url + .path() + .contains(&format!("/v0/orgs/{ORG_SLUG}/patches/view/")) }) .count(); assert!( view_hits >= 1, "get must GET the patches/view/{{uuid}} endpoint; saw paths: {:?}", - received.iter().map(|r| r.url.path().to_string()).collect::>() + received + .iter() + .map(|r| r.url.path().to_string()) + .collect::>() ); } @@ -331,13 +346,18 @@ async fn get_skips_telemetry_in_airgap_mode() { .iter() .filter(|r| { r.method == wiremock::http::Method::GET - && r.url.path().contains(&format!("/v0/orgs/{ORG_SLUG}/patches/view/")) + && r.url + .path() + .contains(&format!("/v0/orgs/{ORG_SLUG}/patches/view/")) }) .count(); assert!( view_hits >= 1, "offline get must still query the view endpoint; saw paths: {:?}; stdout={stdout}", - received.iter().map(|r| r.url.path().to_string()).collect::>() + received + .iter() + .map(|r| r.url.path().to_string()) + .collect::>() ); let count = telemetry_post_count(&mock, None).await; @@ -366,11 +386,7 @@ async fn apply_skips_telemetry_in_airgap_mode() { // runs the command body (and would normally fire telemetry). let socket = tmp.path().join(".socket"); std::fs::create_dir_all(&socket).unwrap(); - std::fs::write( - socket.join("manifest.json"), - r#"{"patches":{}}"#, - ) - .unwrap(); + std::fs::write(socket.join("manifest.json"), r#"{"patches":{}}"#).unwrap(); let (_code, stdout, _stderr) = run_cmd( tmp.path(), @@ -385,10 +401,16 @@ async fn apply_skips_telemetry_in_airgap_mode() { // wasn't a side effect of an early crash. (Apply on an empty manifest // currently reports partialFailure — a separately tracked design gap — // so we assert on the envelope shape, not the status string.) - let v: serde_json::Value = - serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("apply stdout not JSON: {e}\n{stdout}")); - assert_eq!(v["command"], "apply", "apply must emit its command envelope; stdout={stdout}"); - assert!(v.get("summary").is_some(), "apply envelope must carry a summary; stdout={stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("apply stdout not JSON: {e}\n{stdout}")); + assert_eq!( + v["command"], "apply", + "apply must emit its command envelope; stdout={stdout}" + ); + assert!( + v.get("summary").is_some(), + "apply envelope must carry a summary; stdout={stdout}" + ); let count = telemetry_post_count(&mock, None).await; assert_eq!( @@ -414,11 +436,7 @@ async fn list_emits_patch_listed_telemetry_when_telemetry_enabled() { write_root_package_json(tmp.path()); let socket = tmp.path().join(".socket"); std::fs::create_dir_all(&socket).unwrap(); - std::fs::write( - socket.join("manifest.json"), - r#"{"patches":{}}"#, - ) - .unwrap(); + std::fs::write(socket.join("manifest.json"), r#"{"patches":{}}"#).unwrap(); let (code, _stdout, _stderr) = run_cmd(tmp.path(), &mock.uri(), "list", &[], &[]); assert_eq!(code, 0); @@ -489,7 +507,8 @@ async fn scan_falls_back_to_proxy_on_401_and_tags_telemetry() { .expect("recording enabled") .iter() .filter(|r| { - r.method == wiremock::http::Method::GET && r.url.path().starts_with("/patch/by-package/") + r.method == wiremock::http::Method::GET + && r.url.path().starts_with("/patch/by-package/") }) .count(); assert!( @@ -578,7 +597,9 @@ async fn scan_does_not_fall_back_on_500() { .iter() .filter(|r| { r.method == wiremock::http::Method::POST - && r.url.path().ends_with(&format!("/v0/orgs/{ORG_SLUG}/patches/batch")) + && r.url + .path() + .ends_with(&format!("/v0/orgs/{ORG_SLUG}/patches/batch")) }) .count(); assert!( @@ -608,11 +629,7 @@ async fn list_skips_telemetry_in_airgap_mode() { write_root_package_json(tmp.path()); let socket = tmp.path().join(".socket"); std::fs::create_dir_all(&socket).unwrap(); - std::fs::write( - socket.join("manifest.json"), - r#"{"patches":{}}"#, - ) - .unwrap(); + std::fs::write(socket.join("manifest.json"), r#"{"patches":{}}"#).unwrap(); let (code, stdout, stderr) = run_cmd( tmp.path(), @@ -626,10 +643,16 @@ async fn list_skips_telemetry_in_airgap_mode() { // (it's a local command) rather than crashing before the telemetry // decision, which would also yield zero POSTs. assert_eq!(code, 0, "offline list must succeed; stderr={stderr}"); - let v: serde_json::Value = - serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("list stdout not JSON: {e}\n{stdout}")); - assert_eq!(v["command"], "list", "list must emit its command envelope; stdout={stdout}"); - assert_eq!(v["status"], "success", "offline list status; stdout={stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("list stdout not JSON: {e}\n{stdout}")); + assert_eq!( + v["command"], "list", + "list must emit its command envelope; stdout={stdout}" + ); + assert_eq!( + v["status"], "success", + "offline list status; stdout={stdout}" + ); let count = telemetry_post_count(&mock, None).await; assert_eq!(count, 0, "SOCKET_OFFLINE=1 must suppress patch_listed"); diff --git a/crates/socket-patch-core/src/api/blob_fetcher.rs b/crates/socket-patch-core/src/api/blob_fetcher.rs index d839c42..cb3897c 100644 --- a/crates/socket-patch-core/src/api/blob_fetcher.rs +++ b/crates/socket-patch-core/src/api/blob_fetcher.rs @@ -605,7 +605,10 @@ mod tests { }, ); - PatchManifest { patches, setup: None } + PatchManifest { + patches, + setup: None, + } } #[tokio::test] @@ -831,7 +834,10 @@ mod tests { }, ); } - PatchManifest { patches, setup: None } + PatchManifest { + patches, + setup: None, + } } #[tokio::test] @@ -996,7 +1002,11 @@ mod tests { .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .collect(); - assert_eq!(entries.len(), 1, "only the final entry should remain: {entries:?}"); + assert_eq!( + entries.len(), + 1, + "only the final entry should remain: {entries:?}" + ); assert!( !entries[0].starts_with(".socket-dl-"), "no staging turd should survive: {entries:?}" diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index 1a4b978..33e18cb 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -1462,10 +1462,12 @@ mod tests { #[test] fn classify_auth_error_maps_401_to_unauthorized() { - let err = classify_auth_error(StatusCode::UNAUTHORIZED, false) - .expect("401 must classify"); + let err = classify_auth_error(StatusCode::UNAUTHORIZED, false).expect("401 must classify"); assert!(matches!(err, ApiError::Unauthorized(_))); - assert!(is_fallback_candidate(&err), "401 must drive the proxy fallback"); + assert!( + is_fallback_candidate(&err), + "401 must drive the proxy fallback" + ); } #[test] @@ -1473,7 +1475,10 @@ mod tests { // Proxy path (use_public_proxy = true) → paid-subscriber hint. let proxy = classify_auth_error(StatusCode::FORBIDDEN, true).expect("403 classifies"); assert!(matches!(proxy, ApiError::Forbidden(_))); - assert!(is_fallback_candidate(&proxy), "403 must drive the proxy fallback"); + assert!( + is_fallback_candidate(&proxy), + "403 must drive the proxy fallback" + ); assert!( proxy.to_string().contains("paid subscribers"), "proxy 403 must carry the paid-subscriber hint; got: {proxy}" @@ -1489,8 +1494,8 @@ mod tests { #[test] fn classify_auth_error_maps_429_to_rate_limited() { - let err = classify_auth_error(StatusCode::TOO_MANY_REQUESTS, false) - .expect("429 must classify"); + let err = + classify_auth_error(StatusCode::TOO_MANY_REQUESTS, false).expect("429 must classify"); assert!(matches!(err, ApiError::RateLimited(_))); // Rate limits are intentionally *not* a fallback candidate — they // surface as-is so the operator sees them. diff --git a/crates/socket-patch-core/src/composer_setup/mod.rs b/crates/socket-patch-core/src/composer_setup/mod.rs index 8aacac0..030add7 100644 --- a/crates/socket-patch-core/src/composer_setup/mod.rs +++ b/crates/socket-patch-core/src/composer_setup/mod.rs @@ -155,7 +155,11 @@ fn composer_add(content: &str) -> Result, String> { if !changed { // We created an empty `scripts` object above only if it was absent; // drop it again so a no-op truly changes nothing. - if root.get("scripts").and_then(Value::as_object).is_some_and(Map::is_empty) { + if root + .get("scripts") + .and_then(Value::as_object) + .is_some_and(Map::is_empty) + { root.remove("scripts"); } return Ok(None); @@ -289,7 +293,9 @@ async fn edit( None => Ok(false), Some(new) => { if !dry_run { - fs::write(composer_json, &new).await.map_err(|e| e.to_string())?; + fs::write(composer_json, &new) + .await + .map_err(|e| e.to_string())?; } Ok(true) } @@ -303,7 +309,8 @@ async fn edit( mod tests { use super::*; - const BASIC: &str = "{\n \"name\": \"acme/app\",\n \"require\": {\n \"php\": \">=8.1\"\n }\n}\n"; + const BASIC: &str = + "{\n \"name\": \"acme/app\",\n \"require\": {\n \"php\": \">=8.1\"\n }\n}\n"; fn parse(s: &str) -> Value { serde_json::from_str(s).unwrap() @@ -315,7 +322,10 @@ mod tests { let doc = parse(&out); for event in HOOK_EVENTS { let arr = doc["scripts"][event].as_array().unwrap(); - assert!(arr.iter().any(|v| v == APPLY_COMMAND), "{event} must carry our command"); + assert!( + arr.iter().any(|v| v == APPLY_COMMAND), + "{event} must carry our command" + ); } assert!(is_hook_present(&out)); // Idempotent: second add is a no-op. @@ -329,7 +339,10 @@ mod tests { let pos_name = out.find("\"name\"").unwrap(); let pos_require = out.find("\"require\"").unwrap(); let pos_scripts = out.find("\"scripts\"").unwrap(); - assert!(pos_name < pos_require && pos_require < pos_scripts, "key order preserved:\n{out}"); + assert!( + pos_name < pos_require && pos_require < pos_scripts, + "key order preserved:\n{out}" + ); assert_eq!(parse(&out)["require"]["php"], ">=8.1"); } @@ -371,7 +384,10 @@ mod tests { let added = composer_add(BASIC).unwrap().unwrap(); let removed = composer_remove(&added).unwrap().unwrap(); // We created `scripts` solely for our two events; removing both prunes it. - assert!(parse(&removed).get("scripts").is_none(), "emptied scripts pruned:\n{removed}"); + assert!( + parse(&removed).get("scripts").is_none(), + "emptied scripts pruned:\n{removed}" + ); assert!(!is_hook_present(&removed)); } @@ -396,7 +412,10 @@ mod tests { "{{\n \"scripts\": {{\n \"post-install-cmd\": \"{APPLY_COMMAND}\",\n \"post-update-cmd\": \"{APPLY_COMMAND}\"\n }}\n}}\n" ); assert!(is_hook_present(&already)); - assert!(composer_add(&already).unwrap().is_none(), "exact-string command is idempotent"); + assert!( + composer_add(&already).unwrap().is_none(), + "exact-string command is idempotent" + ); } #[test] @@ -427,11 +446,18 @@ mod tests { assert!(is_hook_present(&fs::read_to_string(&cj).await.unwrap())); // Idempotent. - assert_eq!(add_hook(&project, false).await.status, ComposerSetupStatus::AlreadyConfigured); + assert_eq!( + add_hook(&project, false).await.status, + ComposerSetupStatus::AlreadyConfigured + ); let removed = remove_hook(&project, false).await; assert_eq!(removed.status, ComposerSetupStatus::Updated); - assert_eq!(fs::read_to_string(&cj).await.unwrap(), BASIC, "byte-for-byte restore"); + assert_eq!( + fs::read_to_string(&cj).await.unwrap(), + BASIC, + "byte-for-byte restore" + ); } #[tokio::test] @@ -442,7 +468,11 @@ mod tests { let project = discover_composer_project(dir.path()).await.unwrap(); let res = add_hook(&project, true).await; assert_eq!(res.status, ComposerSetupStatus::Updated); - assert_eq!(fs::read_to_string(&cj).await.unwrap(), BASIC, "dry-run must not write"); + assert_eq!( + fs::read_to_string(&cj).await.unwrap(), + BASIC, + "dry-run must not write" + ); } #[tokio::test] @@ -478,7 +508,10 @@ mod tests { // (composer_add) errors on it; `setup --remove` must too, rather than // silently swallowing it as a no-op success. let err = composer_remove("{\"scripts\": \"oops\"}").unwrap_err(); - assert!(err.contains("\"scripts\" is not a JSON object"), "got: {err}"); + assert!( + err.contains("\"scripts\" is not a JSON object"), + "got: {err}" + ); assert!(composer_remove("{\"scripts\": 7}").is_err()); assert!(composer_remove("{\"scripts\": [\"a\"]}").is_err()); // add and remove agree on what counts as malformed. @@ -518,7 +551,10 @@ mod tests { // add is idempotent let after_add = match composer_add(&json).unwrap() { Some(out) => { - assert!(is_hook_present(&out), "add changed but not present:\n{json}\n{out}"); + assert!( + is_hook_present(&out), + "add changed but not present:\n{json}\n{out}" + ); assert!( composer_add(&out).unwrap().is_none(), "add NOT idempotent:\n{json}\n{out}" @@ -557,9 +593,15 @@ mod tests { ]; for inp in inputs { if let Some(out) = composer_add(inp).unwrap() { - assert!(is_hook_present(&out), "add changed but check false for {inp}\n{out}"); + assert!( + is_hook_present(&out), + "add changed but check false for {inp}\n{out}" + ); // second add is a no-op - assert!(composer_add(&out).unwrap().is_none(), "not idempotent for {inp}"); + assert!( + composer_add(&out).unwrap().is_none(), + "not idempotent for {inp}" + ); // remove undoes let rem = composer_remove(&out).unwrap().unwrap(); assert!(!is_hook_present(&rem), "remove left hook for {inp}\n{rem}"); diff --git a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs index 449cdaa..8bd0305 100644 --- a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs @@ -714,7 +714,9 @@ version = "fake" // Mimic a Composer layout: vendor///composer.json let pkg = vendor.join("monolog").join("monolog"); tokio::fs::create_dir_all(&pkg).await.unwrap(); - tokio::fs::write(pkg.join("composer.json"), "{}").await.unwrap(); + tokio::fs::write(pkg.join("composer.json"), "{}") + .await + .unwrap(); let crawler = CargoCrawler::new(); let options = CrawlerOptions { diff --git a/crates/socket-patch-core/src/crawlers/go_crawler.rs b/crates/socket-patch-core/src/crawlers/go_crawler.rs index 8994198..11413b5 100644 --- a/crates/socket-patch-core/src/crawlers/go_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/go_crawler.rs @@ -832,11 +832,7 @@ mod tests { // a versioned directory must not abort the walk of its siblings. let dir = tempfile::tempdir().unwrap(); - let v1 = dir - .path() - .join("github.com") - .join("foo") - .join("bar@v1.0.0"); + let v1 = dir.path().join("github.com").join("foo").join("bar@v1.0.0"); tokio::fs::create_dir_all(&v1).await.unwrap(); let v2 = dir diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 4d330e3..b827240 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -795,10 +795,9 @@ mod tests { assert_eq!(name, "foo"); assert_eq!(ver, "1.0.0"); - let (ns, name, ver) = NpmCrawler::parse_purl_components( - "pkg:npm/@types/node@20.0.0?maintainer=a@b.com", - ) - .unwrap(); + let (ns, name, ver) = + NpmCrawler::parse_purl_components("pkg:npm/@types/node@20.0.0?maintainer=a@b.com") + .unwrap(); assert_eq!(ns.as_deref(), Some("@types")); assert_eq!(name, "node"); assert_eq!(ver, "20.0.0"); @@ -1043,7 +1042,9 @@ mod tests { assert_eq!(result.len(), 2); // Keyed by the verbatim qualified input, and the stored PURL matches. - let foo = result.get(&unscoped_q).expect("qualified unscoped resolved"); + let foo = result + .get(&unscoped_q) + .expect("qualified unscoped resolved"); assert_eq!(foo.purl, unscoped_q); assert_eq!(foo.name, "foo"); assert_eq!(foo.version, "1.0.0"); diff --git a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs index fa42deb..bfb91d1 100644 --- a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs @@ -881,14 +881,9 @@ mod tests { } // A non-version sibling dir under the id (should be ignored, not // emitted as `@tools`). - tokio::fs::create_dir_all( - dir.path() - .join("newtonsoft.json") - .join("tools") - .join("lib"), - ) - .await - .unwrap(); + tokio::fs::create_dir_all(dir.path().join("newtonsoft.json").join("tools").join("lib")) + .await + .unwrap(); let crawler = NuGetCrawler::new(); let options = CrawlerOptions { diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index c8cb1a5..1868aea 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -1012,11 +1012,7 @@ mod tests { #[tokio::test] async fn test_find_python_dirs_matches_bare_python3() { let dir = tempfile::tempdir().unwrap(); - let dist = dir - .path() - .join("lib") - .join("python3") - .join("dist-packages"); + let dist = dir.path().join("lib").join("python3").join("dist-packages"); tokio::fs::create_dir_all(&dist).await.unwrap(); let results = find_python_dirs(dir.path(), &["lib", "python3.*", "dist-packages"]).await; @@ -1252,4 +1248,3 @@ mod tests { assert!(!result.contains_key("pkg:pypi/flask@3.0.0")); } } - diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 1d14762..50f2c33 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -676,8 +676,16 @@ mod tests { /// correct on Unix. #[test] fn gem_homes_split_honors_os_separator() { - let home_a = PathBuf::from(if cfg!(windows) { r"C:\rubies\3.2.0" } else { "/opt/rubies/3.2.0" }); - let home_b = PathBuf::from(if cfg!(windows) { r"D:\gems\global" } else { "/home/dev/.gem/ruby/3.2.0" }); + let home_a = PathBuf::from(if cfg!(windows) { + r"C:\rubies\3.2.0" + } else { + "/opt/rubies/3.2.0" + }); + let home_b = PathBuf::from(if cfg!(windows) { + r"D:\gems\global" + } else { + "/home/dev/.gem/ruby/3.2.0" + }); let joined = std::env::join_paths([&home_a, &home_b]).unwrap(); let joined = joined.to_str().unwrap(); @@ -694,7 +702,11 @@ mod tests { #[test] fn gem_homes_split_drops_empty_segments() { let sep = if cfg!(windows) { ';' } else { ':' }; - let only = if cfg!(windows) { r"C:\rubies\3.2.0" } else { "/opt/rubies/3.2.0" }; + let only = if cfg!(windows) { + r"C:\rubies\3.2.0" + } else { + "/opt/rubies/3.2.0" + }; let input = format!("{sep}{only}{sep}{sep}"); let dirs = gem_homes_to_gems_dirs(&input); assert_eq!(dirs, vec![PathBuf::from(only).join("gems")]); @@ -717,7 +729,10 @@ mod tests { .find_by_purls(dir.path(), &["pkg:gem/foo@1.0".to_string()]) .await .unwrap(); - assert!(result.is_empty(), "1.0 wrongly matched plain foo-1.0.0: {result:?}"); + assert!( + result.is_empty(), + "1.0 wrongly matched plain foo-1.0.0: {result:?}" + ); } /// `crawl_all` must skip dirs that parse as `-` but are @@ -834,7 +849,10 @@ mod tests { batch_size: 100, }; let paths = crawler.get_gem_paths(&options).await.unwrap(); - assert!(paths.is_empty(), "non-Ruby project must yield no gem paths: {paths:?}"); + assert!( + paths.is_empty(), + "non-Ruby project must yield no gem paths: {paths:?}" + ); } /// Gem names with embedded underscores/digits and multi-dash names diff --git a/crates/socket-patch-core/src/gem_setup/mod.rs b/crates/socket-patch-core/src/gem_setup/mod.rs index 3fe697a..e966bab 100644 --- a/crates/socket-patch-core/src/gem_setup/mod.rs +++ b/crates/socket-patch-core/src/gem_setup/mod.rs @@ -267,10 +267,7 @@ mod tests { let proj = discover_bundler_project(&nested).await.unwrap(); assert_eq!(proj.root, inner, "nearest ancestor Gemfile wins"); - assert_eq!( - fs::read_to_string(&proj.gemfile).await.unwrap(), - "inner\n" - ); + assert_eq!(fs::read_to_string(&proj.gemfile).await.unwrap(), "inner\n"); } #[test] @@ -329,7 +326,11 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let r = add_plugin_files(root, true).await; - assert_eq!(r.status, GemSetupStatus::Updated, "dry-run reports the change"); + assert_eq!( + r.status, + GemSetupStatus::Updated, + "dry-run reports the change" + ); assert!(!plugin_files_present(root).await, "dry-run wrote nothing"); } @@ -341,7 +342,10 @@ mod tests { write(&plugins_rb_path(root), "# my own plugin\n").await; let r = remove_plugin_files(root, false).await; assert_eq!(r.status, GemSetupStatus::AlreadyConfigured); - assert!(plugins_rb_path(root).exists(), "user file must be left alone"); + assert!( + plugins_rb_path(root).exists(), + "user file must be left alone" + ); } #[tokio::test] @@ -371,10 +375,18 @@ mod tests { // accept the stale bytes as already-configured. let dir = tempfile::tempdir().unwrap(); let root = dir.path(); - write(&plugins_rb_path(root), "# Code generated by stale\nold body\n").await; + write( + &plugins_rb_path(root), + "# Code generated by stale\nold body\n", + ) + .await; write(&gemspec_path(root), GEMSPEC).await; let r = add_plugin_files(root, false).await; - assert_eq!(r.status, GemSetupStatus::Updated, "stale plugins.rb is re-synced"); + assert_eq!( + r.status, + GemSetupStatus::Updated, + "stale plugins.rb is re-synced" + ); assert_eq!( fs::read_to_string(plugins_rb_path(root)).await.unwrap(), PLUGINS_RB @@ -391,7 +403,10 @@ mod tests { write(&gemspec_path(root), "# drifted gemspec\n").await; let r = add_plugin_files(root, false).await; assert_eq!(r.status, GemSetupStatus::Updated); - assert_eq!(fs::read_to_string(gemspec_path(root)).await.unwrap(), GEMSPEC); + assert_eq!( + fs::read_to_string(gemspec_path(root)).await.unwrap(), + GEMSPEC + ); assert_eq!( fs::read_to_string(plugins_rb_path(root)).await.unwrap(), PLUGINS_RB @@ -405,7 +420,11 @@ mod tests { let root = dir.path(); add_plugin_files(root, false).await; let r = remove_plugin_files(root, true).await; - assert_eq!(r.status, GemSetupStatus::Updated, "dry-run reports the removal"); + assert_eq!( + r.status, + GemSetupStatus::Updated, + "dry-run reports the removal" + ); assert!( plugin_files_present(root).await, "dry-run remove must not delete the plugin files" diff --git a/crates/socket-patch-core/src/gem_setup/update.rs b/crates/socket-patch-core/src/gem_setup/update.rs index 8cf772c..b7ea96b 100644 --- a/crates/socket-patch-core/src/gem_setup/update.rs +++ b/crates/socket-patch-core/src/gem_setup/update.rs @@ -172,7 +172,10 @@ pub async fn add_plugin_directive(project: &BundlerProject, dry_run: bool) -> Ve /// Unwire the project: strip the Gemfile block (byte-for-byte restore) and /// delete the generated plugin directory. -pub async fn remove_plugin_directive(project: &BundlerProject, dry_run: bool) -> Vec { +pub async fn remove_plugin_directive( + project: &BundlerProject, + dry_run: bool, +) -> Vec { vec![ edit_gemfile_remove(&project.gemfile, dry_run).await, remove_plugin_files(&project.root, dry_run).await, @@ -188,7 +191,10 @@ mod tests { #[test] fn test_add_appends_block_and_is_idempotent() { let out = gemfile_add(GEMFILE).unwrap(); - assert!(out.starts_with(GEMFILE), "original bytes preserved as a prefix"); + assert!( + out.starts_with(GEMFILE), + "original bytes preserved as a prefix" + ); assert!(is_plugin_directive_present(&out)); assert!(out.contains("plugin 'socket-patch'")); assert!(out.contains("File.expand_path('.socket/bundler-plugin', __dir__)")); @@ -200,7 +206,10 @@ mod tests { fn test_add_then_remove_round_trips_byte_for_byte() { let added = gemfile_add(GEMFILE).unwrap(); let removed = gemfile_remove(&added).unwrap(); - assert_eq!(removed, GEMFILE, "remove must restore the original bytes exactly"); + assert_eq!( + removed, GEMFILE, + "remove must restore the original bytes exactly" + ); } #[test] @@ -377,7 +386,9 @@ mod tests { // Idempotent re-run. let again = add_plugin_directive(&project, false).await; - assert!(again.iter().all(|r| r.status == GemSetupStatus::AlreadyConfigured)); + assert!(again + .iter() + .all(|r| r.status == GemSetupStatus::AlreadyConfigured)); let removed = remove_plugin_directive(&project, false).await; assert!(removed.iter().all(|r| r.status == GemSetupStatus::Updated)); diff --git a/crates/socket-patch-core/src/manifest/operations.rs b/crates/socket-patch-core/src/manifest/operations.rs index d7381db..8ec9d19 100644 --- a/crates/socket-patch-core/src/manifest/operations.rs +++ b/crates/socket-patch-core/src/manifest/operations.rs @@ -212,7 +212,10 @@ mod tests { }, ); - PatchManifest { patches, setup: None } + PatchManifest { + patches, + setup: None, + } } #[test] diff --git a/crates/socket-patch-core/src/manifest/schema.rs b/crates/socket-patch-core/src/manifest/schema.rs index bfcc128..abb8c53 100644 --- a/crates/socket-patch-core/src/manifest/schema.rs +++ b/crates/socket-patch-core/src/manifest/schema.rs @@ -566,12 +566,18 @@ mod tests { }), }; let json = serde_json::to_string(&manifest).unwrap(); - assert!(json.contains("\"setup\""), "populated setup must be emitted"); + assert!( + json.contains("\"setup\""), + "populated setup must be emitted" + ); assert!(json.contains("crates/member-a")); assert!(json.contains("pypi")); let reparsed: PatchManifest = serde_json::from_str(&json).unwrap(); - assert_eq!(manifest, reparsed, "populated setup must round-trip exactly"); + assert_eq!( + manifest, reparsed, + "populated setup must round-trip exactly" + ); let setup = reparsed.setup.unwrap(); assert_eq!(setup.exclude, vec!["crates/member-a".to_string()]); assert_eq!(setup.manual, vec!["pypi".to_string()]); diff --git a/crates/socket-patch-core/src/package_json/detect.rs b/crates/socket-patch-core/src/package_json/detect.rs index 977c82d..8a671fb 100644 --- a/crates/socket-patch-core/src/package_json/detect.rs +++ b/crates/socket-patch-core/src/package_json/detect.rs @@ -768,7 +768,10 @@ mod tests { // violating the documented contract — `(true, ..)` means a socket-patch // segment was removed, which did not happen. let (changed, new) = remove_socket_patch_from_script("echo a && && echo b"); - assert!(!changed, "no socket-patch present, must not report a removal"); + assert!( + !changed, + "no socket-patch present, must not report a removal" + ); assert_eq!(new.as_deref(), Some("echo a && && echo b")); } @@ -865,8 +868,7 @@ mod tests { fn test_remove_content_roundtrip_with_update() { // update then remove must return to a no-socket-patch state. let original = r#"{"name":"x","scripts":{"build":"tsc"}}"#; - let (_, updated, ..) = - update_package_json_content(original, PackageManager::Npm).unwrap(); + let (_, updated, ..) = update_package_json_content(original, PackageManager::Npm).unwrap(); assert!(updated.contains("socket-patch")); let (modified, removed, _) = remove_package_json_content(&updated).unwrap(); @@ -880,7 +882,8 @@ mod tests { #[test] fn test_remove_content_idempotent() { - let configured = r#"{"name":"x","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply"}}"#; + let configured = + r#"{"name":"x","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply"}}"#; let (modified1, removed, _) = remove_package_json_content(configured).unwrap(); assert!(modified1); let (modified2, _, _) = remove_package_json_content(&removed).unwrap(); @@ -891,8 +894,7 @@ mod tests { fn test_remove_content_roundtrip_pnpm() { // update (pnpm) then remove must fully revert to a no-socket-patch state. let original = r#"{"name":"x","scripts":{"build":"tsc"}}"#; - let (_, updated, ..) = - update_package_json_content(original, PackageManager::Pnpm).unwrap(); + let (_, updated, ..) = update_package_json_content(original, PackageManager::Pnpm).unwrap(); assert!(updated.contains("pnpm dlx @socketsecurity/socket-patch apply")); let (modified, removed, _) = remove_package_json_content(&updated).unwrap(); diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs index 3e0254c..d015bfb 100644 --- a/crates/socket-patch-core/src/package_json/find.rs +++ b/crates/socket-patch-core/src/package_json/find.rs @@ -333,7 +333,11 @@ async fn search_one_level(dir: &Path, results: &mut Vec) { // recursive `**` searchers below deliberately do NOT follow symlinks, // to avoid loops/escapes — there a symlink's `is_dir() == false` is the // desired skip.) - if !fs::metadata(&path).await.map(|m| m.is_dir()).unwrap_or(false) { + if !fs::metadata(&path) + .await + .map(|m| m.is_dir()) + .unwrap_or(false) + { continue; } // A `dir/*` pattern must not pick up node_modules/hidden/output dirs as @@ -790,7 +794,11 @@ mod tests { "nested-workspace leaf must be found via recursion: {paths:?}" ); // root + inner + leaf, no duplicates. - assert_eq!(result.files.len(), 3, "exactly root + inner + leaf: {paths:?}"); + assert_eq!( + result.files.len(), + 3, + "exactly root + inner + leaf: {paths:?}" + ); } #[tokio::test] @@ -978,13 +986,16 @@ mod tests { fs::write(real.join("package.json"), r#"{"name":"a"}"#) .await .unwrap(); - fs::create_dir_all(dir.path().join("packages")).await.unwrap(); + fs::create_dir_all(dir.path().join("packages")) + .await + .unwrap(); std::os::unix::fs::symlink(&real, dir.path().join("packages").join("a")).unwrap(); let result = find_package_json_files(dir.path()).await; let workspace_count = result.files.iter().filter(|f| f.is_workspace).count(); assert_eq!( - workspace_count, 1, + workspace_count, + 1, "symlinked workspace member must be discovered: {:?}", result.files.iter().map(|f| &f.path).collect::>() ); @@ -1021,7 +1032,8 @@ mod tests { let result = find_package_json_files(dir.path()).await; let workspace_count = result.files.iter().filter(|f| f.is_workspace).count(); assert_eq!( - workspace_count, 1, + workspace_count, + 1, "only the real member must be found; symlinks not followed: {:?}", result.files.iter().map(|f| &f.path).collect::>() ); diff --git a/crates/socket-patch-core/src/package_json/update.rs b/crates/socket-patch-core/src/package_json/update.rs index ac00b96..b445b38 100644 --- a/crates/socket-patch-core/src/package_json/update.rs +++ b/crates/socket-patch-core/src/package_json/update.rs @@ -606,7 +606,8 @@ mod tests { async fn test_remove_dry_run_does_not_write() { let dir = tempfile::tempdir().unwrap(); let pkg = dir.path().join("package.json"); - let original = r#"{"name":"x","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply"}}"#; + let original = + r#"{"name":"x","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply"}}"#; fs::write(&pkg, original).await.unwrap(); let result = remove_package_json(&pkg, true).await; assert_eq!(result.status, RemoveStatus::Removed); diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index 4ff6904..3e379a3 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -136,7 +136,8 @@ pub fn is_safe_relative_subpath(normalized: &str) -> bool { if path.is_absolute() { return false; } - path.components().all(|c| matches!(c, Component::Normal(_) | Component::CurDir)) + path.components() + .all(|c| matches!(c, Component::Normal(_) | Component::CurDir)) } /// Verify a single file can be patched. @@ -1066,7 +1067,9 @@ mod tests { } // The `package/`-prefixed escape that previously slipped through: // `package//etc/passwd` normalizes to `/etc/passwd`. - assert!(!is_safe_relative_subpath(normalize_file_path("package//etc/passwd"))); + assert!(!is_safe_relative_subpath(normalize_file_path( + "package//etc/passwd" + ))); } #[tokio::test] @@ -2258,12 +2261,22 @@ mod tests { assert_eq!(tokio::fs::read(&path).await.unwrap(), patched); // New file still defaults to read-only. assert_eq!( - tokio::fs::metadata(&path).await.unwrap().permissions().mode() & 0o7777, + tokio::fs::metadata(&path) + .await + .unwrap() + .permissions() + .mode() + & 0o7777, 0o444 ); // The pre-existing read-only package root is restored exactly. assert_eq!( - tokio::fs::metadata(dir.path()).await.unwrap().permissions().mode() & 0o7777, + tokio::fs::metadata(dir.path()) + .await + .unwrap() + .permissions() + .mode() + & 0o7777, 0o555, "package root mode must be restored after the mkdir" ); @@ -2307,7 +2320,12 @@ mod tests { assert_eq!(tokio::fs::read(sub.join("new.js")).await.unwrap(), patched); assert_eq!( - tokio::fs::metadata(&sub).await.unwrap().permissions().mode() & 0o7777, + tokio::fs::metadata(&sub) + .await + .unwrap() + .permissions() + .mode() + & 0o7777, 0o555, "existing subdir mode must be restored" ); diff --git a/crates/socket-patch-core/src/patch/apply_lock.rs b/crates/socket-patch-core/src/patch/apply_lock.rs index 9acefd7..87e28ea 100644 --- a/crates/socket-patch-core/src/patch/apply_lock.rs +++ b/crates/socket-patch-core/src/patch/apply_lock.rs @@ -305,7 +305,7 @@ mod tests { let releaser = std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(150)); drop(held); // releases the OS lock - // Keep the tempdir alive until the waiter has acquired. + // Keep the tempdir alive until the waiter has acquired. std::thread::sleep(Duration::from_millis(200)); drop(dir2); }); diff --git a/crates/socket-patch-core/src/patch/copy_tree.rs b/crates/socket-patch-core/src/patch/copy_tree.rs index c54ef84..707f268 100644 --- a/crates/socket-patch-core/src/patch/copy_tree.rs +++ b/crates/socket-patch-core/src/patch/copy_tree.rs @@ -137,7 +137,9 @@ mod tests { fs::write(src.path().join("sub/.cargo-checksum.json"), b"{}").unwrap(); fs::write(src.path().join("sub/keep.rs"), b"code").unwrap(); - fresh_copy(src.path(), &d, Some(".cargo-checksum.json")).await.unwrap(); + fresh_copy(src.path(), &d, Some(".cargo-checksum.json")) + .await + .unwrap(); assert!(!d.join(".cargo-checksum.json").exists()); assert!(!d.join("sub/.cargo-checksum.json").exists()); @@ -159,7 +161,10 @@ mod tests { assert!(d.join("real.txt").exists()); assert!(!d.join("link.txt").exists(), "symlink should be skipped"); - assert!(!d.join("escape").exists(), "escaping symlink should be skipped"); + assert!( + !d.join("escape").exists(), + "escaping symlink should be skipped" + ); } #[cfg(unix)] @@ -171,7 +176,11 @@ mod tests { fs::write(root.join("ro_dir/inner/f.txt"), b"x").unwrap(); fs::write(root.join("ro_dir/g.txt"), b"y").unwrap(); // Make files read-only then dirs read-only (bottom-up). - fs::set_permissions(root.join("ro_dir/inner/f.txt"), fs::Permissions::from_mode(0o444)).unwrap(); + fs::set_permissions( + root.join("ro_dir/inner/f.txt"), + fs::Permissions::from_mode(0o444), + ) + .unwrap(); fs::set_permissions(root.join("ro_dir/g.txt"), fs::Permissions::from_mode(0o444)).unwrap(); fs::set_permissions(root.join("ro_dir/inner"), fs::Permissions::from_mode(0o555)).unwrap(); fs::set_permissions(root.join("ro_dir"), fs::Permissions::from_mode(0o555)).unwrap(); @@ -217,13 +226,21 @@ mod tests { let d = dst.path().join("copy"); fs::create_dir_all(src.path().join("ro")).unwrap(); fs::write(src.path().join("ro/f.txt"), b"x").unwrap(); - fs::set_permissions(src.path().join("ro/f.txt"), fs::Permissions::from_mode(0o444)).unwrap(); + fs::set_permissions( + src.path().join("ro/f.txt"), + fs::Permissions::from_mode(0o444), + ) + .unwrap(); fs::set_permissions(src.path().join("ro"), fs::Permissions::from_mode(0o555)).unwrap(); fresh_copy(src.path(), &d, None).await.unwrap(); let dir_mode = fs::metadata(d.join("ro")).unwrap().permissions().mode() & 0o777; - assert!(dir_mode & 0o200 != 0, "copied dir should be writable, got {:o}", dir_mode); + assert!( + dir_mode & 0o200 != 0, + "copied dir should be writable, got {:o}", + dir_mode + ); // cleanup readonly src fs::set_permissions(src.path().join("ro"), fs::Permissions::from_mode(0o755)).unwrap(); } @@ -272,8 +289,11 @@ mod tests { remove_tree(&root).await.unwrap(); let mode = fs::metadata(&outside).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600, "external symlink target perms were changed to {:o}", mode); + assert_eq!( + mode, 0o600, + "external symlink target perms were changed to {:o}", + mode + ); assert!(outside.exists()); } } - diff --git a/crates/socket-patch-core/src/patch/diff.rs b/crates/socket-patch-core/src/patch/diff.rs index bd68e08..b6cae26 100644 --- a/crates/socket-patch-core/src/patch/diff.rs +++ b/crates/socket-patch-core/src/patch/diff.rs @@ -251,8 +251,7 @@ mod tests { // ...and short / bad-magic inputs are deferred to Bspatch::new, so the // guard returns Ok for them rather than masking the canonical error. validate_bsdiff_header(b"too short").expect("short input deferred"); - validate_bsdiff_header(b"NOTBSDIFF.........................") - .expect("bad magic deferred"); + validate_bsdiff_header(b"NOTBSDIFF.........................").expect("bad magic deferred"); } #[test] diff --git a/crates/socket-patch-core/src/patch/go_mod_edit.rs b/crates/socket-patch-core/src/patch/go_mod_edit.rs index 50b6cbd..45ccb1e 100644 --- a/crates/socket-patch-core/src/patch/go_mod_edit.rs +++ b/crates/socket-patch-core/src/patch/go_mod_edit.rs @@ -557,14 +557,23 @@ mod tests { #[test] fn test_detect_owner() { use ReplaceOwner::*; - assert_eq!(detect_owner("./.socket/go-patches/github.com/x/y@v1.0.0"), Some(GoPatches)); + assert_eq!( + detect_owner("./.socket/go-patches/github.com/x/y@v1.0.0"), + Some(GoPatches) + ); assert_eq!(detect_owner(".socket/go-patches/x@v1.0.0"), Some(GoPatches)); - assert_eq!(detect_owner("sub/.socket/go-patches/x@v1.0.0"), Some(GoPatches)); + assert_eq!( + detect_owner("sub/.socket/go-patches/x@v1.0.0"), + Some(GoPatches) + ); assert_eq!( detect_owner("./.socket/vendor/golang/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/github.com/x/y@v1.0.0"), Some(Vendor) ); - assert_eq!(detect_owner(".socket/vendor/golang/u/x@v1.0.0"), Some(Vendor)); + assert_eq!( + detect_owner(".socket/vendor/golang/u/x@v1.0.0"), + Some(Vendor) + ); assert_eq!(detect_owner("../fork"), None); assert_eq!(detect_owner("./vendor/x"), None); assert_eq!(detect_owner("/abs/.socketX/go-patches/x"), None); @@ -611,26 +620,44 @@ replace ( "; let entries = parse_replace_entries(gomod); assert_eq!(entries.len(), 3); - let bar = entries.iter().find(|e| e.module == "github.com/foo/bar").unwrap(); + let bar = entries + .iter() + .find(|e| e.module == "github.com/foo/bar") + .unwrap(); assert!(bar.socket_owned()); assert_eq!(bar.version.as_deref(), Some("v1.4.2")); - let baz = entries.iter().find(|e| e.module == "example.com/baz").unwrap(); + let baz = entries + .iter() + .find(|e| e.module == "example.com/baz") + .unwrap(); assert!(!baz.socket_owned()); assert_eq!(baz.path.as_deref(), Some("../local-baz")); - let qux = entries.iter().find(|e| e.module == "example.com/qux").unwrap(); + let qux = entries + .iter() + .find(|e| e.module == "example.com/qux") + .unwrap(); assert!(!qux.socket_owned()); assert_eq!(qux.path, None, "module-to-module replacement has no path"); let req = parse_required_versions(gomod); - assert_eq!(req.get("github.com/foo/bar").map(String::as_str), Some("v1.4.2")); - assert_eq!(req.get("example.com/baz").map(String::as_str), Some("v2.0.0")); + assert_eq!( + req.get("github.com/foo/bar").map(String::as_str), + Some("v1.4.2") + ); + assert_eq!( + req.get("example.com/baz").map(String::as_str), + Some("v2.0.0") + ); } #[test] fn test_parse_require_single() { let gomod = "module m\n\ngo 1.21\n\nrequire github.com/x/y v1.0.0\n"; let req = parse_required_versions(gomod); - assert_eq!(req.get("github.com/x/y").map(String::as_str), Some("v1.0.0")); + assert_eq!( + req.get("github.com/x/y").map(String::as_str), + Some("v1.0.0") + ); } // ── upsert ─────────────────────────────────────────────────────── @@ -647,9 +674,11 @@ replace ( assert!(out.contains("require github.com/foo/bar v1.4.2")); assert!(out.ends_with('\n')); // Idempotent. - assert!(upsert_replace_entry(&out, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR) - .unwrap() - .is_none()); + assert!( + upsert_replace_entry(&out, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR) + .unwrap() + .is_none() + ); } #[test] @@ -663,7 +692,13 @@ replace ( )); assert!(!out.contains("bar@v1.4.2"), "old version line gone"); // Exactly one replace for the module. - assert_eq!(parse_replace_entries(&out).iter().filter(|e| e.module == "github.com/foo/bar").count(), 1); + assert_eq!( + parse_replace_entries(&out) + .iter() + .filter(|e| e.module == "github.com/foo/bar") + .count(), + 1 + ); } #[test] @@ -673,14 +708,18 @@ replace ( .unwrap() .unwrap(); // Still a block member (indented, no `replace ` keyword), version bumped. - assert!(out.contains("\tgithub.com/foo/bar v1.5.0 => ./.socket/go-patches/github.com/foo/bar@v1.5.0")); + assert!(out.contains( + "\tgithub.com/foo/bar v1.5.0 => ./.socket/go-patches/github.com/foo/bar@v1.5.0" + )); assert!(out.contains("replace (")); } #[test] fn test_upsert_refuses_user_authored_same_version() { let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ../fork\n"; - assert!(upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR).is_err()); + assert!( + upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR).is_err() + ); } #[test] @@ -691,13 +730,17 @@ replace ( .unwrap() .unwrap(); assert!(out.contains("replace github.com/foo/bar v1.0.0 => ../fork")); - assert!(out.contains("replace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2")); + assert!(out.contains( + "replace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2" + )); } #[test] fn test_upsert_refuses_versionless_user_catchall() { let gomod = "module m\n\nreplace github.com/foo/bar => ../fork\n"; - assert!(upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR).is_err()); + assert!( + upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR).is_err() + ); } #[test] @@ -708,18 +751,24 @@ replace ( let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR) .unwrap() .unwrap(); - assert!(out.contains("replace example.com/other => ../other-fork"), "user catch-all preserved"); + assert!( + out.contains("replace example.com/other => ../other-fork"), + "user catch-all preserved" + ); assert!(out.contains( "replace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2" )); let entries = parse_replace_entries(&out); - assert!(entries.iter().any(|e| e.module == "example.com/other" && !e.socket_owned())); - assert!(entries.iter().any(|e| e.module == "github.com/foo/bar" && e.socket_owned())); + assert!(entries + .iter() + .any(|e| e.module == "example.com/other" && !e.socket_owned())); + assert!(entries + .iter() + .any(|e| e.module == "github.com/foo/bar" && e.socket_owned())); } // ── cross-owner takeover + owner filtering ─────────────────────── - const VENDOR_BASE: &str = - ".socket/vendor/golang/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const VENDOR_BASE: &str = ".socket/vendor/golang/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; /// Vendor takes over an apply (go-patches) redirect: the SAME socket-owned /// line is rewritten in place to the vendor path — never a remove+add pair, @@ -736,7 +785,10 @@ replace ( ))); let entries = parse_replace_entries(&out); assert_eq!( - entries.iter().filter(|e| e.module == "github.com/foo/bar").count(), + entries + .iter() + .filter(|e| e.module == "github.com/foo/bar") + .count(), 1, "exactly one directive for the module" ); @@ -811,14 +863,22 @@ replace ( #[test] fn test_remove_leaves_user_replace() { let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ../fork\n"; - assert!(remove_replace_entry(gomod, "github.com/foo/bar", ReplaceOwner::GoPatches).unwrap().is_none()); + assert!( + remove_replace_entry(gomod, "github.com/foo/bar", ReplaceOwner::GoPatches) + .unwrap() + .is_none() + ); } #[test] fn test_remove_absent_is_noop() { - assert!(remove_replace_entry("module m\n\ngo 1.21\n", "github.com/foo/bar", ReplaceOwner::GoPatches) - .unwrap() - .is_none()); + assert!(remove_replace_entry( + "module m\n\ngo 1.21\n", + "github.com/foo/bar", + ReplaceOwner::GoPatches + ) + .unwrap() + .is_none()); } // ── async round-trip ───────────────────────────────────────────── @@ -832,11 +892,20 @@ replace ( .await .unwrap(); - assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR, false) - .await - .unwrap()); + assert!(ensure_replace_entry( + dir.path(), + "github.com/foo/bar", + "v1.4.2", + GO_PATCHES_DIR, + false + ) + .await + .unwrap()); let entries = read_replace_entries(dir.path()).await; - let bar = entries.iter().find(|e| e.module == "github.com/foo/bar").unwrap(); + let bar = entries + .iter() + .find(|e| e.module == "github.com/foo/bar") + .unwrap(); assert!(bar.socket_owned()); assert_eq!( bar.path.as_deref(), @@ -844,16 +913,30 @@ replace ( ); // Required-version cross-check source. let req = read_required_versions(dir.path()).await.unwrap(); - assert_eq!(req.get("github.com/foo/bar").map(String::as_str), Some("v1.4.2")); + assert_eq!( + req.get("github.com/foo/bar").map(String::as_str), + Some("v1.4.2") + ); // Idempotent on disk. - assert!(!ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR, false) - .await - .unwrap()); + assert!(!ensure_replace_entry( + dir.path(), + "github.com/foo/bar", + "v1.4.2", + GO_PATCHES_DIR, + false + ) + .await + .unwrap()); // Drop. - assert!(drop_replace_entry(dir.path(), "github.com/foo/bar", ReplaceOwner::GoPatches, false) - .await - .unwrap()); + assert!(drop_replace_entry( + dir.path(), + "github.com/foo/bar", + ReplaceOwner::GoPatches, + false + ) + .await + .unwrap()); assert!(read_replace_entries(dir.path()).await.is_empty()); } @@ -862,9 +945,15 @@ replace ( let dir = tempfile::tempdir().unwrap(); let body = "module m\n\ngo 1.21\n"; fs::write(dir.path().join("go.mod"), body).await.unwrap(); - let changed = ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR, true) - .await - .unwrap(); + let changed = ensure_replace_entry( + dir.path(), + "github.com/foo/bar", + "v1.4.2", + GO_PATCHES_DIR, + true, + ) + .await + .unwrap(); assert!(changed, "dry-run reports the change it would make"); assert_eq!( fs::read_to_string(dir.path().join("go.mod")).await.unwrap(), @@ -888,9 +977,15 @@ replace ( .await .unwrap(); - assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR, false) - .await - .unwrap()); + assert!(ensure_replace_entry( + dir.path(), + "github.com/foo/bar", + "v1.4.2", + GO_PATCHES_DIR, + false + ) + .await + .unwrap()); // Only go.mod should remain in the project root. let mut names: Vec = std::fs::read_dir(dir.path()) @@ -913,11 +1008,19 @@ replace ( async fn test_ensure_overwrite_preserves_unrelated_content_on_disk() { let dir = tempfile::tempdir().unwrap(); let original = "module example.com/app\n\ngo 1.21\n\n// keep me\nrequire github.com/foo/bar v1.4.2\n\nreplace example.com/other v2.0.0 => ../other-fork\n"; - fs::write(dir.path().join("go.mod"), original).await.unwrap(); - - assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR, false) + fs::write(dir.path().join("go.mod"), original) .await - .unwrap()); + .unwrap(); + + assert!(ensure_replace_entry( + dir.path(), + "github.com/foo/bar", + "v1.4.2", + GO_PATCHES_DIR, + false + ) + .await + .unwrap()); let on_disk = fs::read_to_string(dir.path().join("go.mod")).await.unwrap(); // Our directive landed… @@ -929,14 +1032,23 @@ replace ( assert!(on_disk.contains("// keep me")); assert!(on_disk.contains("require github.com/foo/bar v1.4.2")); assert!(on_disk.contains("replace example.com/other v2.0.0 => ../other-fork")); - assert!(on_disk.starts_with(original), "original content kept verbatim as a prefix"); + assert!( + on_disk.starts_with(original), + "original content kept verbatim as a prefix" + ); } #[tokio::test] async fn test_ensure_missing_go_mod_errors() { let dir = tempfile::tempdir().unwrap(); - assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR, false) - .await - .is_err()); + assert!(ensure_replace_entry( + dir.path(), + "github.com/foo/bar", + "v1.4.2", + GO_PATCHES_DIR, + false + ) + .await + .is_err()); } } diff --git a/crates/socket-patch-core/src/patch/go_redirect.rs b/crates/socket-patch-core/src/patch/go_redirect.rs index 8b50bf0..33d351a 100644 --- a/crates/socket-patch-core/src/patch/go_redirect.rs +++ b/crates/socket-patch-core/src/patch/go_redirect.rs @@ -453,8 +453,9 @@ pub async fn verify_go_redirect_state( .iter() .find(|e| e.module == module && e.owner == Some(ReplaceOwner::GoPatches)); match socket { - Some(e) if e.path.as_deref() == Some(expected.as_str()) - && e.version.as_deref() == Some(version) => {} + Some(e) + if e.path.as_deref() == Some(expected.as_str()) + && e.version.as_deref() == Some(version) => {} Some(e) => drifts.push(Drift::WrongReplacePath { purl: purl.clone(), expected, @@ -685,7 +686,9 @@ mod tests { let pristine = root.join("cache/github.com/foo/bar@v1.4.2"); tokio::fs::create_dir_all(&pristine).await.unwrap(); - tokio::fs::write(pristine.join("bar.go"), PRISTINE).await.unwrap(); + tokio::fs::write(pristine.join("bar.go"), PRISTINE) + .await + .unwrap(); tokio::fs::write( pristine.join("go.mod"), "module github.com/foo/bar\n\ngo 1.21\n", @@ -744,12 +747,24 @@ mod tests { let sources = PatchSources::blobs_only(&blobs); let result = apply_go_redirect( - PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, - false, false, + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, ) .await; assert!(result.success, "apply failed: {:?}", result.error); - assert!(result.sidecar.is_none(), "replace copy must not emit a sidecar"); + assert!( + result.sidecar.is_none(), + "replace copy must not emit a sidecar" + ); // Copy exists with patched bytes + a go.mod. let copy = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2"); @@ -759,7 +774,10 @@ mod tests { assert!(copy.join("go.mod").exists()); // Module cache pristine untouched. - assert_eq!(tokio::fs::read(pristine.join("bar.go")).await.unwrap(), PRISTINE); + assert_eq!( + tokio::fs::read(pristine.join("bar.go")).await.unwrap(), + PRISTINE + ); // go.mod replace points at the copy. let entries = read_replace_entries(root).await; @@ -777,18 +795,55 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; let copy = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2/bar.go"); let gomod = root.join("go.mod"); let body1 = tokio::fs::read(©).await.unwrap(); let mod1 = tokio::fs::read_to_string(&gomod).await.unwrap(); - let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + let result = apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; assert!(result.success); - assert!(result.files_patched.is_empty(), "in-sync resync patches nothing"); - assert_eq!(tokio::fs::read(©).await.unwrap(), body1, "copy unchanged"); - assert_eq!(tokio::fs::read_to_string(&gomod).await.unwrap(), mod1, "go.mod unchanged"); + assert!( + result.files_patched.is_empty(), + "in-sync resync patches nothing" + ); + assert_eq!( + tokio::fs::read(©).await.unwrap(), + body1, + "copy unchanged" + ); + assert_eq!( + tokio::fs::read_to_string(&gomod).await.unwrap(), + mod1, + "go.mod unchanged" + ); } #[tokio::test] @@ -796,12 +851,38 @@ mod tests { let (dir, blobs, pristine, files, after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; let copy = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2/bar.go"); tokio::fs::write(©, b"corrupted").await.unwrap(); - let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + let result = apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; assert!(result.success); assert_eq!(git_sha(&tokio::fs::read(©).await.unwrap()), after); } @@ -810,13 +891,35 @@ mod tests { async fn test_dry_run_writes_nothing() { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); - let pristine_gomod = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); + let pristine_gomod = tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(); let sources = PatchSources::blobs_only(&blobs); - let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, true, false).await; + let result = apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + true, + false, + ) + .await; assert!(result.success); - assert!(!root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists()); + assert!(!root + .join(".socket/go-patches/github.com/foo/bar@v1.4.2") + .exists()); // go.mod unchanged (no replace added). - assert_eq!(tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(), pristine_gomod); + assert_eq!( + tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(), + pristine_gomod + ); } #[tokio::test] @@ -827,10 +930,25 @@ mod tests { tokio::fs::create_dir_all(&empty).await.unwrap(); let sources = PatchSources::blobs_only(&empty); - let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + let result = apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; assert!(!result.success); assert!( - !root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists(), + !root + .join(".socket/go-patches/github.com/foo/bar@v1.4.2") + .exists(), "half-built copy must be rolled back" ); // No replace directive written. @@ -842,10 +960,25 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); // Simulate a pre-modules package: remove the go.mod from the pristine src. - tokio::fs::remove_file(pristine.join("go.mod")).await.unwrap(); + tokio::fs::remove_file(pristine.join("go.mod")) + .await + .unwrap(); let sources = PatchSources::blobs_only(&blobs); - let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + let result = apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; assert!(result.success, "apply failed: {:?}", result.error); let synthesized = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2/go.mod"); assert_eq!( @@ -863,19 +996,32 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); // Pre-modules package → synthesis path is exercised. - tokio::fs::remove_file(pristine.join("go.mod")).await.unwrap(); + tokio::fs::remove_file(pristine.join("go.mod")) + .await + .unwrap(); let sources = PatchSources::blobs_only(&blobs); let result = apply_go_redirect( - PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, - false, false, + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, ) .await; assert!(result.success, "apply failed: {:?}", result.error); let copy = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2"); assert_eq!( - tokio::fs::read_to_string(copy.join("go.mod")).await.unwrap(), + tokio::fs::read_to_string(copy.join("go.mod")) + .await + .unwrap(), "module github.com/foo/bar\n", "synthesized go.mod must be the complete module line, never torn/empty" ); @@ -895,12 +1041,27 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; remove_go_redirect(PURL, root, GO_PATCHES_DIR, ReplaceOwner::GoPatches, false) .await .unwrap(); - assert!(!root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists()); + assert!(!root + .join(".socket/go-patches/github.com/foo/bar@v1.4.2") + .exists()); assert!(read_replace_entries(root).await.is_empty()); // The require directive (not socket-owned) survives. assert!(tokio::fs::read_to_string(root.join("go.mod")) @@ -914,12 +1075,27 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; let desired: HashSet = HashSet::new(); let removed = reconcile_go_redirects(root, &desired, false).await; assert!(removed.contains(&PURL.to_string())); - assert!(!root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists()); + assert!(!root + .join(".socket/go-patches/github.com/foo/bar@v1.4.2") + .exists()); assert!(read_replace_entries(root).await.is_empty()); } @@ -928,9 +1104,24 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; // Add a user-authored replace. - let mut body = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); + let mut body = tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(); body.push_str("replace example.com/other v1.0.0 => ../other-fork\n"); tokio::fs::write(root.join("go.mod"), body).await.unwrap(); @@ -938,8 +1129,12 @@ mod tests { let removed = reconcile_go_redirects(root, &desired, false).await; assert!(removed.is_empty()); let entries = read_replace_entries(root).await; - assert!(entries.iter().any(|e| e.module == MODULE && e.socket_owned())); - assert!(entries.iter().any(|e| e.module == "example.com/other" && !e.socket_owned())); + assert!(entries + .iter() + .any(|e| e.module == MODULE && e.socket_owned())); + assert!(entries + .iter() + .any(|e| e.module == "example.com/other" && !e.socket_owned())); } #[tokio::test] @@ -947,26 +1142,51 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; let manifest = manifest_with(&files); let desired: HashSet = [PURL.to_string()].into_iter().collect(); // Clean → Ok. Registry-independence: delete the pristine source first. tokio::fs::remove_dir_all(&pristine).await.unwrap(); - assert!(verify_go_redirect_state(root, &manifest, &desired).await.is_ok()); + assert!(verify_go_redirect_state(root, &manifest, &desired) + .await + .is_ok()); // Corrupt a file → StaleCopy. let copy = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2/bar.go"); tokio::fs::write(©, b"x").await.unwrap(); - let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); + let drifts = verify_go_redirect_state(root, &manifest, &desired) + .await + .unwrap_err(); assert!(drifts.iter().any(|d| matches!(d, Drift::StaleCopy { .. }))); // Delete the copy → MissingCopy (directive still present). - tokio::fs::remove_dir_all(root.join(".socket/go-patches/github.com/foo/bar@v1.4.2")).await.unwrap(); - let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); - assert!(drifts.iter().any(|d| matches!(d, Drift::MissingCopy { .. }))); - assert!(!drifts.iter().any(|d| matches!(d, Drift::MissingReplace { .. }))); + tokio::fs::remove_dir_all(root.join(".socket/go-patches/github.com/foo/bar@v1.4.2")) + .await + .unwrap(); + let drifts = verify_go_redirect_state(root, &manifest, &desired) + .await + .unwrap_err(); + assert!(drifts + .iter() + .any(|d| matches!(d, Drift::MissingCopy { .. }))); + assert!(!drifts + .iter() + .any(|d| matches!(d, Drift::MissingReplace { .. }))); } #[tokio::test] @@ -974,7 +1194,20 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; // Drop the directive but keep the copy. go_mod_edit::drop_replace_entry(root, MODULE, ReplaceOwner::GoPatches, false) .await @@ -982,8 +1215,12 @@ mod tests { let manifest = manifest_with(&files); let desired: HashSet = [PURL.to_string()].into_iter().collect(); - let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); - assert!(drifts.iter().any(|d| matches!(d, Drift::MissingReplace { .. }))); + let drifts = verify_go_redirect_state(root, &manifest, &desired) + .await + .unwrap_err(); + assert!(drifts + .iter() + .any(|d| matches!(d, Drift::MissingReplace { .. }))); } #[tokio::test] @@ -991,11 +1228,26 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; let manifest = manifest_with(&files); let desired: HashSet = [PURL.to_string()].into_iter().collect(); - assert!(verify_go_redirect_state(root, &manifest, &desired).await.is_ok()); + assert!(verify_go_redirect_state(root, &manifest, &desired) + .await + .is_ok()); // Repin the socket-owned replace at a DIFFERENT version while the copy // stays byte-correct. Go keys replace by module+version, so this @@ -1004,9 +1256,13 @@ mod tests { .await .unwrap(); // ensure_replace refreshed our entry to v9.9.9; the v1.4.2 copy is now orphaned by directive. - let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); + let drifts = verify_go_redirect_state(root, &manifest, &desired) + .await + .unwrap_err(); assert!( - drifts.iter().any(|d| matches!(d, Drift::WrongReplacePath { .. })), + drifts + .iter() + .any(|d| matches!(d, Drift::WrongReplacePath { .. })), "stale replace version must be flagged: {drifts:?}" ); } @@ -1016,11 +1272,26 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; let manifest = manifest_with(&files); let desired: HashSet = [PURL.to_string()].into_iter().collect(); - assert!(verify_go_redirect_state(root, &manifest, &desired).await.is_ok()); + assert!(verify_go_redirect_state(root, &manifest, &desired) + .await + .is_ok()); // go.mod requires a DIFFERENT version → the v1.4.2 patch is unused. tokio::fs::write( @@ -1029,8 +1300,12 @@ mod tests { ) .await .unwrap(); - let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); - assert!(drifts.iter().any(|d| matches!(d, Drift::ResolvedVersionMismatch { .. }))); + let drifts = verify_go_redirect_state(root, &manifest, &desired) + .await + .unwrap_err(); + assert!(drifts + .iter() + .any(|d| matches!(d, Drift::ResolvedVersionMismatch { .. }))); } #[tokio::test] @@ -1038,25 +1313,57 @@ mod tests { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); let sources = PatchSources::blobs_only(&blobs); - apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; // Empty desired + empty manifest → the live directive is an orphan. let manifest = PatchManifest::new(); let desired: HashSet = HashSet::new(); - let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); - assert!(drifts.iter().any(|d| matches!(d, Drift::OrphanReplace { .. }))); + let drifts = verify_go_redirect_state(root, &manifest, &desired) + .await + .unwrap_err(); + assert!(drifts + .iter() + .any(|d| matches!(d, Drift::OrphanReplace { .. }))); } #[tokio::test] async fn test_empty_files_is_noop() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); - tokio::fs::write(root.join("go.mod"), "module m\n\ngo 1.21\n").await.unwrap(); + tokio::fs::write(root.join("go.mod"), "module m\n\ngo 1.21\n") + .await + .unwrap(); let blobs = root.join("blobs"); tokio::fs::create_dir_all(&blobs).await.unwrap(); let sources = PatchSources::blobs_only(&blobs); let files = HashMap::new(); - let result = apply_go_redirect(PURL, MODULE, VERSION, root, root, GO_PATCHES_DIR, &files, &sources, None, false, false).await; + let result = apply_go_redirect( + PURL, + MODULE, + VERSION, + root, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + false, + ) + .await; assert!(result.success); assert!(read_replace_entries(root).await.is_empty()); } @@ -1074,14 +1381,20 @@ mod tests { )); // Traversal / escape attempts in the module. assert!(!are_safe_redirect_coords("../../../etc", "v1.0.0")); - assert!(!are_safe_redirect_coords("github.com/../../../etc", "v1.0.0")); + assert!(!are_safe_redirect_coords( + "github.com/../../../etc", + "v1.0.0" + )); assert!(!are_safe_redirect_coords("/abs/path", "v1.0.0")); assert!(!are_safe_redirect_coords("github.com//bar", "v1.0.0")); // empty segment assert!(!are_safe_redirect_coords("foo/./bar", "v1.0.0")); assert!(!are_safe_redirect_coords("foo\\bar", "v1.0.0")); assert!(!are_safe_redirect_coords("", "v1.0.0")); // Traversal / separators in the version. - assert!(!are_safe_redirect_coords("github.com/foo/bar", "../../../evil")); + assert!(!are_safe_redirect_coords( + "github.com/foo/bar", + "../../../evil" + )); assert!(!are_safe_redirect_coords("github.com/foo/bar", "v1/0/0")); assert!(!are_safe_redirect_coords("github.com/foo/bar", "..")); assert!(!are_safe_redirect_coords("github.com/foo/bar", "")); @@ -1137,7 +1450,9 @@ mod tests { async fn test_apply_rejects_traversal_version() { let (dir, blobs, pristine, files, _after) = fixture().await; let root = dir.path(); - let gomod_before = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); + let gomod_before = tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(); let sources = PatchSources::blobs_only(&blobs); let result = apply_go_redirect( "pkg:golang/github.com/foo/bar@../../../evil", @@ -1156,7 +1471,9 @@ mod tests { assert!(!result.success); // go.mod is byte-unchanged. assert_eq!( - tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(), + tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(), gomod_before ); } @@ -1167,11 +1484,15 @@ mod tests { async fn test_remove_rejects_traversal() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); - tokio::fs::write(root.join("go.mod"), "module m\n\ngo 1.21\n").await.unwrap(); + tokio::fs::write(root.join("go.mod"), "module m\n\ngo 1.21\n") + .await + .unwrap(); // A precious directory that is a sibling of the project root. let precious = root.parent().unwrap().join("precious@v1.0.0"); tokio::fs::create_dir_all(&precious).await.unwrap(); - tokio::fs::write(precious.join("keep.txt"), b"keep").await.unwrap(); + tokio::fs::write(precious.join("keep.txt"), b"keep") + .await + .unwrap(); let err = remove_go_redirect( "pkg:golang/../../../precious@v1.0.0", @@ -1180,8 +1501,8 @@ mod tests { ReplaceOwner::GoPatches, false, ) - .await - .unwrap_err(); + .await + .unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert!( precious.exists() && precious.join("keep.txt").exists(), @@ -1196,14 +1517,19 @@ mod tests { async fn test_verify_skips_unsafe_coords() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); - tokio::fs::write(root.join("go.mod"), "module m\n\ngo 1.21\n").await.unwrap(); + tokio::fs::write(root.join("go.mod"), "module m\n\ngo 1.21\n") + .await + .unwrap(); let unsafe_purl = "pkg:golang/../../../escape@v1.0.0"; let mut manifest = PatchManifest::new(); let mut files = HashMap::new(); files.insert( "package/x.go".to_string(), - PatchFileInfo { before_hash: "b".into(), after_hash: "a".into() }, + PatchFileInfo { + before_hash: "b".into(), + after_hash: "a".into(), + }, ); manifest.patches.insert( unsafe_purl.to_string(), @@ -1219,7 +1545,9 @@ mod tests { ); let desired: HashSet = [unsafe_purl.to_string()].into_iter().collect(); // The unsafe coord is silently skipped → no drift (and no escape-stat). - assert!(verify_go_redirect_state(root, &manifest, &desired).await.is_ok()); + assert!(verify_go_redirect_state(root, &manifest, &desired) + .await + .is_ok()); } #[test] diff --git a/crates/socket-patch-core/src/patch/path_safety.rs b/crates/socket-patch-core/src/patch/path_safety.rs index a9e5ba1..eeb801e 100644 --- a/crates/socket-patch-core/src/patch/path_safety.rs +++ b/crates/socket-patch-core/src/patch/path_safety.rs @@ -27,7 +27,8 @@ pub(crate) fn is_safe_multi_segment(s: &str) -> bool { if s.is_empty() || s.starts_with('/') || s.contains('\\') || s.contains('\0') { return false; } - s.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..") + s.split('/') + .all(|seg| !seg.is_empty() && seg != "." && seg != "..") } /// The canonical lowercase hyphenated UUID grammar diff --git a/crates/socket-patch-core/src/patch/sidecars/cargo.rs b/crates/socket-patch-core/src/patch/sidecars/cargo.rs index 378325d..f95219c 100644 --- a/crates/socket-patch-core/src/patch/sidecars/cargo.rs +++ b/crates/socket-patch-core/src/patch/sidecars/cargo.rs @@ -497,7 +497,9 @@ mod tests { // A secret living OUTSIDE the package dir, reachable only via `..`. let secret = d.path().join("secret.txt"); - tokio::fs::write(&secret, b"top secret bytes").await.unwrap(); + tokio::fs::write(&secret, b"top secret bytes") + .await + .unwrap(); let starting = serde_json::json!({ "files": { "Cargo.toml": "ff".repeat(32) }, diff --git a/crates/socket-patch-core/src/patch/vendor/berry_zip.rs b/crates/socket-patch-core/src/patch/vendor/berry_zip.rs index dc56192..800dc2d 100644 --- a/crates/socket-patch-core/src/patch/vendor/berry_zip.rs +++ b/crates/socket-patch-core/src/patch/vendor/berry_zip.rs @@ -100,12 +100,21 @@ fn collect_entries(tgz_bytes: &[u8], package_ident: &str) -> Result, out: &mut Vec) { + fn mkdirp( + dirpath: &str, + seen: &mut std::collections::HashSet, + out: &mut Vec, + ) { let parts: Vec<&str> = dirpath.split('/').collect(); for i in 1..=parts.len() { let d = format!("{}/", parts[..i].join("/")); if seen.insert(d.clone()) { - out.push(ZipEntry { name: d, is_dir: true, mode: MODE_DIR, data: Vec::new() }); + out.push(ZipEntry { + name: d, + is_dir: true, + mode: MODE_DIR, + data: Vec::new(), + }); } } } @@ -124,11 +133,7 @@ fn collect_entries(tgz_bytes: &[u8], package_ident: &str) -> Result>() - .join("/"); + let stripped = raw_name.split('/').skip(1).collect::>().join("/"); let stripped = stripped.trim_end_matches('/'); let entry_type = entry.header().entry_type(); @@ -158,8 +163,17 @@ fn collect_entries(tgz_bytes: &[u8], package_ident: &str) -> Result Result { /// Serialize the entries per the pinned recipe: LFHs+data, central dir, EOCD. fn write_zip(entries: &[ZipEntry]) -> Result, String> { - let count = - u16::try_from(entries.len()).map_err(|_| "too many entries for a zip32 EOCD".to_string())?; + let count = u16::try_from(entries.len()) + .map_err(|_| "too many entries for a zip32 EOCD".to_string())?; let mut blob: Vec = Vec::new(); let mut central: Vec = Vec::new(); @@ -206,7 +220,11 @@ fn write_zip(entries: &[ZipEntry]) -> Result, String> { crc.sum() }; let size = as_u32(e.data.len(), "entry size")?; - let vneed = if e.is_dir { VERSION_NEEDED_DIR } else { VERSION_NEEDED_FILE }; + let vneed = if e.is_dir { + VERSION_NEEDED_DIR + } else { + VERSION_NEEDED_FILE + }; blob.extend_from_slice(b"PK\x03\x04"); w16(&mut blob, vneed); @@ -217,7 +235,10 @@ fn write_zip(entries: &[ZipEntry]) -> Result, String> { w32(&mut blob, crc); w32(&mut blob, size); // compressed == uncompressed (stored) w32(&mut blob, size); - w16(&mut blob, u16::try_from(e.name.len()).map_err(|_| "entry name too long".to_string())?); + w16( + &mut blob, + u16::try_from(e.name.len()).map_err(|_| "entry name too long".to_string())?, + ); w16(&mut blob, 0); // extra len blob.extend_from_slice(e.name.as_bytes()); blob.extend_from_slice(&e.data); @@ -416,8 +437,9 @@ mod tests { let zip_bytes = rebuild_cache_zip(&tgz, "@scope/pkg").unwrap(); let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap(); - let names: Vec = - (0..zip.len()).map(|i| zip.by_index(i).unwrap().name().to_string()).collect(); + let names: Vec = (0..zip.len()) + .map(|i| zip.by_index(i).unwrap().name().to_string()) + .collect(); assert_eq!( names, vec![ @@ -452,7 +474,8 @@ mod tests { let mut h = tar::Header::new_gnu(); h.set_entry_type(tar::EntryType::Symlink); h.set_size(0); - tar.append_link(&mut h, "package/evil", "/etc/passwd").unwrap(); + tar.append_link(&mut h, "package/evil", "/etc/passwd") + .unwrap(); let tgz = tar.into_inner().unwrap().finish().unwrap(); let err = berry_cache_checksum_10c0(&tgz, "x").unwrap_err(); assert!(err.contains("unsupported tar entry type"), "{err}"); @@ -465,7 +488,8 @@ mod tests { h.set_size(1); h.set_mode(0o644); h.set_cksum(); - tar.append_data(&mut h, "package/na\u{ef}ve.js", &b"x"[..]).unwrap(); + tar.append_data(&mut h, "package/na\u{ef}ve.js", &b"x"[..]) + .unwrap(); let tgz = tar.into_inner().unwrap().finish().unwrap(); let err = berry_cache_checksum_10c0(&tgz, "x").unwrap_err(); assert!(err.contains("not ASCII"), "{err}"); diff --git a/crates/socket-patch-core/src/patch/vendor/bun_lock.rs b/crates/socket-patch-core/src/patch/vendor/bun_lock.rs index ff6a00d..5ef267c 100644 --- a/crates/socket-patch-core/src/patch/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/bun_lock.rs @@ -114,9 +114,9 @@ pub async fn vendor_bun( // ── 3. Pre-flight: at least one rewritable instance ────────────────── let target_spec = format!("{name}@{version}"); - let has_match = entries.iter().any(|e| { - classify(e, &target_spec, name).is_some() - }); + let has_match = entries + .iter() + .any(|e| classify(e, &target_spec, name).is_some()); if !has_match { return refused( "vendor_lock_entry_not_found", @@ -144,7 +144,11 @@ pub async fn vendor_bun( }; let Some(staged) = staged else { // Failed patch or dry run: wiring never ran, project byte-untouched. - return VendorOutcome::Done { result, entry: None, warnings }; + return VendorOutcome::Done { + result, + entry: None, + warnings, + }; }; debug_assert_eq!(staged.rel_tgz, rel_tgz); let packed = staged.packed; @@ -166,7 +170,9 @@ pub async fn vendor_bun( let mut wiring: Vec = Vec::new(); let mut changed = false; for entry in &entries { - let Some(shape) = classify(entry, &target_spec, name) else { continue }; + let Some(shape) = classify(entry, &target_spec, name) else { + continue; + }; let (deps_verbatim, was_ours) = match shape { TupleShape::Registry => (entry.elems[2].clone(), false), TupleShape::Ours { path } => { @@ -196,7 +202,11 @@ pub async fn vendor_bun( // Never record one of our own (stale) edits as the "original" — // revert must restore the pre-vendor registry tuple, not a // dangling `.socket/vendor/` pointer from an earlier uuid. - original: if was_ours { None } else { Some(Value::String(original_line)) }, + original: if was_ours { + None + } else { + Some(Value::String(original_line)) + }, new: Some(Value::String(new_line)), }); changed = true; @@ -206,7 +216,11 @@ pub async fn vendor_bun( // Every instance already points at this uuid with the packed // integrity: in sync. The tarball re-pack above was byte-identical // by determinism; synthesize AlreadyPatched and record nothing. - let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + let verified = record + .files + .keys() + .map(|f| already_patched_verify(f)) + .collect(); return VendorOutcome::Done { result: synthesized_result(purl, &project_root.join(&rel_tgz), verified, true, None), entry: None, @@ -258,7 +272,11 @@ pub async fn vendor_bun( pdm: None, pipenv: None, }; - VendorOutcome::Done { result, entry: Some(entry), warnings } + VendorOutcome::Done { + result, + entry: Some(entry), + warnings, + } } /// Undo one bun-vendored package: restore the recorded entry lines and @@ -284,7 +302,10 @@ pub async fn revert_bun(entry: &VendorEntry, project_root: &Path, dry_run: bool) if !REVERT_ALLOWLIST.contains(&rec.file.as_str()) { outcome.warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", - format!("ignoring wiring record for non-allowlisted file `{}`", rec.file), + format!( + "ignoring wiring record for non-allowlisted file `{}`", + rec.file + ), )); continue; } @@ -312,8 +333,7 @@ pub async fn revert_bun(entry: &VendorEntry, project_root: &Path, dry_run: bool) } if dirty { if let Err(e) = - atomic_write_bytes(&project_root.join(BUN_LOCK), lines.join("\n").as_bytes()) - .await + atomic_write_bytes(&project_root.join(BUN_LOCK), lines.join("\n").as_bytes()).await { return RevertOutcome::failed(format!("cannot write {BUN_LOCK}: {e}")); } @@ -335,7 +355,10 @@ fn revert_one_record( ) { let drifted = |detail: String| VendorWarning::new("vendor_lock_entry_drifted", detail); if rec.kind != KIND_LOCK_PACKAGE { - warnings.push(drifted(format!("unknown wiring kind `{}`; left alone", rec.kind))); + warnings.push(drifted(format!( + "unknown wiring kind `{}`; left alone", + rec.kind + ))); return; } let Some(key) = rec.key.as_deref() else { @@ -350,10 +373,13 @@ fn revert_one_record( ))); return; }; - let located = lines[start + 1..end].iter().enumerate().find_map(|(off, line)| { - let parsed = parse_entry_line(line).ok()?; - (parsed.key == key).then_some((start + 1 + off, parsed)) - }); + let located = lines[start + 1..end] + .iter() + .enumerate() + .find_map(|(off, line)| { + let parsed = parse_entry_line(line).ok()?; + (parsed.key == key).then_some((start + 1 + off, parsed)) + }); if let Some((idx, parsed)) = located { // Ours iff the line is exactly what we wrote, or its tuple still // points into OUR uuid dir (a re-serialized but unmoved entry). @@ -386,7 +412,9 @@ fn revert_one_record( } return; } - warnings.push(drifted(format!("lock entry `{key}` no longer exists; nothing to restore"))); + warnings.push(drifted(format!( + "lock entry `{key}` no longer exists; nothing to restore" + ))); } // ───────────────────────── conservative line grammar ────────────────────── @@ -432,7 +460,9 @@ fn classify(entry: &BunEntry, target_spec: &str, name: &str) -> Option None, } @@ -467,7 +497,9 @@ fn check_lock_version(text: &str) -> Result<(), String> { /// `(header_idx, close_idx)` of the `"packages": {` section. fn packages_bounds(lines: &[String]) -> Option<(usize, usize)> { - let start = lines.iter().position(|l| l.trim_end() == " \"packages\": {")?; + let start = lines + .iter() + .position(|l| l.trim_end() == " \"packages\": {")?; let end = lines .iter() .enumerate() @@ -790,12 +822,16 @@ mod tests { } async fn read_lock(&self) -> String { - tokio::fs::read_to_string(self.root().join(BUN_LOCK)).await.unwrap() + tokio::fs::read_to_string(self.root().join(BUN_LOCK)) + .await + .unwrap() } /// The actual SRI of the tarball our pack produced. async fn actual_integrity(&self) -> String { - let tgz = tokio::fs::read(self.root().join(self.rel_tgz())).await.unwrap(); + let tgz = tokio::fs::read(self.root().join(self.rel_tgz())) + .await + .unwrap(); format!( "sha512-{}", base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&tgz)) @@ -831,14 +867,20 @@ mod tests { ) .await .unwrap(); - tokio::fs::write(installed.join("index.js"), ORIG_INDEX).await.unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX) + .await + .unwrap(); let blobs = root.join(".socket/blobs"); tokio::fs::create_dir_all(&blobs).await.unwrap(); let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); - tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX).await.unwrap(); + tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX) + .await + .unwrap(); - tokio::fs::write(root.join("package.json"), BN3_PKG).await.unwrap(); + tokio::fs::write(root.join("package.json"), BN3_PKG) + .await + .unwrap(); tokio::fs::write(root.join(BUN_LOCK), lock).await.unwrap(); let mut files = HashMap::new(); @@ -858,14 +900,22 @@ mod tests { license: "MIT".to_string(), tier: "free".to_string(), }; - Fixture { tmp, record, installed } + Fixture { + tmp, + record, + installed, + } } fn expect_done( outcome: VendorOutcome, ) -> (ApplyResult, Option, Vec) { match outcome { - VendorOutcome::Done { result, entry, warnings } => (result, entry, warnings), + VendorOutcome::Done { + result, + entry, + warnings, + } => (result, entry, warnings), VendorOutcome::Refused { code, detail } => { panic!("expected Done, got Refused {code}: {detail}") } @@ -879,7 +929,10 @@ mod tests { detail } VendorOutcome::Done { result, .. } => { - panic!("expected Refused {want_code}, got Done (success={})", result.success) + panic!( + "expected Refused {want_code}, got Done (success={})", + result.success + ) } } } @@ -892,7 +945,10 @@ mod tests { let entry = entry.expect("success carries a ledger entry"); let actual = fx.actual_integrity().await; - assert_ne!(actual, SPIKE_INTEGRITY, "different tarballs, different hashes"); + assert_ne!( + actual, SPIKE_INTEGRITY, + "different tarballs, different hashes" + ); assert_eq!( fx.read_lock().await, BN3_AFTER_LOCK.replace(SPIKE_INTEGRITY, &actual), @@ -900,7 +956,9 @@ mod tests { ); // LOCK-ONLY: package.json byte-untouched. assert_eq!( - tokio::fs::read_to_string(fx.root().join("package.json")).await.unwrap(), + tokio::fs::read_to_string(fx.root().join("package.json")) + .await + .unwrap(), BN3_PKG ); @@ -923,8 +981,11 @@ mod tests { #[tokio::test] async fn bn4c_nested_key_is_rewritten_and_the_other_version_stays_registry() { - let fx = - fixture_with(BN4C_BEFORE_LOCK, "node_modules/haspad/node_modules/left-pad").await; + let fx = fixture_with( + BN4C_BEFORE_LOCK, + "node_modules/haspad/node_modules/left-pad", + ) + .await; let (result, entry, _) = expect_done(fx.vendor(false).await); assert!(result.success, "{:?}", result.error); let entry = entry.unwrap(); @@ -956,7 +1017,10 @@ mod tests { "lock must carry the recomputed tarball hash, never an inherited one: {live}" ); assert!(!live.contains("sha512-XI5MPzVN"), "registry integrity gone"); - assert_eq!(entry.artifact.sha256, hex::encode(sha2::Sha256::digest(&tgz))); + assert_eq!( + entry.artifact.sha256, + hex::encode(sha2::Sha256::digest(&tgz)) + ); assert_eq!(entry.artifact.size, Some(tgz.len() as u64)); } @@ -972,7 +1036,8 @@ mod tests { // The patch ALSO rewrites the package's own package.json. let before = br#"{"name":"left-pad","version":"1.3.0"}"#; - let after: &[u8] = br#"{"name":"left-pad","version":"1.3.0","dependencies":{"wow":"^2.0.0"}}"#; + let after: &[u8] = + br#"{"name":"left-pad","version":"1.3.0","dependencies":{"wow":"^2.0.0"}}"#; let after_hash = compute_git_sha256_from_bytes(after); tokio::fs::write(fx.root().join(".socket/blobs").join(&after_hash), after) .await @@ -996,8 +1061,9 @@ mod tests { "deps object carried verbatim into the 3-tuple: {live}" ); assert!( - warnings.iter().any(|w| w.code == "vendor_dep_manifest_stale" - && w.detail.contains("bun install")), + warnings + .iter() + .any(|w| w.code == "vendor_dep_manifest_stale" && w.detail.contains("bun install")), "loud note that the deps mirror was NOT recomputed: {warnings:?}" ); } @@ -1009,7 +1075,10 @@ mod tests { let lock = BN3_BEFORE_LOCK.replace("left-pad@1.3.0", "left-pad@1.2.0"); let fx = fixture_with(&lock, "node_modules/left-pad").await; let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); - assert!(detail.contains("bun install"), "actionable detail: {detail}"); + assert!( + detail.contains("bun install"), + "actionable detail: {detail}" + ); assert_eq!(fx.read_lock().await, lock, "refusal writes nothing"); assert!(!fx.root().join(".socket/vendor").exists()); } @@ -1027,25 +1096,34 @@ mod tests { ); assert_ne!(lock, BN3_BEFORE_LOCK, "replacement must hit"); let fx = fixture_with(&lock, "node_modules/left-pad").await; - let detail = - expect_refused(fx.vendor(false).await, "vendor_lockfile_version_unsupported"); + let detail = expect_refused( + fx.vendor(false).await, + "vendor_lockfile_version_unsupported", + ); assert!(detail.contains("packages section"), "{detail}"); assert_eq!(fx.read_lock().await, lock, "fail-closed: lock untouched"); - assert!(!fx.root().join(".socket/vendor").exists(), "nothing staged/packed"); + assert!( + !fx.root().join(".socket/vendor").exists(), + "nothing staged/packed" + ); } } #[tokio::test] async fn missing_lock_and_unsupported_version_are_refused() { let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; - tokio::fs::remove_file(fx.root().join(BUN_LOCK)).await.unwrap(); + tokio::fs::remove_file(fx.root().join(BUN_LOCK)) + .await + .unwrap(); let detail = expect_refused(fx.vendor(false).await, "vendor_lockfile_missing"); assert!(detail.contains("bun install"), "{detail}"); let lock = BN3_BEFORE_LOCK.replace("\"lockfileVersion\": 1,", "\"lockfileVersion\": 2,"); let fx = fixture_with(&lock, "node_modules/left-pad").await; - let detail = - expect_refused(fx.vendor(false).await, "vendor_lockfile_version_unsupported"); + let detail = expect_refused( + fx.vendor(false).await, + "vendor_lockfile_version_unsupported", + ); assert!(detail.contains('2'), "{detail}"); } @@ -1061,7 +1139,10 @@ mod tests { assert!(result.success); assert!(entry.is_none(), "in-sync re-run records nothing"); assert!( - result.files_verified.iter().all(|v| v.status == VerifyStatus::AlreadyPatched), + result + .files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), "{:?}", result.files_verified ); @@ -1084,7 +1165,9 @@ mod tests { assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK); assert!(!fx.root().join(".socket/vendor").exists()); assert_eq!( - tokio::fs::read(fx.installed.join("index.js")).await.unwrap(), + tokio::fs::read(fx.installed.join("index.js")) + .await + .unwrap(), ORIG_INDEX, "vendor never patches in place" ); @@ -1109,7 +1192,10 @@ mod tests { assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK, "lock byte-restored"); assert!(!tgz_path.exists()); - assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists()); } #[tokio::test] @@ -1118,7 +1204,9 @@ mod tests { let (_, entry, _) = expect_done(fx.vendor(false).await); let mut entry = entry.unwrap(); // A poisoned ledger names a file outside the allowlist. - tokio::fs::write(fx.root().join("package.json.bak"), b"precious").await.unwrap(); + tokio::fs::write(fx.root().join("package.json.bak"), b"precious") + .await + .unwrap(); entry.wiring.push(WiringRecord { file: "package.json.bak".to_string(), kind: KIND_LOCK_PACKAGE.to_string(), @@ -1131,17 +1219,26 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted" - && w.detail.contains("package.json.bak")), + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted" + && w.detail.contains("package.json.bak")), "{:?}", outcome.warnings ); assert_eq!( - tokio::fs::read(fx.root().join("package.json.bak")).await.unwrap(), + tokio::fs::read(fx.root().join("package.json.bak")) + .await + .unwrap(), b"precious", "non-allowlisted file never touched" ); - assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK, "real record still restored"); + assert_eq!( + fx.read_lock().await, + BN3_BEFORE_LOCK, + "real record still restored" + ); } #[tokio::test] @@ -1153,16 +1250,27 @@ mod tests { // The user re-resolved the entry behind our back (`bun update`). let drifted_line = " \"left-pad\": [\"left-pad@1.3.1\", \"\", {}, \"sha512-other==\"],"; let live = fx.read_lock().await; - let new_line = entry.wiring[0].new.as_ref().and_then(Value::as_str).unwrap(); + let new_line = entry.wiring[0] + .new + .as_ref() + .and_then(Value::as_str) + .unwrap(); let drifted_lock = live.replace(new_line, drifted_line); - assert_ne!(drifted_lock, live, "test setup must actually drift the entry"); - tokio::fs::write(fx.root().join(BUN_LOCK), &drifted_lock).await.unwrap(); + assert_ne!( + drifted_lock, live, + "test setup must actually drift the entry" + ); + tokio::fs::write(fx.root().join(BUN_LOCK), &drifted_lock) + .await + .unwrap(); let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted" - && w.detail.contains("left-pad")), + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted" && w.detail.contains("left-pad")), "{:?}", outcome.warnings ); @@ -1171,7 +1279,9 @@ mod tests { "drifted entry left alone" ); assert!( - !fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists(), + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), "artifact still removed" ); } @@ -1208,18 +1318,37 @@ mod tests { ) .unwrap(); assert_eq!(e.elems.len(), 3); - assert_eq!(e.elems[1], r#"{ "dependencies": { "a": "^1", "b": "[2]" } }"#); + assert_eq!( + e.elems[1], + r#"{ "dependencies": { "a": "^1", "b": "[2]" } }"# + ); assert!(!e.trailing_comma); // split at the LAST @ (scoped names). - assert_eq!(split_name_spec("@scope/pkg@1.0.0"), Some(("@scope/pkg", "1.0.0"))); - assert_eq!(split_name_spec("left-pad@.socket/x.tgz"), Some(("left-pad", ".socket/x.tgz"))); - assert_eq!(split_name_spec("@scope/pkg"), None, "a scope @ alone is not a version sep"); + assert_eq!( + split_name_spec("@scope/pkg@1.0.0"), + Some(("@scope/pkg", "1.0.0")) + ); + assert_eq!( + split_name_spec("left-pad@.socket/x.tgz"), + Some(("left-pad", ".socket/x.tgz")) + ); + assert_eq!( + split_name_spec("@scope/pkg"), + None, + "a scope @ alone is not a version sep" + ); // Fail-closed grammar. - assert!(parse_entry_line(" \"k\": [\"a\", ").is_err(), "unterminated"); + assert!( + parse_entry_line(" \"k\": [\"a\", ").is_err(), + "unterminated" + ); assert!(parse_entry_line(" k: [\"a\"]").is_err(), "unquoted key"); assert!(parse_entry_line(" \"k\": \"not an array\"").is_err()); - assert!(parse_entry_line(" \"k\": [\"a\"], junk").is_err(), "trailing junk"); + assert!( + parse_entry_line(" \"k\": [\"a\"], junk").is_err(), + "trailing junk" + ); } } diff --git a/crates/socket-patch-core/src/patch/vendor/cargo.rs b/crates/socket-patch-core/src/patch/vendor/cargo.rs index 42a2608..4d97c61 100644 --- a/crates/socket-patch-core/src/patch/vendor/cargo.rs +++ b/crates/socket-patch-core/src/patch/vendor/cargo.rs @@ -132,7 +132,11 @@ fn already_patched_verify(file: &str) -> VerifyResult { } } -fn done(result: ApplyResult, entry: Option, warnings: Vec) -> VendorOutcome { +fn done( + result: ApplyResult, + entry: Option, + warnings: Vec, +) -> VendorOutcome { VendorOutcome::Done { result, entry, @@ -268,8 +272,16 @@ pub async fn vendor_cargo_crate( // Verify (read-only) against the pristine source — apply_package_patch // never writes when dry_run — for an accurate "would patch" report, // without creating the copy or editing config/lock. - let mut result = - apply_package_patch(purl, pristine_src, &record.files, sources, Some(&record.uuid), true, force).await; + let mut result = apply_package_patch( + purl, + pristine_src, + &record.files, + sources, + Some(&record.uuid), + true, + force, + ) + .await; result.package_path = copy_dir.display().to_string(); result.sidecar = None; return done(result, None, Vec::new()); @@ -277,8 +289,21 @@ pub async fn vendor_cargo_crate( // Hot path: already in sync → touch nothing (entry stays with the caller's // existing ledger record, which holds the unrecoverable lock originals). - if vendor_in_sync(©_dir, &record.files, project_root, name, version, ©_rel).await { - let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + if vendor_in_sync( + ©_dir, + &record.files, + project_root, + name, + version, + ©_rel, + ) + .await + { + let verified = record + .files + .keys() + .map(|f| already_patched_verify(f)) + .collect(); return done( synthesized_result(purl, ©_dir, verified, true, None), None, @@ -309,8 +334,16 @@ pub async fn vendor_cargo_crate( } // Delegate to the hardened pipeline, pointed at the copy. - let mut result = - apply_package_patch(purl, ©_dir, &record.files, sources, Some(&record.uuid), false, force).await; + let mut result = apply_package_patch( + purl, + ©_dir, + &record.files, + sources, + Some(&record.uuid), + false, + force, + ) + .await; result.package_path = copy_dir.display().to_string(); if !result.success { @@ -345,9 +378,7 @@ pub async fn vendor_cargo_crate( if prior_path.as_deref().is_some_and(is_legacy_redirect_path) { warnings.push(VendorWarning::new( "vendor_takeover", - format!( - "took over the legacy `.socket/cargo-patches/` [patch] entry for `{name}`" - ), + format!("took over the legacy `.socket/cargo-patches/` [patch] entry for `{name}`"), )); } @@ -524,9 +555,9 @@ pub async fn revert_cargo_vendor( if !dry_run { let uuid_dir: PathBuf = project_root.join(&base_rel); let _ = remove_tree(&uuid_dir).await; // ignore NotFound - // Best-effort: prune the now-empty `.socket/vendor/cargo/` level so a - // fully-reverted project carries no vendor residue (`save_state` then - // prunes `.socket/vendor/` itself). `remove_dir` fails on non-empty. + // Best-effort: prune the now-empty `.socket/vendor/cargo/` level so a + // fully-reverted project carries no vendor residue (`save_state` then + // prunes `.socket/vendor/` itself). `remove_dir` fails on non-empty. if let Some(eco_dir) = uuid_dir.parent() { let _ = tokio::fs::remove_dir(eco_dir).await; } @@ -607,8 +638,12 @@ mod tests { let root = dir.path().to_path_buf(); let pristine = root.join("registry/cfg-if-1.0.4"); - tokio::fs::create_dir_all(pristine.join("src")).await.unwrap(); - tokio::fs::write(pristine.join("src/lib.rs"), PRISTINE).await.unwrap(); + tokio::fs::create_dir_all(pristine.join("src")) + .await + .unwrap(); + tokio::fs::write(pristine.join("src/lib.rs"), PRISTINE) + .await + .unwrap(); tokio::fs::write( pristine.join("Cargo.toml"), "[package]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n", @@ -640,7 +675,9 @@ mod tests { ) .await .unwrap(); - tokio::fs::write(root.join("Cargo.lock"), lock_body()).await.unwrap(); + tokio::fs::write(root.join("Cargo.lock"), lock_body()) + .await + .unwrap(); (dir, blobs, pristine, record_with(files)) } @@ -667,7 +704,9 @@ mod tests { .await } - fn expect_done(outcome: VendorOutcome) -> (ApplyResult, Option, Vec) { + fn expect_done( + outcome: VendorOutcome, + ) -> (ApplyResult, Option, Vec) { match outcome { VendorOutcome::Done { result, @@ -687,7 +726,10 @@ mod tests { detail } VendorOutcome::Done { result, .. } => { - panic!("expected Refused({want_code}), got Done (success={})", result.success) + panic!( + "expected Refused({want_code}), got Done (success={})", + result.success + ) } } } @@ -705,17 +747,25 @@ mod tests { // Copy holds the patched bytes and NO checksum sidecar. let copy = root.join(copy_rel()); - assert_eq!(tokio::fs::read(copy.join("src/lib.rs")).await.unwrap(), PATCHED); + assert_eq!( + tokio::fs::read(copy.join("src/lib.rs")).await.unwrap(), + PATCHED + ); assert!(!copy.join(".cargo-checksum.json").exists()); // The registry pristine is untouched. - assert_eq!(tokio::fs::read(pristine.join("src/lib.rs")).await.unwrap(), PRISTINE); + assert_eq!( + tokio::fs::read(pristine.join("src/lib.rs")).await.unwrap(), + PRISTINE + ); // Config entry points at the uuid-level copy. let entries = cargo_config::read_patch_entries(root).await; assert_eq!(entries["cfg-if"].path.as_deref(), Some(copy_rel().as_str())); // The lock entry is detached (source+checksum gone), rest preserved. - let lock = tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(); + let lock = tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(); assert!(!lock.contains("source =")); assert!(!lock.contains("checksum =")); assert!(lock.contains("name = \"cfg-if\"\nversion = \"1.0.4\"\n")); @@ -728,7 +778,10 @@ mod tests { .unwrap(); assert!(marker.contains(UUID)); assert!(marker.contains("GHSA-xxxx-yyyy-zzzz")); - assert!(marker.contains(&format!("\"purl\": \"{PURL}\"")), "{marker}"); + assert!( + marker.contains(&format!("\"purl\": \"{PURL}\"")), + "{marker}" + ); // Ledger entry shape. let entry = entry.expect("entry on success"); @@ -747,12 +800,18 @@ mod tests { assert!(!entry.took_over_go_patches); assert_eq!(entry.wiring.len(), 2); let cfg = &entry.wiring[0]; - assert_eq!((cfg.file.as_str(), cfg.kind.as_str()), (".cargo/config.toml", "cargo_patch_entry")); + assert_eq!( + (cfg.file.as_str(), cfg.kind.as_str()), + (".cargo/config.toml", "cargo_patch_entry") + ); assert_eq!(cfg.action, WiringAction::Added); assert_eq!(cfg.key.as_deref(), Some("cfg-if")); assert_eq!(cfg.new, Some(serde_json::Value::from(copy_rel()))); let lockw = &entry.wiring[1]; - assert_eq!((lockw.file.as_str(), lockw.kind.as_str()), ("Cargo.lock", "cargo_lock_entry")); + assert_eq!( + (lockw.file.as_str(), lockw.kind.as_str()), + ("Cargo.lock", "cargo_lock_entry") + ); assert_eq!(lockw.action, WiringAction::Rewritten); assert_eq!(lockw.key.as_deref(), Some("cfg-if@1.0.4")); assert_eq!( @@ -776,7 +835,10 @@ mod tests { run_vendor(PURL, root, &blobs, &pristine, &record, false).await, "locked_version_mismatch", ); - assert!(detail.contains("1.0.5") && detail.contains("1.0.4"), "{detail}"); + assert!( + detail.contains("1.0.5") && detail.contains("1.0.4"), + "{detail}" + ); // Refused before any write. assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); assert!(!root.join(".cargo").exists()); @@ -800,9 +862,13 @@ mod tests { async fn test_refuses_user_authored_patch_entry() { let (dir, blobs, pristine, record) = fixture().await; let root = dir.path(); - tokio::fs::create_dir_all(root.join(".cargo")).await.unwrap(); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); let user_cfg = "[patch.crates-io]\ncfg-if = { path = \"../my-fork\" }\n"; - tokio::fs::write(root.join(".cargo/config.toml"), user_cfg).await.unwrap(); + tokio::fs::write(root.join(".cargo/config.toml"), user_cfg) + .await + .unwrap(); expect_refused( run_vendor(PURL, root, &blobs, &pristine, &record, false).await, @@ -810,12 +876,16 @@ mod tests { ); // Nothing written: config byte-identical, no copy, lock untouched. assert_eq!( - tokio::fs::read_to_string(root.join(".cargo/config.toml")).await.unwrap(), + tokio::fs::read_to_string(root.join(".cargo/config.toml")) + .await + .unwrap(), user_cfg ); assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), lock_body() ); } @@ -824,7 +894,9 @@ mod tests { async fn test_refuses_cargo_vendor_tree() { let (dir, blobs, pristine, record) = fixture().await; let root = dir.path(); - tokio::fs::create_dir_all(root.join("vendor/cfg-if-1.0.4")).await.unwrap(); + tokio::fs::create_dir_all(root.join("vendor/cfg-if-1.0.4")) + .await + .unwrap(); expect_refused( run_vendor(PURL, root, &blobs, &pristine, &record, false).await, "already_vendored_in_tree", @@ -836,7 +908,9 @@ mod tests { async fn test_no_lockfile_proceeds_with_warning() { let (dir, blobs, pristine, record) = fixture().await; let root = dir.path(); - tokio::fs::remove_file(root.join("Cargo.lock")).await.unwrap(); + tokio::fs::remove_file(root.join("Cargo.lock")) + .await + .unwrap(); let (result, entry, warnings) = expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); @@ -866,13 +940,18 @@ mod tests { assert!(!result.success); assert!(entry.is_none()); assert!( - !root.join(format!(".socket/vendor/cargo/{UUID}")).join("cfg-if-1.0.4").exists(), + !root + .join(format!(".socket/vendor/cargo/{UUID}")) + .join("cfg-if-1.0.4") + .exists(), "half-built copy must be rolled back" ); // No config entry, lock untouched. assert!(cargo_config::read_patch_entries(root).await.is_empty()); assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), lock_body() ); } @@ -904,7 +983,9 @@ mod tests { assert!(cargo_config::read_patch_entries(root).await.is_empty()); assert!(!root.join(copy_rel()).exists()); assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n" ); } @@ -925,15 +1006,30 @@ mod tests { let (result, entry, warnings) = expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); assert!(result.success); - assert!(result.files_patched.is_empty(), "in-sync re-run patches nothing"); + assert!( + result.files_patched.is_empty(), + "in-sync re-run patches nothing" + ); assert!( entry.is_none(), "hot path must not emit a fresh entry (it would clobber the ledger's lock originals)" ); assert!(warnings.is_empty()); - assert_eq!(tokio::fs::read(©).await.unwrap(), copy1, "copy unchanged"); - assert_eq!(tokio::fs::read(&cfg).await.unwrap(), cfg1, "config unchanged"); - assert_eq!(tokio::fs::read(&lock).await.unwrap(), lock1, "lock unchanged"); + assert_eq!( + tokio::fs::read(©).await.unwrap(), + copy1, + "copy unchanged" + ); + assert_eq!( + tokio::fs::read(&cfg).await.unwrap(), + cfg1, + "config unchanged" + ); + assert_eq!( + tokio::fs::read(&lock).await.unwrap(), + lock1, + "lock unchanged" + ); } #[tokio::test] @@ -947,7 +1043,9 @@ mod tests { assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); assert!(!root.join(".cargo").exists()); assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), lock_body() ); } @@ -966,7 +1064,9 @@ mod tests { // Lock byte-identical to the pristine fixture. assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), lock_body() ); // Config entry gone — and the socket-created file + .cargo/ pruned. @@ -985,18 +1085,24 @@ mod tests { expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); let entry = entry.unwrap(); // A third party re-resolved the lock (source back) after vendoring. - tokio::fs::write(root.join("Cargo.lock"), lock_body()).await.unwrap(); + tokio::fs::write(root.join("Cargo.lock"), lock_body()) + .await + .unwrap(); let out = revert_cargo_vendor(&entry, root, false).await; assert!(out.success, "{:?}", out.error); assert!( - out.warnings.iter().any(|w| w.code == "lock_restore_skipped"), + out.warnings + .iter() + .any(|w| w.code == "lock_restore_skipped"), "{:?}", out.warnings ); // The re-resolved lock is left alone, the rest still reverted. assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), lock_body() ); assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); @@ -1007,7 +1113,9 @@ mod tests { let (dir, blobs, pristine, record) = fixture().await; let root = dir.path(); // Residue from the retired redirect backend: a legacy-path entry. - tokio::fs::create_dir_all(root.join(".cargo")).await.unwrap(); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); tokio::fs::write( root.join(".cargo/config.toml"), "[patch.crates-io]\ncfg-if = { path = \".socket/cargo-patches/cfg-if-1.0.4\" }\n", @@ -1027,11 +1135,15 @@ mod tests { assert_eq!(cfg.action, WiringAction::Rewritten); assert_eq!( cfg.original, - Some(serde_json::Value::from(".socket/cargo-patches/cfg-if-1.0.4")) + Some(serde_json::Value::from( + ".socket/cargo-patches/cfg-if-1.0.4" + )) ); // The live entry now points at the vendor copy. assert_eq!( - cargo_config::read_patch_entries(root).await["cfg-if"].path.as_deref(), + cargo_config::read_patch_entries(root).await["cfg-if"] + .path + .as_deref(), Some(copy_rel().as_str()) ); } @@ -1049,17 +1161,39 @@ mod tests { let _ = remove_tree(&escaped).await; expect_refused( - run_vendor("pkg:cargo/../../../escape@1.0.0", root, &blobs, &pristine, &record, false) - .await, + run_vendor( + "pkg:cargo/../../../escape@1.0.0", + root, + &blobs, + &pristine, + &record, + false, + ) + .await, "unsafe_coordinates", ); expect_refused( - run_vendor("pkg:cargo/cfg-if@../../../evil", root, &blobs, &pristine, &record, false) - .await, + run_vendor( + "pkg:cargo/cfg-if@../../../evil", + root, + &blobs, + &pristine, + &record, + false, + ) + .await, "unsafe_coordinates", ); expect_refused( - run_vendor("pkg:npm/not-cargo@1.0.0", root, &blobs, &pristine, &record, false).await, + run_vendor( + "pkg:npm/not-cargo@1.0.0", + root, + &blobs, + &pristine, + &record, + false, + ) + .await, "unsafe_coordinates", ); assert!(!escaped.exists(), "no copy outside the project"); @@ -1119,7 +1253,9 @@ mod tests { assert!(warnings.is_empty()); assert!(!root.join(".cargo").exists()); assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")).await.unwrap(), + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), lock_body() ); } diff --git a/crates/socket-patch-core/src/patch/vendor/cargo_config.rs b/crates/socket-patch-core/src/patch/vendor/cargo_config.rs index 5325718..ba485dd 100644 --- a/crates/socket-patch-core/src/patch/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/patch/vendor/cargo_config.rs @@ -317,7 +317,7 @@ mod tests { assert!(path_is_socket_owned("./.socket/vendor/cargo/u/x-1.0.0")); // contains "/.socket/…" assert!(path_is_socket_owned("sub/.socket/vendor/cargo/u/x-1.0.0")); assert!(path_is_socket_owned(r".socket\vendor\cargo\u\x-1.0.0")); // backslash normalised - // Legacy redirect copies are recognised as ours (takeover / cleanup). + // Legacy redirect copies are recognised as ours (takeover / cleanup). assert!(path_is_socket_owned(".socket/cargo-patches/cfg-if-1.0.0")); assert!(path_is_socket_owned("./.socket/cargo-patches/x-1.0.0")); // User paths are not. @@ -499,9 +499,10 @@ mod tests { #[tokio::test] async fn test_ensure_dry_run_does_not_create() { let dir = tempfile::tempdir().unwrap(); - let changed = ensure_patch_entry(dir.path(), "cfg-if", &vendor_path("cfg-if", "1.0.4"), true) - .await - .unwrap(); + let changed = + ensure_patch_entry(dir.path(), "cfg-if", &vendor_path("cfg-if", "1.0.4"), true) + .await + .unwrap(); assert!(changed, "dry-run reports the change it would make"); assert!( !dir.path().join(".cargo/config.toml").exists(), @@ -589,7 +590,9 @@ mod tests { .unwrap(); assert!(drop_patch_entry(dir.path(), "cfg-if", false).await.unwrap()); // The file survives (user content remains); only our entry is gone. - let body = fs::read_to_string(cargo_dir.join("config.toml")).await.unwrap(); + let body = fs::read_to_string(cargo_dir.join("config.toml")) + .await + .unwrap(); assert!(body.contains("jobs = 4"), "user [build] table preserved"); assert!(!body.contains("cfg-if")); } @@ -601,9 +604,12 @@ mod tests { fs::create_dir_all(&cargo_dir).await.unwrap(); // A sibling file (e.g. credentials) means `.cargo/` must survive even // though our config is emptied + deleted. - fs::write(cargo_dir.join("credentials.toml"), "[registry]\ntoken = \"x\"\n") - .await - .unwrap(); + fs::write( + cargo_dir.join("credentials.toml"), + "[registry]\ntoken = \"x\"\n", + ) + .await + .unwrap(); assert!( ensure_patch_entry(dir.path(), "cfg-if", &vendor_path("cfg-if", "1.0.4"), false) .await @@ -651,7 +657,8 @@ mod tests { "create-path commit must rename the stage file away, not leave it" ); // A second, mutating upsert (uuid bump) must also clean up. - let bumped = format!("{CARGO_VENDOR_DIR}/11111111-2222-3333-4444-555555555555/cfg-if-1.0.4"); + let bumped = + format!("{CARGO_VENDOR_DIR}/11111111-2222-3333-4444-555555555555/cfg-if-1.0.4"); assert!(ensure_patch_entry(dir.path(), "cfg-if", &bumped, false) .await .unwrap()); diff --git a/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs b/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs index 5fe7384..a59bd65 100644 --- a/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs @@ -66,7 +66,9 @@ impl std::fmt::Display for LockEditError { } /// Read + parse `/Cargo.lock`, mapping errors to [`LockEditError`]. -async fn read_lock(project_root: &Path) -> Result<(std::path::PathBuf, DocumentMut), LockEditError> { +async fn read_lock( + project_root: &Path, +) -> Result<(std::path::PathBuf, DocumentMut), LockEditError> { let path = project_root.join("Cargo.lock"); let content = match tokio::fs::read_to_string(&path).await { Ok(c) => c, @@ -193,9 +195,7 @@ pub async fn restore_lock_entry( /// is skipped (a malformed lock would itself break a real `cargo build`). /// Multi-version aware: a v4 lock may resolve the same name at several /// versions. Reads only the project lockfile: no registry, no network. -pub async fn read_locked_versions( - project_root: &Path, -) -> Option>> { +pub async fn read_locked_versions(project_root: &Path) -> Option>> { let content = tokio::fs::read_to_string(project_root.join("Cargo.lock")) .await .ok()?; @@ -266,23 +266,30 @@ mod tests { // entry with its dependencies array, and cfg-if's name/version pair. assert!(body.starts_with("# This file is automatically @generated by Cargo.\n")); assert!(body.contains("version = 4\n")); - assert!(body.contains("name = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \"cfg-if\",\n]\n")); + assert!(body + .contains("name = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \"cfg-if\",\n]\n")); assert!(body.contains("[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n")); } #[tokio::test] async fn detach_restore_round_trip_is_byte_identical() { let dir = fixture().await; - let before = tokio::fs::read(dir.path().join("Cargo.lock")).await.unwrap(); + let before = tokio::fs::read(dir.path().join("Cargo.lock")) + .await + .unwrap(); let orig = detach_lock_entry(dir.path(), "cfg-if", "1.0.4", false) .await .unwrap(); - assert!(restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, false) - .await - .unwrap()); + assert!( + restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, false) + .await + .unwrap() + ); - let after = tokio::fs::read(dir.path().join("Cargo.lock")).await.unwrap(); + let after = tokio::fs::read(dir.path().join("Cargo.lock")) + .await + .unwrap(); assert_eq!( String::from_utf8_lossy(&before), String::from_utf8_lossy(&after), @@ -313,7 +320,9 @@ mod tests { assert_eq!(err, LockEditError::EntryMissing); // The refusals wrote nothing. assert_eq!( - tokio::fs::read_to_string(dir.path().join("Cargo.lock")).await.unwrap(), + tokio::fs::read_to_string(dir.path().join("Cargo.lock")) + .await + .unwrap(), lock_body() ); } @@ -336,7 +345,9 @@ mod tests { .unwrap(); assert_eq!(orig.source, SOURCE); assert_eq!( - tokio::fs::read_to_string(dir.path().join("Cargo.lock")).await.unwrap(), + tokio::fs::read_to_string(dir.path().join("Cargo.lock")) + .await + .unwrap(), lock_body(), "dry-run must not write" ); @@ -378,7 +389,10 @@ mod tests { let restored = restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, false) .await .unwrap(); - assert!(restored, "detached entry must restore despite the extra table"); + assert!( + restored, + "detached entry must restore despite the extra table" + ); let after = tokio::fs::read_to_string(dir.path().join("Cargo.lock")) .await @@ -400,16 +414,22 @@ mod tests { }; // The entry still has its registry source (the user/cargo re-resolved // it after a hand-revert) — restoring would clobber it: Ok(false). - assert!(!restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, false) - .await - .unwrap()); + assert!( + !restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, false) + .await + .unwrap() + ); // The entry is gone entirely (the dependency was dropped): Ok(false). - assert!(!restore_lock_entry(dir.path(), "gone", "1.0.0", &orig, false) - .await - .unwrap()); + assert!( + !restore_lock_entry(dir.path(), "gone", "1.0.0", &orig, false) + .await + .unwrap() + ); // Neither skip touched the file. assert_eq!( - tokio::fs::read_to_string(dir.path().join("Cargo.lock")).await.unwrap(), + tokio::fs::read_to_string(dir.path().join("Cargo.lock")) + .await + .unwrap(), lock_body() ); } @@ -423,11 +443,15 @@ mod tests { let detached = tokio::fs::read_to_string(dir.path().join("Cargo.lock")) .await .unwrap(); - assert!(restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, true) - .await - .unwrap()); + assert!( + restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, true) + .await + .unwrap() + ); assert_eq!( - tokio::fs::read_to_string(dir.path().join("Cargo.lock")).await.unwrap(), + tokio::fs::read_to_string(dir.path().join("Cargo.lock")) + .await + .unwrap(), detached, "dry-run restore must not write" ); @@ -443,7 +467,9 @@ mod tests { ) .await .unwrap(); - let orig = detach_lock_entry(dir.path(), "x", "1.0.0", false).await.unwrap(); + let orig = detach_lock_entry(dir.path(), "x", "1.0.0", false) + .await + .unwrap(); assert_eq!(orig.checksum, None); assert!(restore_lock_entry(dir.path(), "x", "1.0.0", &orig, false) .await diff --git a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs index 7db2bcf..d8144b4 100644 --- a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs @@ -98,10 +98,7 @@ pub async fn vendor_composer( ) -> VendorOutcome { // ── coordinates ────────────────────────────────────────────────────── let Some(((vendor, name), version)) = parse_composer_purl(purl) else { - return refused( - "unsafe_coordinates", - format!("not a composer purl: {purl}"), - ); + return refused("unsafe_coordinates", format!("not a composer purl: {purl}")); }; // Canonical (packagist) lowercase form keys the on-disk copy dir and the // dist URL; the lock's own pretty casing is preserved untouched. @@ -182,7 +179,11 @@ pub async fn vendor_composer( if entry_is_wired(&lock[section][idx], ©_rel) && copy_matches_after_hashes(©_dir, &record.files).await { - let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + let verified = record + .files + .keys() + .map(|f| already_patched_verify(f)) + .collect(); return VendorOutcome::Done { result: synthesized_result(purl, ©_dir, verified, true, None), entry: None, @@ -192,9 +193,16 @@ pub async fn vendor_composer( // ── dry run: verify-only against the installed dir, no writes ──────── if dry_run { - let mut result = - apply_package_patch(purl, installed_dir, &record.files, sources, Some(&record.uuid), true, force) - .await; + let mut result = apply_package_patch( + purl, + installed_dir, + &record.files, + sources, + Some(&record.uuid), + true, + force, + ) + .await; result.package_path = copy_dir.display().to_string(); return VendorOutcome::Done { result, @@ -217,9 +225,16 @@ pub async fn vendor_composer( warnings: Vec::new(), }; } - let mut result = - apply_package_patch(purl, ©_dir, &record.files, sources, Some(&record.uuid), false, force) - .await; + let mut result = apply_package_patch( + purl, + ©_dir, + &record.files, + sources, + Some(&record.uuid), + false, + force, + ) + .await; result.package_path = copy_dir.display().to_string(); if !result.success { // Don't leave a half-built copy; the lock was never touched. @@ -238,7 +253,11 @@ pub async fn vendor_composer( let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some("composer.lock entry is not a JSON object".to_string()); - return VendorOutcome::Done { result, entry: None, warnings: Vec::new() }; + return VendorOutcome::Done { + result, + entry: None, + warnings: Vec::new(), + }; }; let rewritten = rewrite_lock_entry(original_obj, ©_rel, &record.uuid); lock[section][idx] = Value::Object(rewritten.clone()); @@ -250,7 +269,11 @@ pub async fn vendor_composer( let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some(format!("failed to write composer.lock: {e}")); - return VendorOutcome::Done { result, entry: None, warnings: Vec::new() }; + return VendorOutcome::Done { + result, + entry: None, + warnings: Vec::new(), + }; } // ── marker + ledger entry ──────────────────────────────────────────── @@ -541,8 +564,8 @@ async fn restore_lock_entry( Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(e) => return Err(format!("unreadable composer.lock: {e}")), }; - let mut lock: Value = serde_json::from_str(&lock_text) - .map_err(|e| format!("unparseable composer.lock: {e}"))?; + let mut lock: Value = + serde_json::from_str(&lock_text).map_err(|e| format!("unparseable composer.lock: {e}"))?; let Some(arr) = lock.get(section).and_then(Value::as_array) else { return Ok(false); @@ -681,10 +704,15 @@ mod tests { .unwrap(); let installed = root.join("vendor/psr/log"); - tokio::fs::create_dir_all(installed.join("src")).await.unwrap(); - tokio::fs::write(installed.join("composer.json"), b"{\"name\": \"psr/log\"}\n") + tokio::fs::create_dir_all(installed.join("src")) .await .unwrap(); + tokio::fs::write( + installed.join("composer.json"), + b"{\"name\": \"psr/log\"}\n", + ) + .await + .unwrap(); tokio::fs::write(installed.join("src/LoggerInterface.php"), PRISTINE) .await .unwrap(); @@ -778,18 +806,22 @@ mod tests { // Copy patched at the uuid path; installed dir untouched. let copy = root.join(copy_rel()); assert_eq!( - tokio::fs::read(copy.join("src/LoggerInterface.php")).await.unwrap(), + tokio::fs::read(copy.join("src/LoggerInterface.php")) + .await + .unwrap(), PATCHED ); assert_eq!( - tokio::fs::read(installed.join("src/LoggerInterface.php")).await.unwrap(), + tokio::fs::read(installed.join("src/LoggerInterface.php")) + .await + .unwrap(), PRISTINE ); // Marker present in the uuid dir. - let marker = tokio::fs::read_to_string( - root.join(format!(".socket/vendor/composer/{UUID}/{VENDOR_MARKER_FILE}")), - ) + let marker = tokio::fs::read_to_string(root.join(format!( + ".socket/vendor/composer/{UUID}/{VENDOR_MARKER_FILE}" + ))) .await .unwrap(); assert!(marker.contains(UUID)); @@ -797,13 +829,22 @@ mod tests { // Lock surgery: source gone, dist replaced in slot, transport-options // right after, all other keys in their original order. - let text = tokio::fs::read_to_string(root.join(COMPOSER_LOCK)).await.unwrap(); + let text = tokio::fs::read_to_string(root.join(COMPOSER_LOCK)) + .await + .unwrap(); let new_lock: Value = serde_json::from_str(&text).unwrap(); let e = &new_lock["packages"][0]; let keys: Vec<&str> = e.as_object().unwrap().keys().map(String::as_str).collect(); assert_eq!( keys, - vec!["name", "version", "dist", "transport-options", "require", "type"], + vec![ + "name", + "version", + "dist", + "transport-options", + "require", + "type" + ], "dist replaced in its original slot, source dropped, transport-options after dist" ); assert_eq!(e["dist"]["type"], "path"); @@ -853,7 +894,9 @@ mod tests { assert_eq!(entry.wiring[0].key.as_deref(), Some("packages-dev:psr/log")); let new_lock: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join(COMPOSER_LOCK)).await.unwrap(), + &tokio::fs::read_to_string(root.join(COMPOSER_LOCK)) + .await + .unwrap(), ) .unwrap(); assert_eq!(new_lock["packages-dev"][0]["dist"]["type"], "path"); @@ -873,7 +916,9 @@ mod tests { unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); assert!(result.success, "{:?}", result.error); let new_lock: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join(COMPOSER_LOCK)).await.unwrap(), + &tokio::fs::read_to_string(root.join(COMPOSER_LOCK)) + .await + .unwrap(), ) .unwrap(); assert_eq!(new_lock["packages"][0]["version"], "v3.0.2"); @@ -892,12 +937,24 @@ mod tests { unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); assert!(result.success, "{:?}", result.error); let new_lock: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join(COMPOSER_LOCK)).await.unwrap(), + &tokio::fs::read_to_string(root.join(COMPOSER_LOCK)) + .await + .unwrap(), ) .unwrap(); - assert_eq!(new_lock["packages"][0]["name"], "Psr/Log", "pretty casing kept"); - assert_eq!(new_lock["packages"][0]["dist"]["url"], copy_rel(), "dist url lowercase"); - assert!(dir.path().join(copy_rel()).exists(), "copy at the lowercase path"); + assert_eq!( + new_lock["packages"][0]["name"], "Psr/Log", + "pretty casing kept" + ); + assert_eq!( + new_lock["packages"][0]["dist"]["url"], + copy_rel(), + "dist url lowercase" + ); + assert!( + dir.path().join(copy_rel()).exists(), + "copy at the lowercase path" + ); } #[tokio::test] @@ -905,7 +962,9 @@ mod tests { let lock = lock_value("psr/log", "3.0.2", false); let (dir, blobs, installed, record) = fixture(&lock).await; let root = dir.path(); - tokio::fs::remove_file(root.join(COMPOSER_LOCK)).await.unwrap(); + tokio::fs::remove_file(root.join(COMPOSER_LOCK)) + .await + .unwrap(); let (code, _d) = unwrap_refused(run_vendor(root, &blobs, &installed, &record, PURL, false).await); @@ -950,14 +1009,24 @@ mod tests { // (b) traversal in the package name let (code, _d) = unwrap_refused( - run_vendor(root, &blobs, &installed, &record, "pkg:composer/../evil@1.0.0", false) - .await, + run_vendor( + root, + &blobs, + &installed, + &record, + "pkg:composer/../evil@1.0.0", + false, + ) + .await, ); assert_eq!(code, "unsafe_coordinates"); assert!(!root.join(".socket").exists(), "nothing written"); assert!(!root.parent().unwrap().join("escape").exists()); - assert_eq!(tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), before); + assert_eq!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + before + ); } #[tokio::test] @@ -980,7 +1049,9 @@ mod tests { assert!(r2.success); assert!(r2.files_patched.is_empty(), "in-sync rerun patches nothing"); assert!( - r2.files_verified.iter().all(|v| v.status == VerifyStatus::AlreadyPatched), + r2.files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), "synthesized AlreadyPatched: {:?}", r2.files_verified ); @@ -988,9 +1059,14 @@ mod tests { e2.is_none(), "hot path must not re-record (would clobber the original in the ledger)" ); - assert_eq!(tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), lock_bytes); assert_eq!( - tokio::fs::read(root.join(copy_rel()).join("src/LoggerInterface.php")).await.unwrap(), + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + lock_bytes + ); + assert_eq!( + tokio::fs::read(root.join(copy_rel()).join("src/LoggerInterface.php")) + .await + .unwrap(), copy_bytes ); } @@ -1007,7 +1083,10 @@ mod tests { assert!(result.success, "{:?}", result.error); assert!(entry.is_none(), "dry run records nothing"); assert!(!root.join(".socket").exists(), "no copy created"); - assert_eq!(tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), before); + assert_eq!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + before + ); } #[tokio::test] @@ -1025,7 +1104,9 @@ mod tests { assert!(!result.success); assert!(entry.is_none()); assert!( - !root.join(format!(".socket/vendor/composer/{UUID}")).exists(), + !root + .join(format!(".socket/vendor/composer/{UUID}")) + .exists(), "half-built copy must be removed" ); assert_eq!( @@ -1055,12 +1136,18 @@ mod tests { let outcome = revert_composer(&entry, root, false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - !outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + !outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), "clean revert must not report drift: {:?}", outcome.warnings ); assert!( - outcome.warnings.iter().any(|w| w.code == "vendor_installed_copy_stale"), + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_installed_copy_stale"), "revert advises about the stale installed copy" ); assert_eq!( @@ -1069,7 +1156,9 @@ mod tests { "lock restored byte-identically" ); assert!( - !root.join(format!(".socket/vendor/composer/{UUID}")).exists(), + !root + .join(format!(".socket/vendor/composer/{UUID}")) + .exists(), "uuid dir removed" ); } @@ -1099,7 +1188,10 @@ mod tests { let outcome = revert_composer(&entry, root, false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), "drift must be reported: {:?}", outcome.warnings ); @@ -1109,7 +1201,9 @@ mod tests { "drifted lock left alone" ); assert!( - !root.join(format!(".socket/vendor/composer/{UUID}")).exists(), + !root + .join(format!(".socket/vendor/composer/{UUID}")) + .exists(), "uuid dir still removed" ); } diff --git a/crates/socket-patch-core/src/patch/vendor/gem.rs b/crates/socket-patch-core/src/patch/vendor/gem.rs index d688e88..f26cf7d 100644 --- a/crates/socket-patch-core/src/patch/vendor/gem.rs +++ b/crates/socket-patch-core/src/patch/vendor/gem.rs @@ -196,7 +196,10 @@ pub async fn vendor_gem( Err(_) => { return refused( "vendor_lockfile_missing", - format!("no Gemfile.lock at {} (the pair edit needs the lock)", lock_path.display()), + format!( + "no Gemfile.lock at {} (the pair edit needs the lock)", + lock_path.display() + ), ); } }; @@ -242,12 +245,18 @@ pub async fn vendor_gem( // originals. let remote_line = format!(" remote: {copy_rel}"); let wired = copy_matches_after_hashes(©_dir, &record.files).await - && tokio::fs::metadata(copy_dir.join(format!("{name}.gemspec"))).await.is_ok() + && tokio::fs::metadata(copy_dir.join(format!("{name}.gemspec"))) + .await + .is_ok() && lock_text.split('\n').any(|l| l == remote_line) && gemfile_text.contains(©_rel); if wired { if lock_checksum_in_sync(&lock_text, name, version) { - let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + let verified = record + .files + .keys() + .map(|f| already_patched_verify(f)) + .collect(); return VendorOutcome::Done { result: synthesized_result(purl, ©_dir, verified, true, None), entry: None, @@ -273,9 +282,16 @@ pub async fn vendor_gem( // ── dry run: verify-only against the installed dir, no writes ──────── if dry_run { - let mut result = - apply_package_patch(purl, installed_dir, &record.files, sources, Some(&record.uuid), true, force) - .await; + let mut result = apply_package_patch( + purl, + installed_dir, + &record.files, + sources, + Some(&record.uuid), + true, + force, + ) + .await; result.package_path = copy_dir.display().to_string(); return VendorOutcome::Done { result, @@ -314,15 +330,24 @@ pub async fn vendor_gem( ©_dir, Vec::new(), false, - Some(format!("failed to copy the stub gemspec into the vendored dir: {e}")), + Some(format!( + "failed to copy the stub gemspec into the vendored dir: {e}" + )), ), entry: None, warnings: Vec::new(), }; } - let mut result = - apply_package_patch(purl, ©_dir, &record.files, sources, Some(&record.uuid), false, force) - .await; + let mut result = apply_package_patch( + purl, + ©_dir, + &record.files, + sources, + Some(&record.uuid), + false, + force, + ) + .await; result.package_path = copy_dir.display().to_string(); if !result.success { // Don't leave a half-built copy; neither file was touched. @@ -340,7 +365,11 @@ pub async fn vendor_gem( let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some(format!("failed to write Gemfile: {e}")); - return VendorOutcome::Done { result, entry: None, warnings: Vec::new() }; + return VendorOutcome::Done { + result, + entry: None, + warnings: Vec::new(), + }; } // ── Gemfile.lock edit (a failure here unwinds the Gemfile) ─────────── @@ -363,7 +392,11 @@ pub async fn vendor_gem( let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some(detail); - return VendorOutcome::Done { result, entry: None, warnings: Vec::new() }; + return VendorOutcome::Done { + result, + entry: None, + warnings: Vec::new(), + }; } }; @@ -390,7 +423,10 @@ pub async fn vendor_gem( } let gemfile_record = match &plan { - GemfilePlan::Rewrite { original_line, new_line } => WiringRecord { + GemfilePlan::Rewrite { + original_line, + new_line, + } => WiringRecord { file: GEMFILE.to_string(), kind: GEMFILE_WIRING_KIND.to_string(), action: WiringAction::Rewritten, @@ -497,11 +533,15 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) // last (the mirror image of vendor's Gemfile-then-lock). for w in entry.wiring.iter().rev() { let restored = match w.kind.as_str() { - LOCK_WIRING_KIND => revert_lock_record(&project_root.join(GEMFILE_LOCK), w, dry_run).await, + LOCK_WIRING_KIND => { + revert_lock_record(&project_root.join(GEMFILE_LOCK), w, dry_run).await + } LOCK_CHECKSUM_WIRING_KIND => { revert_lock_checksum_record(&project_root.join(GEMFILE_LOCK), w, dry_run).await } - GEMFILE_WIRING_KIND => revert_gemfile_record(&project_root.join(GEMFILE), w, dry_run).await, + GEMFILE_WIRING_KIND => { + revert_gemfile_record(&project_root.join(GEMFILE), w, dry_run).await + } _ => { warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", @@ -553,7 +593,10 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) enum GemfilePlan { /// The gem is declared on a safe single top-level line: rewrite it in /// place (quote style preserved). - Rewrite { original_line: String, new_line: String }, + Rewrite { + original_line: String, + new_line: String, + }, /// The gem is transitive (not declared): append a fenced managed block. Append { block: String }, } @@ -592,7 +635,9 @@ fn plan_gemfile_edit( }); } if found.len() > 1 { - return Err(format!("`gem \"{name}\"` is declared more than once in the Gemfile")); + return Err(format!( + "`gem \"{name}\"` is declared more than once in the Gemfile" + )); } let (idx, top_level, paren, q, rest) = found.remove(0); if !top_level { @@ -601,10 +646,14 @@ fn plan_gemfile_edit( )); } if paren { - return Err(format!("the `gem \"{name}\"` declaration uses a parenthesized call")); + return Err(format!( + "the `gem \"{name}\"` declaration uses a parenthesized call" + )); } if let Some(reason) = rest_blocks_edit(&rest) { - return Err(format!("the `gem \"{name}\"` declaration is not editable: {reason}")); + return Err(format!( + "the `gem \"{name}\"` declaration is not editable: {reason}" + )); } Ok(GemfilePlan::Rewrite { original_line: lines[idx].to_string(), @@ -664,7 +713,10 @@ fn rest_blocks_edit(rest: &str) -> Option { fn apply_gemfile_plan(text: &str, plan: &GemfilePlan) -> String { match plan { - GemfilePlan::Rewrite { original_line, new_line } => { + GemfilePlan::Rewrite { + original_line, + new_line, + } => { let mut lines: Vec<&str> = text.split('\n').collect(); if let Some(i) = lines.iter().position(|l| *l == original_line) { lines[i] = new_line; @@ -1069,7 +1121,10 @@ fn revert_lock_text(text: &str, original_lines: &[String], new_lines: &[String]) if !remote_line.starts_with(" remote: ") { return None; } - let spec_block: Vec<&String> = original_lines.iter().filter(|l| l.starts_with(" ")).collect(); + let spec_block: Vec<&String> = original_lines + .iter() + .filter(|l| l.starts_with(" ")) + .collect(); let old_dep_line = original_lines .iter() .find(|l| l.starts_with(" ") && !l[2..].starts_with(' ')); @@ -1113,7 +1168,10 @@ fn revert_lock_text(text: &str, original_lines: &[String], new_lines: &[String]) None => i += 1, } } - lines.splice(insert_at..insert_at, spec_block.iter().map(|l| (*l).clone())); + lines.splice( + insert_at..insert_at, + spec_block.iter().map(|l| (*l).clone()), + ); // 3. DEPENDENCIES entry: restore the original line, or delete the one we // added for a transitive gem. @@ -1261,7 +1319,8 @@ mod tests { const GEMSPEC: &str = "Gem::Specification.new do |s|\n s.name = \"rack\"\n s.version = \"3.2.6\"\n s.summary = \"a modular Ruby web server interface\"\n s.require_paths = [\"lib\"]\nend\n"; - const GEMFILE_DIRECT: &str = "source \"https://rubygems.org\"\n\ngem \"puma\"\ngem \"rack\", \"~> 3.1\"\n"; + const GEMFILE_DIRECT: &str = + "source \"https://rubygems.org\"\n\ngem \"puma\"\ngem \"rack\", \"~> 3.1\"\n"; const GEMFILE_TRANSITIVE: &str = "source \"https://rubygems.org\"\n\ngem \"puma\"\n"; const LOCK_DIRECT: &str = "GEM\n remote: https://rubygems.org/\n specs:\n puma (6.4.2)\n nio4r (~> 2.0)\n rack (3.2.6)\n base64 (>= 0.1.0)\n\nPLATFORMS\n arm64-darwin-23\n ruby\n\nDEPENDENCIES\n puma\n rack (~> 3.1)\n\nBUNDLED WITH\n 2.5.22\n"; @@ -1282,16 +1341,24 @@ mod tests { let base = dir.path(); let installed = base.join("gem_home/gems/rack-3.2.6"); - tokio::fs::create_dir_all(installed.join("lib")).await.unwrap(); - tokio::fs::write(installed.join("lib/rack.rb"), PRISTINE).await.unwrap(); + tokio::fs::create_dir_all(installed.join("lib")) + .await + .unwrap(); + tokio::fs::write(installed.join("lib/rack.rb"), PRISTINE) + .await + .unwrap(); let specs = base.join("gem_home/specifications"); tokio::fs::create_dir_all(&specs).await.unwrap(); - tokio::fs::write(specs.join("rack-3.2.6.gemspec"), GEMSPEC).await.unwrap(); + tokio::fs::write(specs.join("rack-3.2.6.gemspec"), GEMSPEC) + .await + .unwrap(); let root = base.join("project"); tokio::fs::create_dir_all(&root).await.unwrap(); tokio::fs::write(root.join(GEMFILE), gemfile).await.unwrap(); - tokio::fs::write(root.join(GEMFILE_LOCK), lock).await.unwrap(); + tokio::fs::write(root.join(GEMFILE_LOCK), lock) + .await + .unwrap(); let before = compute_git_sha256_from_bytes(PRISTINE); let after = compute_git_sha256_from_bytes(PATCHED); @@ -1369,18 +1436,29 @@ mod tests { async fn test_direct_dep_happy_path() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; - let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); assert!(result.success, "vendor failed: {:?}", result.error); // Copy patched + gemspec materialized; installed dir untouched. let copy = root.join(copy_rel()); - assert_eq!(tokio::fs::read(copy.join("lib/rack.rb")).await.unwrap(), PATCHED); assert_eq!( - tokio::fs::read_to_string(copy.join("rack.gemspec")).await.unwrap(), + tokio::fs::read(copy.join("lib/rack.rb")).await.unwrap(), + PATCHED + ); + assert_eq!( + tokio::fs::read_to_string(copy.join("rack.gemspec")) + .await + .unwrap(), GEMSPEC, "stub gemspec copied in as .gemspec" ); - assert_eq!(tokio::fs::read(installed.join("lib/rack.rb")).await.unwrap(), PRISTINE); + assert_eq!( + tokio::fs::read(installed.join("lib/rack.rb")) + .await + .unwrap(), + PRISTINE + ); // Gemfile: line rewritten in place, double quotes preserved. let gemfile = tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(); @@ -1395,7 +1473,9 @@ mod tests { // Lock: the exact bundler-canonical pair-edit form (PATH before GEM, // bare relative remote, spec block moved with its sublines, exact-pin // `!` dependency, PLATFORMS/BUNDLED WITH byte-preserved). - let lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + let lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(); assert_eq!(lock, expected_lock_direct()); // Marker present in the uuid dir. @@ -1446,10 +1526,13 @@ mod tests { #[tokio::test] async fn test_single_quote_style_preserved() { let gemfile = "source 'https://rubygems.org'\n\ngem 'rack', '~> 3.1'\n"; - let lock = LOCK_DIRECT.replace(" puma\n", "").replace(" puma (6.4.2)\n nio4r (~> 2.0)\n", ""); + let lock = LOCK_DIRECT + .replace(" puma\n", "") + .replace(" puma (6.4.2)\n nio4r (~> 2.0)\n", ""); let (_tmp, root, installed, blobs, record) = fixture(gemfile, &lock).await; - let (result, _e, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + let (result, _e, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); assert!(result.success, "{:?}", result.error); let new_gemfile = tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(); assert!( @@ -1463,7 +1546,8 @@ mod tests { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_TRANSITIVE, LOCK_TRANSITIVE).await; - let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); assert!(result.success, "{:?}", result.error); let gemfile = tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(); @@ -1476,7 +1560,9 @@ mod tests { ); // DEPENDENCIES gains the pin in sorted position (after puma). - let lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + let lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(); assert!( lock.contains("DEPENDENCIES\n puma\n rack (= 3.2.6)!\n"), "sorted insert: {lock}" @@ -1486,7 +1572,12 @@ mod tests { assert_eq!(entry.wiring[0].action, WiringAction::Added); assert!(entry.wiring[0].original.is_none()); // No old DEPENDENCIES line recorded → revert deletes the added one. - let orig = entry.wiring[1].original.as_ref().unwrap().as_array().unwrap(); + let orig = entry.wiring[1] + .original + .as_ref() + .unwrap() + .as_array() + .unwrap(); assert!( orig.iter().all(|l| l.as_str().unwrap().starts_with(" ")), "transitive: only the spec block is recorded: {orig:?}" @@ -1498,7 +1589,8 @@ mod tests { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; tokio::fs::remove_file(root.join(GEMFILE)).await.unwrap(); - let (code, _d) = unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + let (code, _d) = + unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); assert_eq!(code, "gemfile_missing"); assert!(!root.join(".socket").exists(), "refusal must write nothing"); } @@ -1506,9 +1598,12 @@ mod tests { #[tokio::test] async fn test_refuses_missing_lock() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; - tokio::fs::remove_file(root.join(GEMFILE_LOCK)).await.unwrap(); + tokio::fs::remove_file(root.join(GEMFILE_LOCK)) + .await + .unwrap(); - let (code, _d) = unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + let (code, _d) = + unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); assert_eq!(code, "vendor_lockfile_missing"); assert!(!root.join(".socket").exists()); } @@ -1529,7 +1624,8 @@ mod tests { .await .unwrap(); - let (code, detail) = unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + let (code, detail) = + unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); assert_eq!(code, "native_extensions_unsupported"); assert!(detail.contains("native extensions")); assert!(!root.join(".socket").exists()); @@ -1539,7 +1635,9 @@ mod tests { GEMFILE_DIRECT ); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), LOCK_DIRECT ); } @@ -1551,7 +1649,8 @@ mod tests { let platform_dir = installed.parent().unwrap().join("rack-3.2.6-x86_64-linux"); tokio::fs::rename(&installed, &platform_dir).await.unwrap(); - let (code, _d) = unwrap_refused(run_vendor(&root, &blobs, &platform_dir, &record, false).await); + let (code, _d) = + unwrap_refused(run_vendor(&root, &blobs, &platform_dir, &record, false).await); assert_eq!(code, "platform_gem_unsupported"); assert!(!root.join(".socket").exists()); } @@ -1559,25 +1658,32 @@ mod tests { #[tokio::test] async fn test_refuses_unparseable_declaration() { // (a) indented inside a group block - let grouped = "source \"https://rubygems.org\"\n\ngroup :test do\n gem \"rack\", \"~> 3.1\"\nend\n"; + let grouped = + "source \"https://rubygems.org\"\n\ngroup :test do\n gem \"rack\", \"~> 3.1\"\nend\n"; let (_tmp, root, installed, blobs, record) = fixture(grouped, LOCK_DIRECT).await; - let (code, detail) = unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + let (code, detail) = + unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); assert_eq!(code, "gemfile_declaration_not_editable"); assert!(detail.contains("indented"), "{detail}"); assert!(!root.join(".socket").exists()); - assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), grouped); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + grouped + ); // (b) multi-line declaration (trailing comma continuation) let multiline = "source \"https://rubygems.org\"\n\ngem \"rack\",\n \"~> 3.1\"\n"; let (_tmp2, root2, installed2, blobs2, record2) = fixture(multiline, LOCK_DIRECT).await; - let (code, detail) = unwrap_refused(run_vendor(&root2, &blobs2, &installed2, &record2, false).await); + let (code, detail) = + unwrap_refused(run_vendor(&root2, &blobs2, &installed2, &record2, false).await); assert_eq!(code, "gemfile_declaration_not_editable"); assert!(detail.contains("continues"), "{detail}"); // (c) already path-sourced (a previous run / a user fork) let pathed = "source \"https://rubygems.org\"\n\ngem \"rack\", path: \"../rack-fork\"\n"; let (_tmp3, root3, installed3, blobs3, record3) = fixture(pathed, LOCK_DIRECT).await; - let (code, detail) = unwrap_refused(run_vendor(&root3, &blobs3, &installed3, &record3, false).await); + let (code, detail) = + unwrap_refused(run_vendor(&root3, &blobs3, &installed3, &record3, false).await); assert_eq!(code, "gemfile_declaration_not_editable"); assert!(detail.contains("path:"), "{detail}"); } @@ -1596,7 +1702,8 @@ mod tests { .await .unwrap(); - let (code, _d) = unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + let (code, _d) = + unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); assert_eq!(code, "gem_spec_missing"); assert!(!root.join(".socket").exists()); } @@ -1613,9 +1720,14 @@ mod tests { assert_eq!(code, "unsafe_coordinates"); assert!(!root.join(".socket").exists()); assert!(!root.parent().unwrap().join("escape").exists()); - assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), GEMFILE_DIRECT); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_DIRECT + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), LOCK_DIRECT ); } @@ -1628,9 +1740,12 @@ mod tests { let lock = "GEM\n remote: https://rubygems.org/\n specs:\n rack (3.2.6)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rack (~> 3.1)\n\nBUNDLED WITH\n 2.5.22\n"; let (_tmp, root, installed, blobs, record) = fixture(gemfile, lock).await; - let (result, _e, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + let (result, _e, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); assert!(result.success, "{:?}", result.error); - let new_lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + let new_lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(); assert_eq!( new_lock, format!( @@ -1654,7 +1769,9 @@ mod tests { assert!(r2.success); assert!(r2.files_patched.is_empty(), "in-sync rerun patches nothing"); assert!( - r2.files_verified.iter().all(|v| v.status == VerifyStatus::AlreadyPatched), + r2.files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), "synthesized AlreadyPatched: {:?}", r2.files_verified ); @@ -1663,14 +1780,18 @@ mod tests { "hot path must not re-record (would clobber the originals in the ledger)" ); assert_eq!(tokio::fs::read(root.join(GEMFILE)).await.unwrap(), gemfile1); - assert_eq!(tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(), lock1); + assert_eq!( + tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(), + lock1 + ); } #[tokio::test] async fn test_dry_run_writes_nothing() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; - let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, true).await); + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, true).await); assert!(result.success, "{:?}", result.error); assert!(entry.is_none(), "dry run records nothing"); assert!(!root.join(".socket").exists(), "no copy created"); @@ -1679,7 +1800,9 @@ mod tests { GEMFILE_DIRECT ); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), LOCK_DIRECT ); } @@ -1692,9 +1815,14 @@ mod tests { let lock = LOCK_DIRECT.replace(" rack (3.2.6)", " rack (3.1.0)"); let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, &lock).await; - let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); assert!(!result.success); - assert!(result.error.as_deref().unwrap_or("").contains("Gemfile.lock")); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("Gemfile.lock")); assert!(entry.is_none()); assert_eq!( tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), @@ -1702,7 +1830,9 @@ mod tests { "Gemfile unwound to its original bytes" ); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), lock, "lock untouched" ); @@ -1716,14 +1846,18 @@ mod tests { async fn test_revert_round_trip_direct() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; - let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); assert!(result.success); let entry = entry.unwrap(); let outcome = revert_gem(&entry, &root, false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - !outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + !outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), "clean revert must not report drift: {:?}", outcome.warnings ); @@ -1733,11 +1867,16 @@ mod tests { "Gemfile byte-identical to the fixture" ); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), LOCK_DIRECT, "lock byte-identical to the fixture" ); - assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists(), "uuid dir removed"); + assert!( + !root.join(format!(".socket/vendor/gem/{UUID}")).exists(), + "uuid dir removed" + ); } #[tokio::test] @@ -1745,7 +1884,8 @@ mod tests { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_TRANSITIVE, LOCK_TRANSITIVE).await; - let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); assert!(result.success); let entry = entry.unwrap(); @@ -1757,7 +1897,9 @@ mod tests { "managed block deleted, Gemfile byte-identical" ); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), LOCK_TRANSITIVE, "spec block moved back, added DEPENDENCIES entry deleted" ); @@ -1768,15 +1910,20 @@ mod tests { async fn test_revert_drift_warnings() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; - let (result, entry, _w) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); assert!(result.success); let entry = entry.unwrap(); // Third-party drift: a `bundle update` regenerated both files back to // registry form. Revert must leave them alone, warn per file, and // still remove the artifact dir. - tokio::fs::write(root.join(GEMFILE), GEMFILE_DIRECT).await.unwrap(); - tokio::fs::write(root.join(GEMFILE_LOCK), LOCK_DIRECT).await.unwrap(); + tokio::fs::write(root.join(GEMFILE), GEMFILE_DIRECT) + .await + .unwrap(); + tokio::fs::write(root.join(GEMFILE_LOCK), LOCK_DIRECT) + .await + .unwrap(); let outcome = revert_gem(&entry, &root, false).await; assert!(outcome.success, "{:?}", outcome.error); @@ -1785,13 +1932,19 @@ mod tests { .iter() .filter(|w| w.code == "vendor_lock_entry_drifted") .count(); - assert_eq!(drift_count, 2, "one drift warning per file: {:?}", outcome.warnings); + assert_eq!( + drift_count, 2, + "one drift warning per file: {:?}", + outcome.warnings + ); assert_eq!( tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), GEMFILE_DIRECT ); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), LOCK_DIRECT ); assert!( @@ -1804,7 +1957,8 @@ mod tests { const PURL_318: &str = "pkg:gem/rack@3.1.8"; const PRISTINE_318: &[u8] = b"module Rack\n VERSION = \"3.1.8\"\nend\n"; - const PATCHED_318: &[u8] = b"module Rack\n SOCKET_PATCHED = true\n VERSION = \"3.1.8\"\nend\n"; + const PATCHED_318: &[u8] = + b"module Rack\n SOCKET_PATCHED = true\n VERSION = \"3.1.8\"\nend\n"; const GEMSPEC_318: &str = "Gem::Specification.new do |s|\n s.name = \"rack\"\n s.version = \"3.1.8\"\n s.require_paths = [\"lib\"]\nend\n"; // Embedded VERBATIM from the spike pair @@ -1846,22 +2000,32 @@ mod tests { let base = dir.path(); let installed = base.join("gem_home/gems/rack-3.1.8"); - tokio::fs::create_dir_all(installed.join("lib")).await.unwrap(); - tokio::fs::write(installed.join("lib/rack.rb"), PRISTINE_318).await.unwrap(); + tokio::fs::create_dir_all(installed.join("lib")) + .await + .unwrap(); + tokio::fs::write(installed.join("lib/rack.rb"), PRISTINE_318) + .await + .unwrap(); let specs = base.join("gem_home/specifications"); tokio::fs::create_dir_all(&specs).await.unwrap(); - tokio::fs::write(specs.join("rack-3.1.8.gemspec"), GEMSPEC_318).await.unwrap(); + tokio::fs::write(specs.join("rack-3.1.8.gemspec"), GEMSPEC_318) + .await + .unwrap(); let root = base.join("project"); tokio::fs::create_dir_all(&root).await.unwrap(); tokio::fs::write(root.join(GEMFILE), gemfile).await.unwrap(); - tokio::fs::write(root.join(GEMFILE_LOCK), lock).await.unwrap(); + tokio::fs::write(root.join(GEMFILE_LOCK), lock) + .await + .unwrap(); let before = compute_git_sha256_from_bytes(PRISTINE_318); let after = compute_git_sha256_from_bytes(PATCHED_318); let blobs = base.join("blobs"); tokio::fs::create_dir_all(&blobs).await.unwrap(); - tokio::fs::write(blobs.join(&after), PATCHED_318).await.unwrap(); + tokio::fs::write(blobs.join(&after), PATCHED_318) + .await + .unwrap(); let mut files = HashMap::new(); files.insert( @@ -1915,7 +2079,9 @@ mod tests { // Lock: bundler's own path-gem output (spike G3 pair) byte-for-byte, // modulo the PATH remote value. - let lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + let lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(); assert_eq!(lock, expected_lock_checksums()); // Ledger: the checksum rewrite is its own third record with the @@ -1968,7 +2134,9 @@ mod tests { // Full oracle: rack moved to PATH + sorted `!` dep + bare CHECKSUMS // entry; puma's checksum line is byte-untouched. - let new_lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + let new_lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(); assert_eq!( new_lock, format!( @@ -1984,12 +2152,23 @@ mod tests { let outcome = revert_gem(&entry, &root, false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - !outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + !outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), "clean revert must not report drift: {:?}", outcome.warnings ); - assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), gemfile); - assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), lock); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + gemfile + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + lock + ); } #[tokio::test] @@ -2005,7 +2184,10 @@ mod tests { let outcome = revert_gem(&entry, &root, false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - !outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + !outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), "clean revert must not report drift: {:?}", outcome.warnings ); @@ -2016,7 +2198,9 @@ mod tests { SPIKE_GEMFILE_CHECKSUMS ); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), SPIKE_LOCK_CHECKSUMS_BEFORE ); assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists()); @@ -2027,7 +2211,8 @@ mod tests { let (_tmp, root, installed, blobs, record) = fixture_318(SPIKE_GEMFILE_CHECKSUMS, SPIKE_LOCK_CHECKSUMS_BEFORE).await; - let (r1, e1, _) = unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + let (r1, e1, _) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); assert!(r1.success); assert!(e1.is_some()); let gemfile1 = tokio::fs::read(root.join(GEMFILE)).await.unwrap(); @@ -2035,11 +2220,15 @@ mod tests { // The bare CHECKSUMS entry counts as in-sync: the rerun takes the hot // path and records nothing. - let (r2, e2, _) = unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + let (r2, e2, _) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); assert!(r2.success); assert!(e2.is_none(), "hot path must not re-record"); assert_eq!(tokio::fs::read(root.join(GEMFILE)).await.unwrap(), gemfile1); - assert_eq!(tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(), lock1); + assert_eq!( + tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(), + lock1 + ); } #[tokio::test] @@ -2064,7 +2253,9 @@ mod tests { entry.wiring ); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), expected_lock_checksums(), "the bare line is kept verbatim" ); @@ -2083,10 +2274,19 @@ mod tests { let (result, entry, _w) = unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); assert!(result.success, "{:?}", result.error); - assert_eq!(entry.unwrap().wiring.len(), 2, "no checksum record for an absent entry"); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), - expected_lock_checksums().replace(" rack (3.1.8)\n\nBUNDLED", &format!("{other_line}\n\nBUNDLED")), + entry.unwrap().wiring.len(), + 2, + "no checksum record for an absent entry" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + expected_lock_checksums().replace( + " rack (3.1.8)\n\nBUNDLED", + &format!("{other_line}\n\nBUNDLED") + ), "the foreign entry is byte-untouched" ); } @@ -2096,7 +2296,8 @@ mod tests { // A CHECKSUMS line that names our gem but breaks the entry grammar // (lost closing paren) fails closed AFTER the Gemfile was rewritten: // the pair-edit unwind must restore the Gemfile bytes. - let lock = SPIKE_LOCK_CHECKSUMS_BEFORE.replace(SPIKE_RACK_SHA_LINE, " rack (3.1.8 sha256=deadbeef"); + let lock = SPIKE_LOCK_CHECKSUMS_BEFORE + .replace(SPIKE_RACK_SHA_LINE, " rack (3.1.8 sha256=deadbeef"); let (_tmp, root, installed, blobs, record) = fixture_318(SPIKE_GEMFILE_CHECKSUMS, &lock).await; @@ -2104,7 +2305,10 @@ mod tests { unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); assert!(!result.success); let err = result.error.as_deref().unwrap_or(""); - assert!(err.contains("CHECKSUMS") && err.contains("not parseable"), "{err}"); + assert!( + err.contains("CHECKSUMS") && err.contains("not parseable"), + "{err}" + ); assert!(entry.is_none()); assert_eq!( tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), @@ -2112,7 +2316,9 @@ mod tests { "Gemfile unwound to its original bytes" ); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), lock, "lock untouched" ); @@ -2136,7 +2342,11 @@ mod tests { unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); assert!(!result.success); assert!( - result.error.as_deref().unwrap_or("").contains("platform-suffixed"), + result + .error + .as_deref() + .unwrap_or("") + .contains("platform-suffixed"), "{:?}", result.error ); @@ -2146,7 +2356,12 @@ mod tests { SPIKE_GEMFILE_CHECKSUMS, "Gemfile unwound" ); - assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), lock); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + lock + ); assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists()); } @@ -2186,10 +2401,17 @@ mod tests { // a token): revert must leave that line alone with a warning, never // clobber it, while the other records still restore cleanly. let drifted_line = " rack (3.1.8) sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let wired = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); - let edited = wired.replace("\nCHECKSUMS\n rack (3.1.8)\n", &format!("\nCHECKSUMS\n{drifted_line}\n")); + let wired = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(); + let edited = wired.replace( + "\nCHECKSUMS\n rack (3.1.8)\n", + &format!("\nCHECKSUMS\n{drifted_line}\n"), + ); assert_ne!(edited, wired, "fixture edit must hit the bare line"); - tokio::fs::write(root.join(GEMFILE_LOCK), &edited).await.unwrap(); + tokio::fs::write(root.join(GEMFILE_LOCK), &edited) + .await + .unwrap(); let outcome = revert_gem(&entry, &root, false).await; assert!(outcome.success, "{:?}", outcome.error); @@ -2198,9 +2420,15 @@ mod tests { .iter() .filter(|w| w.code == "vendor_lock_entry_drifted") .count(); - assert_eq!(drift_count, 1, "exactly the checksum record drifts: {:?}", outcome.warnings); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + drift_count, 1, + "exactly the checksum record drifts: {:?}", + outcome.warnings + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), SPIKE_LOCK_CHECKSUMS_BEFORE.replace(SPIKE_RACK_SHA_LINE, drifted_line), "everything else restored; the drifted checksum line preserved verbatim" ); @@ -2220,15 +2448,20 @@ mod tests { // a lock it has no ledger entry for. let (_tmp, root, installed, blobs, record) = fixture_318(SPIKE_GEMFILE_CHECKSUMS, SPIKE_LOCK_CHECKSUMS_BEFORE).await; - let (r1, _e1, _) = unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + let (r1, _e1, _) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); assert!(r1.success); - let wired = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(); + let wired = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(); let v1 = wired.replace( "\nCHECKSUMS\n rack (3.1.8)\n", &format!("\nCHECKSUMS\n{SPIKE_RACK_SHA_LINE}\n"), ); assert_ne!(v1, wired, "fixture edit must hit the bare line"); - tokio::fs::write(root.join(GEMFILE_LOCK), &v1).await.unwrap(); + tokio::fs::write(root.join(GEMFILE_LOCK), &v1) + .await + .unwrap(); let gemfile = tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(); let (code, detail) = @@ -2236,7 +2469,15 @@ mod tests { assert_eq!(code, "vendor_stale_lock_checksum"); assert!(detail.contains("vendor --revert"), "{detail}"); // The refusal mutates nothing. - assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), gemfile); - assert_eq!(tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), v1); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + gemfile + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + v1 + ); } } diff --git a/crates/socket-patch-core/src/patch/vendor/golang.rs b/crates/socket-patch-core/src/patch/vendor/golang.rs index 6279a0c..c03b3aa 100644 --- a/crates/socket-patch-core/src/patch/vendor/golang.rs +++ b/crates/socket-patch-core/src/patch/vendor/golang.rs @@ -285,9 +285,9 @@ pub async fn revert_go_vendor( if !dry_run { let uuid_dir = project_root.join(&base_rel); let _ = remove_tree(&uuid_dir).await; // ignore NotFound - // Best-effort: prune the now-empty `.socket/vendor/golang/` level so a - // fully-reverted project carries no vendor residue (`save_state` then - // prunes `.socket/vendor/` itself). `remove_dir` fails on non-empty. + // Best-effort: prune the now-empty `.socket/vendor/golang/` level so a + // fully-reverted project carries no vendor residue (`save_state` then + // prunes `.socket/vendor/` itself). `remove_dir` fails on non-empty. if let Some(eco_dir) = uuid_dir.parent() { let _ = tokio::fs::remove_dir(eco_dir).await; } @@ -362,7 +362,9 @@ mod tests { let pristine = root.join("cache/github.com/foo/bar@v1.4.2"); tokio::fs::create_dir_all(&pristine).await.unwrap(); - tokio::fs::write(pristine.join("bar.go"), PRISTINE).await.unwrap(); + tokio::fs::write(pristine.join("bar.go"), PRISTINE) + .await + .unwrap(); tokio::fs::write( pristine.join("go.mod"), "module github.com/foo/bar\n\ngo 1.21\n", @@ -416,7 +418,9 @@ mod tests { .await } - fn expect_done(outcome: VendorOutcome) -> (ApplyResult, Option, Vec) { + fn expect_done( + outcome: VendorOutcome, + ) -> (ApplyResult, Option, Vec) { match outcome { VendorOutcome::Done { result, @@ -436,7 +440,10 @@ mod tests { detail } VendorOutcome::Done { result, .. } => { - panic!("expected Refused({want_code}), got Done (success={})", result.success) + panic!( + "expected Refused({want_code}), got Done (success={})", + result.success + ) } } } @@ -457,13 +464,19 @@ mod tests { assert_eq!(tokio::fs::read(copy.join("bar.go")).await.unwrap(), PATCHED); assert!(copy.join("go.mod").exists()); // The module cache pristine is untouched. - assert_eq!(tokio::fs::read(pristine.join("bar.go")).await.unwrap(), PRISTINE); + assert_eq!( + tokio::fs::read(pristine.join("bar.go")).await.unwrap(), + PRISTINE + ); // The replace directive is vendor-owned and points at the uuid path. let entries = read_replace_entries(root).await; let e = entries.iter().find(|e| e.module == MODULE).unwrap(); assert_eq!(e.owner, Some(ReplaceOwner::Vendor)); - assert_eq!(e.path.as_deref(), Some(format!("./{}", copy_rel()).as_str())); + assert_eq!( + e.path.as_deref(), + Some(format!("./{}", copy_rel()).as_str()) + ); assert_eq!(e.version.as_deref(), Some(VERSION)); // Marker sits in the uuid dir, carrying the vuln + uuid + base purl. @@ -474,7 +487,10 @@ mod tests { .unwrap(); assert!(marker.contains(UUID)); assert!(marker.contains("GHSA-xxxx-yyyy-zzzz")); - assert!(marker.contains(&format!("\"purl\": \"{PURL}\"")), "{marker}"); + assert!( + marker.contains(&format!("\"purl\": \"{PURL}\"")), + "{marker}" + ); // Ledger entry shape. let entry = entry.expect("entry on success"); @@ -491,7 +507,10 @@ mod tests { assert_eq!(w.action, WiringAction::Added); assert_eq!(w.key.as_deref(), Some(MODULE)); assert_eq!(w.original, None); - assert_eq!(w.new, Some(serde_json::Value::from(format!("./{}", copy_rel())))); + assert_eq!( + w.new, + Some(serde_json::Value::from(format!("./{}", copy_rel()))) + ); } #[tokio::test] @@ -501,8 +520,17 @@ mod tests { // Pre-seed an `apply` redirect through the engine itself. let sources = PatchSources::blobs_only(&blobs); let pre = apply_go_redirect( - PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &record.files, &sources, - Some(UUID), false, false, + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &record.files, + &sources, + Some(UUID), + false, + false, ) .await; assert!(pre.success, "fixture redirect failed: {:?}", pre.error); @@ -521,7 +549,11 @@ mod tests { // Exactly ONE directive for the module, now vendor-owned. let entries = read_replace_entries(root).await; let mine: Vec<_> = entries.iter().filter(|e| e.module == MODULE).collect(); - assert_eq!(mine.len(), 1, "single directive after takeover: {entries:?}"); + assert_eq!( + mine.len(), + 1, + "single directive after takeover: {entries:?}" + ); assert_eq!(mine[0].owner, Some(ReplaceOwner::Vendor)); let entry = entry.unwrap(); @@ -551,19 +583,32 @@ mod tests { let (result, entry, warnings) = expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); assert!(result.success); - assert!(result.files_patched.is_empty(), "in-sync re-run patches nothing"); + assert!( + result.files_patched.is_empty(), + "in-sync re-run patches nothing" + ); assert!(entry.is_some(), "re-run still reports the ledger entry"); assert!(!entry.unwrap().took_over_go_patches); assert!(warnings.is_empty(), "{warnings:?}"); - assert_eq!(tokio::fs::read(©).await.unwrap(), copy1, "copy unchanged"); - assert_eq!(tokio::fs::read(&gomod).await.unwrap(), mod1, "go.mod byte-stable"); + assert_eq!( + tokio::fs::read(©).await.unwrap(), + copy1, + "copy unchanged" + ); + assert_eq!( + tokio::fs::read(&gomod).await.unwrap(), + mod1, + "go.mod byte-stable" + ); } #[tokio::test] async fn test_dry_run_writes_nothing() { let (dir, blobs, pristine, record) = fixture().await; let root = dir.path(); - let gomod_before = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); + let gomod_before = tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(); let (result, entry, _warnings) = expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, true).await); @@ -571,7 +616,9 @@ mod tests { assert!(entry.is_none(), "dry-run emits no entry"); assert!(!root.join(format!(".socket/vendor/golang/{UUID}")).exists()); assert_eq!( - tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(), + tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(), gomod_before, "go.mod untouched" ); @@ -589,7 +636,9 @@ mod tests { ) .await .unwrap(); - let gomod_before = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); + let gomod_before = tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(); let (result, entry, _warnings) = expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); @@ -597,7 +646,9 @@ mod tests { assert!(entry.is_none()); // go.mod untouched and the failed copy fully unwound (no uuid husks). assert_eq!( - tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(), + tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(), gomod_before ); assert!(!root.join(format!(".socket/vendor/golang/{UUID}")).exists()); @@ -633,8 +684,17 @@ mod tests { // Vendor takes over an apply redirect, then is reverted. let sources = PatchSources::blobs_only(&blobs); apply_go_redirect( - PURL, MODULE, VERSION, &pristine, root, GO_PATCHES_DIR, &record.files, &sources, - Some(UUID), false, false, + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &record.files, + &sources, + Some(UUID), + false, + false, ) .await; let (_result, entry, _warnings) = @@ -645,14 +705,18 @@ mod tests { let out = revert_go_vendor(&entry, root, false).await; assert!(out.success, "{:?}", out.error); assert!( - out.warnings.iter().any(|w| w.code == "takeover_not_restored"), + out.warnings + .iter() + .any(|w| w.code == "takeover_not_restored"), "{:?}", out.warnings ); // Neither the vendor directive nor the go-patches one remains: the // module is back on the pristine cache until `apply` is re-run. assert!(read_replace_entries(root).await.is_empty()); - assert!(!root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists()); + assert!(!root + .join(".socket/go-patches/github.com/foo/bar@v1.4.2") + .exists()); } // ── filesystem-safety: coordinate traversal ────────────────────────── @@ -664,7 +728,9 @@ mod tests { async fn test_refuses_traversal_coordinates() { let (dir, blobs, pristine, record) = fixture().await; let root = dir.path(); - let gomod_before = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); + let gomod_before = tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(); let escaped = root.parent().unwrap().join("escape@v1.0.0"); let _ = remove_tree(&escaped).await; @@ -693,13 +759,22 @@ mod tests { "unsafe_coordinates", ); expect_refused( - run_vendor("pkg:cargo/not-golang@1.0.0", root, &blobs, &pristine, &record, false) - .await, + run_vendor( + "pkg:cargo/not-golang@1.0.0", + root, + &blobs, + &pristine, + &record, + false, + ) + .await, "unsafe_coordinates", ); assert!(!escaped.exists(), "no copy outside the project"); assert_eq!( - tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(), + tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(), gomod_before, "go.mod untouched" ); @@ -721,7 +796,10 @@ mod tests { ); assert!(detail.contains("uuid"), "{detail}"); } - assert!(read_replace_entries(root).await.is_empty(), "go.mod untouched"); + assert!( + read_replace_entries(root).await.is_empty(), + "go.mod untouched" + ); } /// SECURITY regression: revert re-validates the (tamper-able) ledger entry @@ -760,7 +838,10 @@ mod tests { assert!(result.success); assert!(entry.is_none(), "nothing vendored, nothing recorded"); assert!(warnings.is_empty()); - assert!(read_replace_entries(root).await.is_empty(), "no replace written"); + assert!( + read_replace_entries(root).await.is_empty(), + "no replace written" + ); assert!(!root.join(format!(".socket/vendor/golang/{UUID}")).exists()); } } diff --git a/crates/socket-patch-core/src/patch/vendor/mod.rs b/crates/socket-patch-core/src/patch/vendor/mod.rs index 8735b62..433a37a 100644 --- a/crates/socket-patch-core/src/patch/vendor/mod.rs +++ b/crates/socket-patch-core/src/patch/vendor/mod.rs @@ -38,6 +38,8 @@ pub mod path; pub mod state; +mod berry_zip; +pub mod bun_lock; #[cfg(feature = "cargo")] pub mod cargo; #[cfg(feature = "cargo")] @@ -49,8 +51,6 @@ pub mod composer_lock; pub mod gem; #[cfg(feature = "golang")] pub mod golang; -mod berry_zip; -pub mod bun_lock; mod npm_common; pub mod npm_flavor; pub mod npm_lock; @@ -142,8 +142,7 @@ pub fn is_vendorable(purl: &str) -> bool { pub async fn is_purl_vendored(project_root: &std::path::Path, purl: &str) -> bool { match load_state(project_root).await { Ok(state) => { - state.entries.contains_key(purl) - || state.entries.values().any(|e| e.base_purl == purl) + state.entries.contains_key(purl) || state.entries.values().any(|e| e.base_purl == purl) } Err(_) => false, } diff --git a/crates/socket-patch-core/src/patch/vendor/npm_common.rs b/crates/socket-patch-core/src/patch/vendor/npm_common.rs index 098bd93..1b26cc4 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_common.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_common.rs @@ -288,11 +288,13 @@ pub(super) fn tgz_rel_leaf(name: &str, version: &str) -> String { /// `true` means "all deps", an array names them; either makes the package /// unvendorable (see the refusal site). fn declares_bundled_deps(pkg: &Value) -> bool { - ["bundleDependencies", "bundledDependencies"].iter().any(|k| match pkg.get(*k) { - Some(Value::Bool(b)) => *b, - Some(Value::Array(a)) => !a.is_empty(), - _ => false, - }) + ["bundleDependencies", "bundledDependencies"] + .iter() + .any(|k| match pkg.get(*k) { + Some(Value::Bool(b)) => *b, + Some(Value::Array(a)) => !a.is_empty(), + _ => false, + }) } async fn read_staged_package_json(stage: &Path) -> Result { @@ -371,10 +373,12 @@ mod tests { assert_eq!(coords.uuid_dir_rel, format!(".socket/vendor/npm/{UUID}")); assert_eq!(coords.base_purl, "pkg:npm/left-pad@1.3.0"); - let coords = - guard_coordinates("pkg:npm/@scope/pkg@1.0.0?artifact_id=x", &record).unwrap(); + let coords = guard_coordinates("pkg:npm/@scope/pkg@1.0.0?artifact_id=x", &record).unwrap(); assert_eq!((coords.name, coords.version), ("@scope/pkg", "1.0.0")); - assert_eq!(coords.base_purl, "pkg:npm/@scope/pkg@1.0.0", "qualifiers stripped"); + assert_eq!( + coords.base_purl, "pkg:npm/@scope/pkg@1.0.0", + "qualifiers stripped" + ); } #[test] @@ -406,7 +410,12 @@ mod tests { #[tokio::test] async fn done_failure_shape_matches_contract() { let outcome = done_failure("pkg:npm/x@1.0.0", "boom".to_string()); - let VendorOutcome::Done { result, entry, warnings } = outcome else { + let VendorOutcome::Done { + result, + entry, + warnings, + } = outcome + else { panic!("done_failure must be Done"); }; assert!(!result.success); diff --git a/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs b/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs index 67b7aeb..f5484db 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs @@ -72,7 +72,10 @@ const YARN_SNIFF_HEAD_LINES: usize = 30; const LOCKFILE_FAMILIES: [(NpmLockFlavor, &[&str]); 4] = [ // npm itself ignores package-lock.json when npm-shrinkwrap.json exists, // so the npm family never warns about its own sibling. - (NpmLockFlavor::PackageLock, &["npm-shrinkwrap.json", "package-lock.json"]), + ( + NpmLockFlavor::PackageLock, + &["npm-shrinkwrap.json", "package-lock.json"], + ), (NpmLockFlavor::YarnClassic, &["yarn.lock"]), (NpmLockFlavor::Pnpm, &["pnpm-lock.yaml"]), // bun reads bun.lock when both exist (lockb is the migrated-away binary). @@ -278,31 +281,66 @@ pub async fn vendor_npm_any( let mut outcome = match flavor { NpmLockFlavor::PackageLock => { npm_lock::vendor_npm( - purl, installed_dir, project_root, record, sources, vendored_at, dry_run, force, + purl, + installed_dir, + project_root, + record, + sources, + vendored_at, + dry_run, + force, ) .await } NpmLockFlavor::YarnClassic => { super::yarn_classic_lock::vendor_yarn_classic( - purl, installed_dir, project_root, record, sources, vendored_at, dry_run, force, + purl, + installed_dir, + project_root, + record, + sources, + vendored_at, + dry_run, + force, ) .await } NpmLockFlavor::YarnBerry => { super::yarn_berry_lock::vendor_yarn_berry( - purl, installed_dir, project_root, record, sources, vendored_at, dry_run, force, + purl, + installed_dir, + project_root, + record, + sources, + vendored_at, + dry_run, + force, ) .await } NpmLockFlavor::Pnpm => { super::pnpm_lock::vendor_pnpm( - purl, installed_dir, project_root, record, sources, vendored_at, dry_run, force, + purl, + installed_dir, + project_root, + record, + sources, + vendored_at, + dry_run, + force, ) .await } NpmLockFlavor::Bun => { super::bun_lock::vendor_bun( - purl, installed_dir, project_root, record, sources, vendored_at, dry_run, force, + purl, + installed_dir, + project_root, + record, + sources, + vendored_at, + dry_run, + force, ) .await } @@ -312,7 +350,10 @@ pub async fn vendor_npm_any( // the entry so revert routes — and fails closed on a build lacking the // backend. Each backend already self-stamps `flavor`; we re-assert it from // the probe for belt-and-braces (the values are identical). - if let VendorOutcome::Done { entry, warnings, .. } = &mut outcome { + if let VendorOutcome::Done { + entry, warnings, .. + } = &mut outcome + { if !probe_warnings.is_empty() { let mut merged = probe_warnings; merged.append(warnings); @@ -365,13 +406,16 @@ mod tests { tokio::fs::write(root.join(name), content).await.unwrap(); } - async fn detect(root: &Path) -> Result<(NpmLockFlavor, Vec), (&'static str, String)> { + async fn detect( + root: &Path, + ) -> Result<(NpmLockFlavor, Vec), (&'static str, String)> { detect_npm_lock_flavor(root).await } const YARN_V1: &str = "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ # yarn lockfile v1\n\n\nleft-pad@^1.3.0:\n version \"1.3.0\"\n"; - const YARN_BERRY: &str = "# This file is generated by running \"yarn install\" inside your project.\n\ + const YARN_BERRY: &str = + "# This file is generated by running \"yarn install\" inside your project.\n\ # Manifest files (package.json) are also used.\n\n\ __metadata:\n version: 8\n cacheKey: 10\n"; const PNPM_9: &str = "lockfileVersion: '9.0'\n\nsettings:\n autoInstallPeers: true\n"; @@ -417,15 +461,27 @@ mod tests { touch(tmp.path(), "bun.lockb", "binary").await; let (code, detail) = detect(tmp.path()).await.unwrap_err(); assert_eq!(code, "vendor_bun_lockb_unsupported"); - assert!(detail.contains("bun install --save-text-lockfile"), "{detail}"); + assert!( + detail.contains("bun install --save-text-lockfile"), + "{detail}" + ); } #[tokio::test] async fn pnpm_version_sniff() { // Quoted (pnpm's own spelling), double-quoted, and bare all accept. - for head in ["lockfileVersion: '9.0'", "lockfileVersion: \"9.0\"", "lockfileVersion: 9.0"] { + for head in [ + "lockfileVersion: '9.0'", + "lockfileVersion: \"9.0\"", + "lockfileVersion: 9.0", + ] { let tmp = tempfile::tempdir().unwrap(); - touch(tmp.path(), "pnpm-lock.yaml", &format!("{head}\n\nsettings: {{}}\n")).await; + touch( + tmp.path(), + "pnpm-lock.yaml", + &format!("{head}\n\nsettings: {{}}\n"), + ) + .await; let (flavor, _) = detect(tmp.path()).await.unwrap(); assert_eq!(flavor, NpmLockFlavor::Pnpm, "{head}"); } @@ -440,7 +496,12 @@ mod tests { // No version line in the head at all. let tmp = tempfile::tempdir().unwrap(); - touch(tmp.path(), "pnpm-lock.yaml", "settings:\n autoInstallPeers: true\n").await; + touch( + tmp.path(), + "pnpm-lock.yaml", + "settings:\n autoInstallPeers: true\n", + ) + .await; let (code, _) = detect(tmp.path()).await.unwrap_err(); assert_eq!(code, "vendor_lockfile_version_unsupported"); } @@ -470,7 +531,10 @@ mod tests { async fn npm_locks_route_to_package_lock_and_nothing_is_missing() { let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "package-lock.json", "{}").await; - assert_eq!(detect(tmp.path()).await.unwrap().0, NpmLockFlavor::PackageLock); + assert_eq!( + detect(tmp.path()).await.unwrap().0, + NpmLockFlavor::PackageLock + ); let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "npm-shrinkwrap.json", "{}").await; @@ -501,10 +565,14 @@ mod tests { assert_eq!(flavor, NpmLockFlavor::Bun); let named: Vec<&str> = warnings.iter().map(|w| w.detail.as_str()).collect(); assert_eq!(warnings.len(), 3, "{named:?}"); - assert!(warnings.iter().all(|w| w.code == "vendor_multiple_lockfiles")); + assert!(warnings + .iter() + .all(|w| w.code == "vendor_multiple_lockfiles")); for file in ["pnpm-lock.yaml", "yarn.lock", "package-lock.json"] { assert!( - warnings.iter().any(|w| w.detail.contains(file) && w.detail.contains("UNPATCHED")), + warnings + .iter() + .any(|w| w.detail.contains(file) && w.detail.contains("UNPATCHED")), "missing loud warning for {file}: {named:?}" ); } @@ -517,7 +585,10 @@ mod tests { let (flavor, warnings) = detect(tmp.path()).await.unwrap(); assert_eq!(flavor, NpmLockFlavor::YarnClassic); assert_eq!(warnings.len(), 1, "{warnings:?}"); - assert!(warnings[0].detail.contains("package-lock.json"), "{warnings:?}"); + assert!( + warnings[0].detail.contains("package-lock.json"), + "{warnings:?}" + ); } /// Build a vendorable npm project (installed package, v3 package-lock, @@ -529,7 +600,12 @@ mod tests { let root = tmp.path(); let pkg = root.join("node_modules/left-pad"); tokio::fs::create_dir_all(&pkg).await.unwrap(); - touch(&pkg, "package.json", r#"{"name":"left-pad","version":"1.3.0"}"#).await; + touch( + &pkg, + "package.json", + r#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .await; tokio::fs::write(pkg.join("index.js"), ORIG).await.unwrap(); touch( root, @@ -551,7 +627,9 @@ mod tests { let blobs = root.join(".socket/blobs"); tokio::fs::create_dir_all(&blobs).await.unwrap(); let after_hash = compute_git_sha256_from_bytes(PATCHED); - tokio::fs::write(blobs.join(&after_hash), PATCHED).await.unwrap(); + tokio::fs::write(blobs.join(&after_hash), PATCHED) + .await + .unwrap(); let mut files = HashMap::new(); files.insert( "package/index.js".to_string(), @@ -572,7 +650,10 @@ mod tests { (tmp, record) } - async fn vendor_any(root: &Path, record: &crate::manifest::schema::PatchRecord) -> VendorOutcome { + async fn vendor_any( + root: &Path, + record: &crate::manifest::schema::PatchRecord, + ) -> VendorOutcome { let blobs = root.join(".socket/blobs"); let sources = crate::patch::apply::PatchSources::blobs_only(&blobs); vendor_npm_any( @@ -598,7 +679,12 @@ mod tests { let (tmp, record) = npm_project().await; let outcome = vendor_any(tmp.path(), &record).await; - let VendorOutcome::Done { result, entry, warnings } = outcome else { + let VendorOutcome::Done { + result, + entry, + warnings, + } = outcome + else { panic!("expected Done, got {outcome:?}"); }; assert!(result.success, "{:?}", result.error); @@ -606,8 +692,12 @@ mod tests { let entry = entry.expect("success carries a ledger entry"); assert_eq!(entry.flavor.as_deref(), Some("package-lock")); // The lock really was wired (the backend ran). - let lock = tokio::fs::read_to_string(tmp.path().join("package-lock.json")).await.unwrap(); - assert!(lock.contains(&format!("file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"))); + let lock = tokio::fs::read_to_string(tmp.path().join("package-lock.json")) + .await + .unwrap(); + assert!(lock.contains(&format!( + "file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz" + ))); } /// A yarn.lock now ROUTES to the yarn-classic backend (no longer the old @@ -617,7 +707,9 @@ mod tests { #[tokio::test] async fn yarn_lock_routes_to_the_backend_not_the_old_gate() { let (tmp, record) = npm_project().await; - tokio::fs::remove_file(tmp.path().join("package-lock.json")).await.unwrap(); + tokio::fs::remove_file(tmp.path().join("package-lock.json")) + .await + .unwrap(); touch(tmp.path(), "yarn.lock", YARN_V1).await; let outcome = vendor_any(tmp.path(), &record).await; @@ -629,7 +721,10 @@ mod tests { "yarn.lock must reach the yarn-classic backend, not the removed gate" ); assert_ne!(code, "vendor_pkg_manager_unsupported"); - assert!(!tmp.path().join(".socket/vendor").exists(), "refusal writes nothing"); + assert!( + !tmp.path().join(".socket/vendor").exists(), + "refusal writes nothing" + ); } #[tokio::test] diff --git a/crates/socket-patch-core/src/patch/vendor/npm_lock.rs b/crates/socket-patch-core/src/patch/vendor/npm_lock.rs index 0c1dcc9..71d70b4 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_lock.rs @@ -184,11 +184,18 @@ pub async fn vendor_npm( let Some(staged) = staged else { // Failed patch (no lock writes — wiring is last, so the project is // byte-untouched) or a dry run (stops after the verify). - return VendorOutcome::Done { result, entry: None, warnings }; + return VendorOutcome::Done { + result, + entry: None, + warnings, + }; }; // `staged.name`/`staged.version` echo the validated coords (the wiring // below keeps using the borrowed `name`/`version`). - debug_assert_eq!((staged.name.as_str(), staged.version.as_str()), (name, version)); + debug_assert_eq!( + (staged.name.as_str(), staged.version.as_str()), + (name, version) + ); let rel_tgz = staged.rel_tgz; let packed = staged.packed; let staged_pkg_json = staged.staged_pkg_json; @@ -205,7 +212,10 @@ pub async fn vendor_npm( let mut recomputed_deps = false; { let Some(packages) = lock.get_mut("packages").and_then(Value::as_object_mut) else { - return done_failure(purl, "lock `packages` object vanished mid-rewrite".to_string()); + return done_failure( + purl, + "lock `packages` object vanished mid-rewrite".to_string(), + ); }; for m in &matches { let Some(live) = packages.get_mut(&m.key).and_then(Value::as_object_mut) else { @@ -221,7 +231,10 @@ pub async fn vendor_npm( // dangling `.socket/vendor/` pointer from an earlier uuid. let was_vendored = entry_points_into_vendor(live); live.insert("resolved".to_string(), Value::String(resolved.clone())); - live.insert("integrity".to_string(), Value::String(packed.integrity.clone())); + live.insert( + "integrity".to_string(), + Value::String(packed.integrity.clone()), + ); if let Some(pkg) = &staged_pkg_json { recompute_dep_fields(live, pkg); recomputed_deps = true; @@ -231,7 +244,11 @@ pub async fn vendor_npm( kind: KIND_LOCK_ENTRY.to_string(), action: WiringAction::Rewritten, key: Some(m.key.clone()), - original: if was_vendored { None } else { Some(m.original.clone()) }, + original: if was_vendored { + None + } else { + Some(m.original.clone()) + }, new: Some(Value::Object(live.clone())), }); changed = true; @@ -270,7 +287,11 @@ pub async fn vendor_npm( // integrity: the project is in sync. Touch nothing (the tarball // rewrite above was byte-identical by determinism) and synthesize an // AlreadyPatched-style success, mirroring the go_redirect hot path. - let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + let verified = record + .files + .keys() + .map(|f| already_patched_verify(f)) + .collect(); return VendorOutcome::Done { result: synthesized_result(purl, &dest, verified, true, None), entry: None, @@ -328,7 +349,11 @@ pub async fn vendor_npm( pdm: None, pipenv: None, }; - VendorOutcome::Done { result, entry: Some(entry), warnings } + VendorOutcome::Done { + result, + entry: Some(entry), + warnings, + } } /// Undo one vendored npm package: restore the recorded lock fragments and @@ -397,7 +422,13 @@ pub async fn revert_npm(entry: &VendorEntry, project_root: &Path, dry_run: bool) let mut changed = false; // Reverse application order, like every backend's revert. for rec in entry.wiring.iter().rev().filter(|r| r.file == lock_name) { - revert_one_record(&mut lock, rec, &entry.uuid, &mut changed, &mut outcome.warnings); + revert_one_record( + &mut lock, + rec, + &entry.uuid, + &mut changed, + &mut outcome.warnings, + ); } if changed { @@ -438,7 +469,9 @@ struct LockMatch { enum LockScan { Matches(Vec), /// A matching key outside `node_modules/` — the caller refuses. - WorkspaceMember { key: String }, + WorkspaceMember { + key: String, + }, } /// Scan `packages` for instances of `name@version`, pushing skip warnings @@ -458,7 +491,9 @@ fn scan_lock_matches( if key.is_empty() { continue; } - let Some(obj) = entry.as_object() else { continue }; + let Some(obj) = entry.as_object() else { + continue; + }; if entry_name(key, obj) != name { continue; } @@ -560,7 +595,9 @@ fn rewrite_legacy_tree( changed: &mut bool, ) { for (dep_name, node) in deps.iter_mut() { - let Some(obj) = node.as_object_mut() else { continue }; + let Some(obj) = node.as_object_mut() else { + continue; + }; let pointer = format!("{pointer_base}/{}", escape_json_pointer_token(dep_name)); if dep_name == name && obj.get("version").and_then(Value::as_str) == Some(version) @@ -569,7 +606,10 @@ fn rewrite_legacy_tree( let was_vendored = entry_points_into_vendor(obj); let original = Value::Object(obj.clone()); obj.insert("resolved".to_string(), Value::String(resolved.to_string())); - obj.insert("integrity".to_string(), Value::String(integrity.to_string())); + obj.insert( + "integrity".to_string(), + Value::String(integrity.to_string()), + ); wiring.push(WiringRecord { file: lock_name.to_string(), kind: KIND_LOCK_LEGACY_ENTRY.to_string(), @@ -875,18 +915,27 @@ mod tests { let installed = root.join("node_modules").join(name); tokio::fs::create_dir_all(&installed).await.unwrap(); - tokio::fs::write(installed.join("package.json"), installed_pkg_json(name, version)) + tokio::fs::write( + installed.join("package.json"), + installed_pkg_json(name, version), + ) + .await + .unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX) .await .unwrap(); - tokio::fs::write(installed.join("index.js"), ORIG_INDEX).await.unwrap(); let blobs = root.join(".socket/blobs"); tokio::fs::create_dir_all(&blobs).await.unwrap(); let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); - tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX).await.unwrap(); + tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX) + .await + .unwrap(); let lock_bytes = serialize_lock(&lock, " ").unwrap(); - tokio::fs::write(root.join(PACKAGE_LOCK), &lock_bytes).await.unwrap(); + tokio::fs::write(root.join(PACKAGE_LOCK), &lock_bytes) + .await + .unwrap(); let mut files = HashMap::new(); files.insert( @@ -915,9 +964,15 @@ mod tests { } } - fn expect_done(outcome: VendorOutcome) -> (ApplyResult, Option, Vec) { + fn expect_done( + outcome: VendorOutcome, + ) -> (ApplyResult, Option, Vec) { match outcome { - VendorOutcome::Done { result, entry, warnings } => (result, entry, warnings), + VendorOutcome::Done { + result, + entry, + warnings, + } => (result, entry, warnings), VendorOutcome::Refused { code, detail } => { panic!("expected Done, got Refused {code}: {detail}") } @@ -931,7 +986,10 @@ mod tests { detail } VendorOutcome::Done { result, .. } => { - panic!("expected Refused {want_code}, got Done (success={})", result.success) + panic!( + "expected Refused {want_code}, got Done (success={})", + result.success + ) } } } @@ -956,19 +1014,32 @@ mod tests { let tgz = tokio::fs::read(fx.root().join(&rel_tgz)).await.unwrap(); assert_eq!(entry.artifact.path, rel_tgz); assert_eq!(entry.artifact.size, Some(tgz.len() as u64)); - assert_eq!(entry.artifact.sha256, hex::encode(sha2::Sha256::digest(&tgz))); + assert_eq!( + entry.artifact.sha256, + hex::encode(sha2::Sha256::digest(&tgz)) + ); let expected_integrity = sri_sha512(&tgz); // BOTH instances (direct + nested) rewritten; everything else intact. let lock = fx.read_lock().await; let expected_resolved = format!("file:{rel_tgz}"); - for key in ["node_modules/left-pad", "node_modules/foo/node_modules/left-pad"] { + for key in [ + "node_modules/left-pad", + "node_modules/foo/node_modules/left-pad", + ] { let e = &lock["packages"][key]; assert_eq!(e["resolved"], json!(expected_resolved), "{key}"); - assert_eq!(e["integrity"], json!(expected_integrity), "{key}: integrity MUST be the recomputed tarball hash"); + assert_eq!( + e["integrity"], + json!(expected_integrity), + "{key}: integrity MUST be the recomputed tarball hash" + ); assert_eq!(e["version"], json!("1.3.0"), "{key}: version untouched"); } - assert_eq!(lock["packages"]["node_modules/left-pad"]["license"], json!("WTFPL")); + assert_eq!( + lock["packages"]["node_modules/left-pad"]["license"], + json!("WTFPL") + ); assert_eq!( lock["packages"]["node_modules/foo"], default_lock()["packages"]["node_modules/foo"], @@ -987,13 +1058,16 @@ mod tests { &default_lock()["packages"][key], "original must be the verbatim pre-vendor entry for {key}" ); - assert_eq!(rec.new.as_ref().unwrap()["resolved"], json!(expected_resolved)); + assert_eq!( + rec.new.as_ref().unwrap()["resolved"], + json!(expected_resolved) + ); } // Marker sits next to the artifact. - let marker = tokio::fs::read_to_string( - fx.root().join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")), - ) + let marker = tokio::fs::read_to_string(fx.root().join(format!( + ".socket/vendor/npm/{UUID}/socket-patch.vendor.json" + ))) .await .unwrap(); assert!(marker.contains(UUID)); @@ -1025,7 +1099,10 @@ mod tests { let (result, entry, _) = expect_done(fx.vendor(false).await); assert!(result.success); - assert!(entry.is_none(), "in-sync re-run must not produce a new ledger entry"); + assert!( + entry.is_none(), + "in-sync re-run must not produce a new ledger entry" + ); assert!( result .files_verified @@ -1091,12 +1168,23 @@ mod tests { let (result, entry, _) = expect_done(fx.vendor(false).await); assert!(result.success); let entry = entry.unwrap(); - assert_eq!(entry.wiring.len(), 3, "direct + nested + alias all rewritten"); + assert_eq!( + entry.wiring.len(), + 3, + "direct + nested + alias all rewritten" + ); let lock = fx.read_lock().await; let alias = &lock["packages"]["node_modules/aliased"]; - assert_eq!(alias["resolved"], json!(format!("file:{}", fx.expected_rel_tgz()))); - assert_eq!(alias["name"], json!("left-pad"), "alias name field preserved"); + assert_eq!( + alias["resolved"], + json!(format!("file:{}", fx.expected_rel_tgz())) + ); + assert_eq!( + alias["name"], + json!("left-pad"), + "alias name field preserved" + ); } #[tokio::test] @@ -1117,13 +1205,27 @@ mod tests { let fx = fixture_with("left-pad", "1.3.0", lock.clone()).await; let (result, entry, warnings) = expect_done(fx.vendor(false).await); assert!(result.success); - assert_eq!(entry.unwrap().wiring.len(), 2, "only the rewritable instances"); + assert_eq!( + entry.unwrap().wiring.len(), + 2, + "only the rewritable instances" + ); let codes: Vec<&str> = warnings.iter().map(|w| w.code).collect(); assert!(codes.contains(&"vendor_link_entry_skipped"), "{codes:?}"); - assert!(codes.contains(&"vendor_bundled_instance_skipped"), "{codes:?}"); - let bundled = warnings.iter().find(|w| w.code == "vendor_bundled_instance_skipped").unwrap(); - assert!(bundled.detail.contains("UNPATCHED"), "loud warning: {}", bundled.detail); + assert!( + codes.contains(&"vendor_bundled_instance_skipped"), + "{codes:?}" + ); + let bundled = warnings + .iter() + .find(|w| w.code == "vendor_bundled_instance_skipped") + .unwrap(); + assert!( + bundled.detail.contains("UNPATCHED"), + "loud warning: {}", + bundled.detail + ); // Skipped entries are byte-untouched. let live = fx.read_lock().await; @@ -1151,7 +1253,10 @@ mod tests { let fx = fixture_with("left-pad", "1.3.0", lock).await; let detail = expect_refused(fx.vendor(false).await, "vendor_workspace_member"); assert!(detail.contains("packages/left-pad")); - assert!(!fx.root().join(".socket/vendor").exists(), "refusal writes nothing"); + assert!( + !fx.root().join(".socket/vendor").exists(), + "refusal writes nothing" + ); } #[tokio::test] @@ -1183,7 +1288,10 @@ mod tests { } }); let fx = fixture_with("left-pad", "1.3.0", lock).await; - expect_refused(fx.vendor(false).await, "vendor_lockfile_version_unsupported"); + expect_refused( + fx.vendor(false).await, + "vendor_lockfile_version_unsupported", + ); } #[tokio::test] @@ -1191,7 +1299,10 @@ mod tests { let fx = fixture().await; tokio::fs::remove_file(fx.lock_path()).await.unwrap(); let detail = expect_refused(fx.vendor(false).await, "vendor_lockfile_missing"); - assert!(detail.contains("npm install"), "actionable detail: {detail}"); + assert!( + detail.contains("npm install"), + "actionable detail: {detail}" + ); } #[tokio::test] @@ -1202,14 +1313,19 @@ mod tests { lock["packages"]["node_modules/foo/node_modules/left-pad"]["version"] = json!("1.2.0"); let fx = fixture_with("left-pad", "1.3.0", lock).await; let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); - assert!(detail.contains("npm install"), "actionable detail: {detail}"); + assert!( + detail.contains("npm install"), + "actionable detail: {detail}" + ); } #[tokio::test] async fn shrinkwrap_wins_over_package_lock() { let fx = fixture().await; // Same content as the package-lock, but under the shrinkwrap name. - tokio::fs::write(fx.root().join(SHRINKWRAP), &fx.lock_bytes).await.unwrap(); + tokio::fs::write(fx.root().join(SHRINKWRAP), &fx.lock_bytes) + .await + .unwrap(); let (result, entry, _) = expect_done(fx.vendor(false).await); assert!(result.success); @@ -1217,7 +1333,10 @@ mod tests { assert!(entry.wiring.iter().all(|r| r.file == SHRINKWRAP)); // package-lock.json byte-untouched; shrinkwrap rewritten. - assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes + ); let shrink: Value = serde_json::from_slice(&tokio::fs::read(fx.root().join(SHRINKWRAP)).await.unwrap()) .unwrap(); @@ -1278,12 +1397,23 @@ mod tests { assert!(result.success); let entry = entry.unwrap(); - let legacy: Vec<&WiringRecord> = - entry.wiring.iter().filter(|r| r.kind == KIND_LOCK_LEGACY_ENTRY).collect(); - assert_eq!(legacy.len(), 2, "top-level + nested legacy nodes: {:?}", entry.wiring); + let legacy: Vec<&WiringRecord> = entry + .wiring + .iter() + .filter(|r| r.kind == KIND_LOCK_LEGACY_ENTRY) + .collect(); + assert_eq!( + legacy.len(), + 2, + "top-level + nested legacy nodes: {:?}", + entry.wiring + ); let keys: Vec<&str> = legacy.iter().map(|r| r.key.as_deref().unwrap()).collect(); assert!(keys.contains(&"/dependencies/left-pad"), "{keys:?}"); - assert!(keys.contains(&"/dependencies/foo/dependencies/left-pad"), "{keys:?}"); + assert!( + keys.contains(&"/dependencies/foo/dependencies/left-pad"), + "{keys:?}" + ); let resolved = json!(format!("file:{}", fx.expected_rel_tgz())); let live = fx.read_lock().await; @@ -1301,7 +1431,10 @@ mod tests { // Pointer-addressed revert restores the v2 lock byte-for-byte. let outcome = revert_npm(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes + ); } #[tokio::test] @@ -1317,10 +1450,15 @@ mod tests { fx.lock_bytes, "lock byte-untouched" ); - assert!(!fx.root().join(".socket/vendor").exists(), ".socket/vendor absent"); + assert!( + !fx.root().join(".socket/vendor").exists(), + ".socket/vendor absent" + ); // The installed package is never patched in place by vendor. assert_eq!( - tokio::fs::read(fx.installed().join("index.js")).await.unwrap(), + tokio::fs::read(fx.installed().join("index.js")) + .await + .unwrap(), ORIG_INDEX ); } @@ -1355,7 +1493,9 @@ mod tests { assert!(result.success, "{:?}", result.error); assert!(entry.is_some()); assert!( - warnings.iter().any(|w| w.code == "vendor_dep_manifest_rewritten"), + warnings + .iter() + .any(|w| w.code == "vendor_dep_manifest_rewritten"), "{warnings:?}" ); @@ -1381,7 +1521,10 @@ mod tests { // Dry-run revert: success, nothing removed/restored. let outcome = revert_npm(&entry, fx.root(), true).await; assert!(outcome.success); - assert!(tgz_path.exists(), "dry-run revert must not delete the artifact"); + assert!( + tgz_path.exists(), + "dry-run revert must not delete the artifact" + ); assert_ne!( tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes, @@ -1398,7 +1541,9 @@ mod tests { ); assert!(!tgz_path.exists(), "tarball removed"); assert!( - !fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists(), + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), "uuid dir pruned" ); } @@ -1420,7 +1565,10 @@ mod tests { let outcome = revert_npm(&entry, fx.root(), false).await; assert!(outcome.success); assert!( - outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), "{:?}", outcome.warnings ); @@ -1436,7 +1584,10 @@ mod tests { default_lock()["packages"]["node_modules/foo/node_modules/left-pad"], "non-drifted instance restored" ); - assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists()); } #[tokio::test] @@ -1445,7 +1596,10 @@ mod tests { fx.record.uuid = "../../x".to_string(); expect_refused(fx.vendor(false).await, "unsafe_coordinates"); assert!(!fx.root().join(".socket/vendor").exists()); - assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes + ); // And revert refuses to delete through a tampered uuid too. let entry = VendorEntry { ecosystem: "npm".into(), @@ -1473,13 +1627,20 @@ mod tests { #[test] fn purl_and_name_helpers() { - assert_eq!(parse_npm_purl("pkg:npm/left-pad@1.3.0"), Some(("left-pad", "1.3.0"))); + assert_eq!( + parse_npm_purl("pkg:npm/left-pad@1.3.0"), + Some(("left-pad", "1.3.0")) + ); assert_eq!( parse_npm_purl("pkg:npm/@scope/pkg@1.0.0?foo=bar"), Some(("@scope/pkg", "1.0.0")) ); assert_eq!(parse_npm_purl("pkg:npm/@scope/pkg"), None, "no version"); - assert_eq!(parse_npm_purl("pkg:pypi/six@1.16.0"), None, "wrong ecosystem"); + assert_eq!( + parse_npm_purl("pkg:pypi/six@1.16.0"), + None, + "wrong ecosystem" + ); assert!(is_safe_npm_name("left-pad")); assert!(is_safe_npm_name("@scope/pkg")); diff --git a/crates/socket-patch-core/src/patch/vendor/npm_pack.rs b/crates/socket-patch-core/src/patch/vendor/npm_pack.rs index 5a3c322..e8cfe89 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_pack.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_pack.rs @@ -144,10 +144,16 @@ fn collect_regular_files(staged_dir: &Path) -> std::io::Result>() ); @@ -313,7 +341,9 @@ mod tests { build_stage(&stage).await; let dest_dir = tmp.path().join("out"); tokio::fs::create_dir_all(&dest_dir).await.unwrap(); - pack_deterministic(&stage, &dest_dir.join("pkg.tgz")).await.unwrap(); + pack_deterministic(&stage, &dest_dir.join("pkg.tgz")) + .await + .unwrap(); for entry in std::fs::read_dir(&dest_dir).unwrap() { let name = entry.unwrap().file_name().to_string_lossy().into_owned(); diff --git a/crates/socket-patch-core/src/patch/vendor/path.rs b/crates/socket-patch-core/src/patch/vendor/path.rs index 7bcecc1..0e84f9e 100644 --- a/crates/socket-patch-core/src/patch/vendor/path.rs +++ b/crates/socket-patch-core/src/patch/vendor/path.rs @@ -29,9 +29,7 @@ use std::path::{Path, PathBuf}; -use crate::patch::path_safety::{ - is_canonical_uuid, is_safe_multi_segment, is_safe_single_segment, -}; +use crate::patch::path_safety::{is_canonical_uuid, is_safe_multi_segment, is_safe_single_segment}; use crate::utils::fs::list_dir_entries; /// Project-relative root of all vendored artifacts. @@ -276,9 +274,7 @@ async fn collect_leaf_purls(eco: &str, uuid_dir: &Path) -> Vec { // Keep descending through structural levels (go module path // segments, composer vendor dirs, npm @scope dirs) up to a sane // depth bound. - if crate::utils::fs::entry_is_dir(&entry).await - && leaf.matches('/').count() < 8 - { + if crate::utils::fs::entry_is_dir(&entry).await && leaf.matches('/').count() < 8 { stack.push((entry.path(), leaf)); } } @@ -302,7 +298,10 @@ mod tests { ); assert!(vendor_uuid_dir_rel("npm", "../../escape").is_none()); assert!(vendor_uuid_dir_rel("npm", "9F6B2C4E-1D3A-4F6B-8C2D-7E5A9B1C3D5F").is_none()); - assert!(vendor_uuid_dir_rel("maven", UUID).is_none(), "unknown eco dir"); + assert!( + vendor_uuid_dir_rel("maven", UUID).is_none(), + "unknown eco dir" + ); } #[test] @@ -331,11 +330,12 @@ mod tests { assert_eq!(p.leaf, "monolog/monolog@2.9.1"); // cargo config path, backslashes (Windows spelling) - let p = parse_vendor_path(&format!( - ".socket\\vendor\\cargo\\{UUID}\\serde-1.0.190" - )) - .unwrap(); - assert_eq!((p.eco.as_str(), p.leaf.as_str()), ("cargo", "serde-1.0.190")); + let p = + parse_vendor_path(&format!(".socket\\vendor\\cargo\\{UUID}\\serde-1.0.190")).unwrap(); + assert_eq!( + (p.eco.as_str(), p.leaf.as_str()), + ("cargo", "serde-1.0.190") + ); // anchored mid-string (workspace-relative) assert!(parse_vendor_path(&format!( @@ -395,7 +395,10 @@ mod tests { // Unparseable leaves are None, not garbage. assert!(leaf_to_purl("npm", "noversion.tgz").is_none()); assert!(leaf_to_purl("golang", "no-version-here").is_none()); - assert!(leaf_to_purl("pypi", "six-1.16.0.whl").is_none(), "tags required"); + assert!( + leaf_to_purl("pypi", "six-1.16.0.whl").is_none(), + "tags required" + ); } #[tokio::test] @@ -417,14 +420,19 @@ mod tests { ))) .await .unwrap(); - tokio::fs::create_dir_all(root.join(".socket/vendor/npm/not-a-uuid")).await.unwrap(); + tokio::fs::create_dir_all(root.join(".socket/vendor/npm/not-a-uuid")) + .await + .unwrap(); let swept = sweep_vendor_dirs(root).await; assert_eq!(swept.len(), 2, "junk dir skipped: {swept:?}"); let npm = swept.iter().find(|s| s.eco == "npm").unwrap(); assert_eq!(npm.purls, vec!["pkg:npm/lodash@4.17.21".to_string()]); let go = swept.iter().find(|s| s.eco == "golang").unwrap(); - assert_eq!(go.purls, vec!["pkg:golang/github.com/foo/bar@v1.4.2".to_string()]); + assert_eq!( + go.purls, + vec!["pkg:golang/github.com/foo/bar@v1.4.2".to_string()] + ); assert_eq!(go.uuid, UUID); } } diff --git a/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs index 1164209..cb298ee 100644 --- a/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs @@ -171,7 +171,11 @@ pub async fn vendor_pnpm( }; let Some(staged) = staged else { // Failed patch or dry run: wiring never ran, project byte-untouched. - return VendorOutcome::Done { result, entry: None, warnings }; + return VendorOutcome::Done { + result, + entry: None, + warnings, + }; }; debug_assert_eq!(staged.rel_tgz, rel_tgz); let packed = staged.packed; @@ -223,7 +227,11 @@ pub async fn vendor_pnpm( // project is in sync. The tarball re-pack above was byte-identical // by determinism; synthesize AlreadyPatched and record nothing (the // existing ledger entry stays authoritative). - let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + let verified = record + .files + .keys() + .map(|f| already_patched_verify(f)) + .collect(); return VendorOutcome::Done { result: synthesized_result(purl, &project_root.join(&rel_tgz), verified, true, None), entry: None, @@ -282,12 +290,19 @@ pub async fn vendor_pnpm( took_over_go_patches: false, flavor: Some("pnpm".to_string()), uv: None, - pnpm: Some(PnpmMeta { created_overrides_table, created_pnpm_table }), + pnpm: Some(PnpmMeta { + created_overrides_table, + created_pnpm_table, + }), poetry: None, pdm: None, pipenv: None, }; - VendorOutcome::Done { result, entry: Some(entry), warnings } + VendorOutcome::Done { + result, + entry: Some(entry), + warnings, + } } /// Undo one pnpm-vendored package: restore the recorded pair fragments and @@ -316,7 +331,10 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool if !REVERT_ALLOWLIST.contains(&rec.file.as_str()) { outcome.warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", - format!("ignoring wiring record for non-allowlisted file `{}`", rec.file), + format!( + "ignoring wiring record for non-allowlisted file `{}`", + rec.file + ), )); continue; } @@ -374,7 +392,13 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool match rec.file.as_str() { PNPM_LOCK => { if let Some(lines) = lock_lines.as_mut() { - revert_lock_record(lines, rec, &entry.uuid, &mut lock_dirty, &mut outcome.warnings); + revert_lock_record( + lines, + rec, + &entry.uuid, + &mut lock_dirty, + &mut outcome.warnings, + ); } } PACKAGE_JSON => { @@ -396,16 +420,20 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool if let Some(obj) = doc.as_object_mut() { if let Some(pnpm_tbl) = obj.get_mut("pnpm").and_then(Value::as_object_mut) { if created_overrides - && pnpm_tbl.get("overrides").and_then(Value::as_object).is_some_and( - serde_json::Map::is_empty, - ) + && pnpm_tbl + .get("overrides") + .and_then(Value::as_object) + .is_some_and(serde_json::Map::is_empty) { pnpm_tbl.shift_remove("overrides"); pkg_dirty = true; } } if created_pnpm - && obj.get("pnpm").and_then(Value::as_object).is_some_and(serde_json::Map::is_empty) + && obj + .get("pnpm") + .and_then(Value::as_object) + .is_some_and(serde_json::Map::is_empty) { obj.shift_remove("pnpm"); pkg_dirty = true; @@ -480,7 +508,11 @@ impl EditCtx<'_> { if importer == "." { self.spec.to_string() } else { - format!("file:{}{}", "../".repeat(importer.split('/').count()), self.rel_tgz) + format!( + "file:{}{}", + "../".repeat(importer.split('/').count()), + self.rel_tgz + ) } } } @@ -559,11 +591,7 @@ fn check_pkg_override_conflict(pkg: &Value, name: &str, our_key: &str) -> Result /// Same conflict check against the lock's own `overrides:` section (a /// desynced lock-side override would be silently clobbered otherwise). -fn check_lock_override_conflict( - lines: &[String], - name: &str, - our_key: &str, -) -> Result<(), String> { +fn check_lock_override_conflict(lines: &[String], name: &str, our_key: &str) -> Result<(), String> { let Some((start, end)) = section_bounds(lines, "overrides") else { return Ok(()); }; @@ -584,7 +612,9 @@ fn check_lock_override_conflict( /// the registry `name@version` key, or our own rekeyed `name@file:` key /// (the in-sync / stale-uuid re-run)? fn lock_has_target_package(lines: &[String], name: &str, version: &str) -> bool { - let Some((start, end)) = section_bounds(lines, "packages") else { return false }; + let Some((start, end)) = section_bounds(lines, "packages") else { + return false; + }; let reg_key = format!("{name}@{version}"); let ours_prefix = format!("{name}@file:"); let mut i = start + 1; @@ -613,7 +643,9 @@ fn apply_pkg_override( spec: &str, wiring: &mut Vec, ) -> Result<(bool, bool, bool), String> { - let obj = pkg.as_object_mut().ok_or("package.json root is not an object")?; + let obj = pkg + .as_object_mut() + .ok_or("package.json root is not an object")?; let created_pnpm_table = !obj.contains_key("pnpm"); let pnpm_tbl = obj .entry("pnpm") @@ -638,7 +670,11 @@ fn apply_pkg_override( wiring.push(WiringRecord { file: PACKAGE_JSON.to_string(), kind: KIND_PKG_OVERRIDE.to_string(), - action: if was_ours { WiringAction::Rewritten } else { WiringAction::Added }, + action: if was_ours { + WiringAction::Rewritten + } else { + WiringAction::Added + }, key: Some(our_key.to_string()), original: None, // Added has none; Rewritten-over-ours records none by design new: Some(Value::String(spec.to_string())), @@ -678,7 +714,11 @@ fn edit_overrides( } // Ours with a stale uuid (conflict pre-flight proved it). lines[i] = entry_line; - wiring.push(overrides_record(&our_key, ctx.spec, WiringAction::Rewritten)); + wiring.push(overrides_record( + &our_key, + ctx.spec, + WiringAction::Rewritten, + )); return Ok(true); } lines.insert(last_entry + 1, entry_line); @@ -743,7 +783,9 @@ fn edit_importers( let mut ver_idx = None; let mut f = k + 1; while f < importer.end { - let Some((field, _frepr, fval)) = parse_key_line(&lines[f], 8) else { break }; + let Some((field, _frepr, fval)) = parse_key_line(&lines[f], 8) else { + break; + }; match field.as_str() { "specifier" => spec_idx = Some((f, fval)), "version" => ver_idx = Some((f, fval)), @@ -752,8 +794,8 @@ fn edit_importers( f += 1; } if let (Some((si, old_spec)), Some((vi, old_ver))) = (spec_idx, ver_idx) { - let target = old_ver == ctx.version - || (old_ver != ctx.spec && ctx.is_ours(&old_ver)); + let target = + old_ver == ctx.version || (old_ver != ctx.spec && ctx.is_ours(&old_ver)); if target { let was_ours = ctx.is_ours(&old_ver); let importer_spec = ctx.spec_for_importer(&importer_key); @@ -812,16 +854,20 @@ fn edit_packages( continue; } let original_lines: Vec = lines[block.header..block.end].to_vec(); - let expected_resolution = - format!(" resolution: {{integrity: {}, tarball: {}}}", ctx.integrity, ctx.spec); - if block.key == new_key - && original_lines.iter().any(|l| l == &expected_resolution) - { + let expected_resolution = format!( + " resolution: {{integrity: {}, tarball: {}}}", + ctx.integrity, ctx.spec + ); + if block.key == new_key && original_lines.iter().any(|l| l == &expected_resolution) { return Ok(false); // in sync (only the exact version moves: done) } // Rebuild the block (registry → ours, or stale-ours → current). let mut new_lines = Vec::with_capacity(original_lines.len() + 1); - new_lines.push(format!(" {}:{}", yaml_key_like(&new_key, &block.repr), block.rest_suffix())); + new_lines.push(format!( + " {}:{}", + yaml_key_like(&new_key, &block.repr), + block.rest_suffix() + )); let mut replaced_resolution = false; for line in &original_lines[1..] { if line.trim_start().starts_with("resolution:") { @@ -838,7 +884,10 @@ fn edit_packages( } } if !replaced_resolution { - return Err(format!("packages entry `{}` has no resolution line", block.key)); + return Err(format!( + "packages entry `{}` has no resolution line", + block.key + )); } let header = block.header; let block_end = block.end; @@ -848,7 +897,11 @@ fn edit_packages( kind: KIND_LOCK_PACKAGE.to_string(), action: WiringAction::Rewritten, key: Some(block.key.clone()), - original: if is_ours_key { None } else { Some(lines_value(&original_lines)) }, + original: if is_ours_key { + None + } else { + Some(lines_value(&original_lines)) + }, new: Some(lines_value(&new_lines)), }); return Ok(true); @@ -887,7 +940,11 @@ fn edit_snapshot_rekey( } let original_lines: Vec = lines[block.header..block.end].to_vec(); let mut new_lines = original_lines.clone(); - new_lines[0] = format!(" {}:{}", yaml_key_like(&new_key, &block.repr), block.rest_suffix()); + new_lines[0] = format!( + " {}:{}", + yaml_key_like(&new_key, &block.repr), + block.rest_suffix() + ); let header = block.header; let block_end = block.end; lines.splice(header..block_end, new_lines.clone()); @@ -896,7 +953,11 @@ fn edit_snapshot_rekey( kind: KIND_LOCK_SNAPSHOT.to_string(), action: WiringAction::Rewritten, key: Some(block.key.clone()), - original: if is_ours_key { None } else { Some(lines_value(&original_lines)) }, + original: if is_ours_key { + None + } else { + Some(lines_value(&original_lines)) + }, new: Some(lines_value(&new_lines)), }); return Ok(true); @@ -922,12 +983,13 @@ fn edit_snapshot_refs( let mut i = start + 1; while let Some(block) = next_block(lines, i, end) { for line in lines[block.header + 1..block.end].iter_mut() { - let Some((dep, _repr, rest)) = parse_key_line(line, 6) else { continue }; + let Some((dep, _repr, rest)) = parse_key_line(line, 6) else { + continue; + }; if dep != ctx.name { continue; } - let target = - rest == ctx.version || (rest != ctx.spec && ctx.is_ours(&rest)); + let target = rest == ctx.version || (rest != ctx.spec && ctx.is_ours(&rest)); if !target { continue; } @@ -938,7 +1000,11 @@ fn edit_snapshot_refs( kind: KIND_LOCK_SNAPSHOT_REF.to_string(), action: WiringAction::Rewritten, key: Some(format!("{}|{dep}", block.key)), - original: if was_ours { None } else { Some(Value::String(rest.clone())) }, + original: if was_ours { + None + } else { + Some(Value::String(rest.clone())) + }, new: Some(Value::String(ctx.spec.to_string())), }); changed = true; @@ -960,12 +1026,17 @@ fn revert_pkg_record( if rec.kind != KIND_PKG_OVERRIDE { warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", - format!("unknown wiring kind `{}` for {PACKAGE_JSON}; left alone", rec.kind), + format!( + "unknown wiring kind `{}` for {PACKAGE_JSON}; left alone", + rec.kind + ), )); return; } let Some(key) = rec.key.as_deref() else { - warnings.push(drifted("package.json override record has no key; left alone")); + warnings.push(drifted( + "package.json override record has no key; left alone", + )); return; }; let overrides = doc @@ -973,7 +1044,9 @@ fn revert_pkg_record( .and_then(|p| p.get_mut("overrides")) .and_then(Value::as_object_mut); let Some(overrides) = overrides else { - warnings.push(drifted(format!("pnpm.overrides is gone; `{key}` not removed"))); + warnings.push(drifted(format!( + "pnpm.overrides is gone; `{key}` not removed" + ))); return; }; let live = overrides.get(key).and_then(Value::as_str); @@ -999,23 +1072,19 @@ fn revert_lock_record( warnings: &mut Vec, ) { let Some(key) = rec.key.as_deref() else { - warnings.push(drifted(format!("wiring record in {PNPM_LOCK} has no key; left alone"))); + warnings.push(drifted(format!( + "wiring record in {PNPM_LOCK} has no key; left alone" + ))); return; }; match rec.kind.as_str() { KIND_LOCK_OVERRIDES => revert_overrides_line(lines, rec, key, entry_uuid, dirty, warnings), - KIND_LOCK_IMPORTER_DEP => { - revert_importer_dep(lines, rec, key, entry_uuid, dirty, warnings) - } - KIND_LOCK_PACKAGE => { - revert_block(lines, rec, key, "packages", entry_uuid, dirty, warnings) - } + KIND_LOCK_IMPORTER_DEP => revert_importer_dep(lines, rec, key, entry_uuid, dirty, warnings), + KIND_LOCK_PACKAGE => revert_block(lines, rec, key, "packages", entry_uuid, dirty, warnings), KIND_LOCK_SNAPSHOT => { revert_block(lines, rec, key, "snapshots", entry_uuid, dirty, warnings) } - KIND_LOCK_SNAPSHOT_REF => { - revert_snapshot_ref(lines, rec, key, entry_uuid, dirty, warnings) - } + KIND_LOCK_SNAPSHOT_REF => revert_snapshot_ref(lines, rec, key, entry_uuid, dirty, warnings), other => warnings.push(drifted(format!( "unknown wiring kind `{other}` for `{key}`; left alone" ))), @@ -1031,7 +1100,9 @@ fn revert_overrides_line( warnings: &mut Vec, ) { let Some((start, end)) = section_bounds(lines, "overrides") else { - warnings.push(drifted(format!("overrides section is gone; `{key}` not removed"))); + warnings.push(drifted(format!( + "overrides section is gone; `{key}` not removed" + ))); return; }; // First pass: locate our line + count the other entries (the section is @@ -1080,11 +1151,15 @@ fn revert_importer_dep( warnings: &mut Vec, ) { let Some((importer_key, dep)) = key.rsplit_once('|') else { - warnings.push(drifted(format!("malformed importer-dep key `{key}`; left alone"))); + warnings.push(drifted(format!( + "malformed importer-dep key `{key}`; left alone" + ))); return; }; let Some((start, end)) = section_bounds(lines, "importers") else { - warnings.push(drifted("importers section is gone; nothing to restore".to_string())); + warnings.push(drifted( + "importers section is gone; nothing to restore".to_string(), + )); return; }; let mut i = start + 1; @@ -1107,7 +1182,9 @@ fn revert_importer_dep( let mut ver_idx = None; let mut f = k + 1; while f < importer.end { - let Some((field, _fr, fval)) = parse_key_line(&lines[f], 8) else { break }; + let Some((field, _fr, fval)) = parse_key_line(&lines[f], 8) else { + break; + }; match field.as_str() { "specifier" => spec_idx = Some(f), "version" => ver_idx = Some((f, fval)), @@ -1115,7 +1192,9 @@ fn revert_importer_dep( } f += 1; } - let (Some(si), Some((vi, live_ver))) = (spec_idx, ver_idx) else { break }; + let (Some(si), Some((vi, live_ver))) = (spec_idx, ver_idx) else { + break; + }; let new_ver = rec .new .as_ref() @@ -1141,7 +1220,9 @@ fn revert_importer_dep( original.get("specifier").and_then(Value::as_str), original.get("version").and_then(Value::as_str), ) else { - warnings.push(drifted(format!("importer dep `{key}` original is malformed"))); + warnings.push(drifted(format!( + "importer dep `{key}` original is malformed" + ))); return; }; lines[si] = format!(" specifier: {orig_spec}"); @@ -1151,7 +1232,9 @@ fn revert_importer_dep( } break; } - warnings.push(drifted(format!("importer dep `{key}` no longer exists; nothing to restore"))); + warnings.push(drifted(format!( + "importer dep `{key}` no longer exists; nothing to restore" + ))); } /// Restore a rekeyed packages/snapshots block: locate the block by the NEW @@ -1168,17 +1251,21 @@ fn revert_block( ) { let new_lines = rec.new.as_ref().and_then(value_lines); let Some(new_lines) = new_lines else { - warnings.push(drifted(format!("record for `{key}` has no `new` fragment; left alone"))); + warnings.push(drifted(format!( + "record for `{key}` has no `new` fragment; left alone" + ))); return; }; - let Some((new_key, _repr, _rest)) = - new_lines.first().and_then(|l| parse_key_line(l, 2)) - else { - warnings.push(drifted(format!("record for `{key}` has a malformed fragment"))); + let Some((new_key, _repr, _rest)) = new_lines.first().and_then(|l| parse_key_line(l, 2)) else { + warnings.push(drifted(format!( + "record for `{key}` has a malformed fragment" + ))); return; }; let Some((start, end)) = section_bounds(lines, section) else { - warnings.push(drifted(format!("{section} section is gone; `{key}` not restored"))); + warnings.push(drifted(format!( + "{section} section is gone; `{key}` not restored" + ))); return; }; let mut i = start + 1; @@ -1190,11 +1277,9 @@ fn revert_block( // Ours iff the live block is exactly what we wrote, or its key still // points into OUR uuid dir (a re-serialized but unmoved entry). let live: Vec = lines[block.header..block.end].to_vec(); - let key_is_ours = new_key - .rsplit_once("@file:") - .is_some_and(|(_, p)| { - parse_vendor_path(p).is_some_and(|v| v.eco == "npm" && v.uuid == entry_uuid) - }); + let key_is_ours = new_key.rsplit_once("@file:").is_some_and(|(_, p)| { + parse_vendor_path(p).is_some_and(|v| v.eco == "npm" && v.uuid == entry_uuid) + }); if live != new_lines && !key_is_ours { warnings.push(drifted(format!( "{section} entry `{new_key}` was changed since vendoring; left alone" @@ -1228,11 +1313,15 @@ fn revert_snapshot_ref( warnings: &mut Vec, ) { let Some((snapshot_key, dep)) = key.rsplit_once('|') else { - warnings.push(drifted(format!("malformed snapshot-ref key `{key}`; left alone"))); + warnings.push(drifted(format!( + "malformed snapshot-ref key `{key}`; left alone" + ))); return; }; let Some((start, end)) = section_bounds(lines, "snapshots") else { - warnings.push(drifted("snapshots section is gone; nothing to restore".to_string())); + warnings.push(drifted( + "snapshots section is gone; nothing to restore".to_string(), + )); return; }; let mut i = start + 1; @@ -1242,13 +1331,14 @@ fn revert_snapshot_ref( continue; } for line in lines[block.header + 1..block.end].iter_mut() { - let Some((d, _repr, rest)) = parse_key_line(line, 6) else { continue }; + let Some((d, _repr, rest)) = parse_key_line(line, 6) else { + continue; + }; if d != dep { continue; } let ours = Some(rest.as_str()) == rec.new.as_ref().and_then(Value::as_str) - || parse_vendor_path(&rest) - .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + || parse_vendor_path(&rest).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if !ours { warnings.push(drifted(format!( "snapshot ref `{key}` was re-resolved since vendoring ({rest}); left alone" @@ -1267,7 +1357,9 @@ fn revert_snapshot_ref( } break; } - warnings.push(drifted(format!("snapshot ref `{key}` no longer exists; nothing to restore"))); + warnings.push(drifted(format!( + "snapshot ref `{key}` no longer exists; nothing to restore" + ))); } fn drifted(detail: impl Into) -> VendorWarning { @@ -1296,8 +1388,7 @@ async fn commit_pair( // Unwind (best effort): a failure here leaves the desync pair // anyway, but the lock write failing usually means the // restore fails identically loudly. - let _ = - atomic_write_bytes(&project_root.join(PACKAGE_JSON), original_pkg).await; + let _ = atomic_write_bytes(&project_root.join(PACKAGE_JSON), original_pkg).await; } return Err(format!( "cannot write {PNPM_LOCK}: {e} ({PACKAGE_JSON} restored to its original bytes)" @@ -1352,7 +1443,11 @@ struct YamlBlock { impl YamlBlock { /// The inline-rest suffix to re-emit after the (re)written key. fn rest_suffix(&self) -> String { - if self.rest.is_empty() { String::new() } else { format!(" {}", self.rest) } + if self.rest.is_empty() { + String::new() + } else { + format!(" {}", self.rest) + } } } @@ -1364,7 +1459,13 @@ fn next_block(lines: &[String], mut i: usize, end: usize) -> Option { while j < end && !lines[j].is_empty() && indent_of(&lines[j]) >= 4 { j += 1; } - return Some(YamlBlock { header: i, end: j, key, repr, rest }); + return Some(YamlBlock { + header: i, + end: j, + key, + repr, + rest, + }); } i += 1; } @@ -1417,7 +1518,11 @@ fn parse_key_line(line: &str, indent: usize) -> Option<(String, String, String)> /// pnpm quotes `@`-leading keys with single quotes; everything we write is /// otherwise bare. fn yaml_key(key: &str) -> String { - if key.starts_with('@') { format!("'{key}'") } else { key.to_string() } + if key.starts_with('@') { + format!("'{key}'") + } else { + key.to_string() + } } /// Re-spell `key` in the same quoting style as the original `repr`. @@ -1434,8 +1539,12 @@ fn lines_value(lines: &[String]) -> Value { } fn value_lines(v: &Value) -> Option> { - v.as_array() - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + v.as_array().map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) } // ───────────────────────── small shared helpers ─────────────────────────── @@ -1729,12 +1838,16 @@ snapshots: } async fn read(&self, name: &str) -> String { - tokio::fs::read_to_string(self.root().join(name)).await.unwrap() + tokio::fs::read_to_string(self.root().join(name)) + .await + .unwrap() } /// The actual SRI of the tarball our pack produced. async fn actual_integrity(&self) -> String { - let tgz = tokio::fs::read(self.root().join(self.rel_tgz())).await.unwrap(); + let tgz = tokio::fs::read(self.root().join(self.rel_tgz())) + .await + .unwrap(); format!( "sha512-{}", base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&tgz)) @@ -1770,14 +1883,20 @@ snapshots: ) .await .unwrap(); - tokio::fs::write(installed.join("index.js"), ORIG_INDEX).await.unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX) + .await + .unwrap(); let blobs = root.join(".socket/blobs"); tokio::fs::create_dir_all(&blobs).await.unwrap(); let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); - tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX).await.unwrap(); + tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX) + .await + .unwrap(); - tokio::fs::write(root.join(PACKAGE_JSON), pkg_json).await.unwrap(); + tokio::fs::write(root.join(PACKAGE_JSON), pkg_json) + .await + .unwrap(); tokio::fs::write(root.join(PNPM_LOCK), lock).await.unwrap(); let mut files = HashMap::new(); @@ -1804,7 +1923,11 @@ snapshots: outcome: VendorOutcome, ) -> (ApplyResult, Option, Vec) { match outcome { - VendorOutcome::Done { result, entry, warnings } => (result, entry, warnings), + VendorOutcome::Done { + result, + entry, + warnings, + } => (result, entry, warnings), VendorOutcome::Refused { code, detail } => { panic!("expected Done, got Refused {code}: {detail}") } @@ -1818,7 +1941,10 @@ snapshots: detail } VendorOutcome::Done { result, .. } => { - panic!("expected Refused {want_code}, got Done (success={})", result.success) + panic!( + "expected Refused {want_code}, got Done (success={})", + result.success + ) } } } @@ -1836,7 +1962,10 @@ snapshots: // Lock: byte-identical modulo the integrity (ours is recomputed from // the deterministic tarball we packed — never the spike's bytes). let actual = fx.actual_integrity().await; - assert_ne!(actual, SPIKE_INTEGRITY, "different tarballs, different hashes"); + assert_ne!( + actual, SPIKE_INTEGRITY, + "different tarballs, different hashes" + ); let expected_lock = P1_AFTER_LOCK.replace(SPIKE_INTEGRITY, &actual); assert_eq!(fx.read(PNPM_LOCK).await, expected_lock); @@ -1844,7 +1973,10 @@ snapshots: assert_eq!(entry.flavor.as_deref(), Some("pnpm")); assert_eq!( entry.pnpm, - Some(PnpmMeta { created_overrides_table: true, created_pnpm_table: true }) + Some(PnpmMeta { + created_overrides_table: true, + created_pnpm_table: true + }) ); assert_eq!(entry.artifact.path, fx.rel_tgz()); let kinds: Vec<&str> = entry.wiring.iter().map(|r| r.kind.as_str()).collect(); @@ -1862,15 +1994,25 @@ snapshots: entry.wiring ); // The transitive consumer snapshot-ref is keyed snapshot|dep. - let snap_ref = entry.wiring.iter().find(|r| r.kind == KIND_LOCK_SNAPSHOT_REF).unwrap(); - assert_eq!(snap_ref.key.as_deref(), Some("consumer@file:consumer|left-pad")); + let snap_ref = entry + .wiring + .iter() + .find(|r| r.kind == KIND_LOCK_SNAPSHOT_REF) + .unwrap(); + assert_eq!( + snap_ref.key.as_deref(), + Some("consumer@file:consumer|left-pad") + ); assert_eq!(snap_ref.original, Some(Value::String("1.3.0".into()))); // Scoping: the 1.2.0 sibling stayed registry (asserted by the byte // oracle above, re-asserted explicitly here). let lock = fx.read(PNPM_LOCK).await; assert!(lock.contains(" left-pad@1.2.0:\n resolution: {integrity: sha512-OQadpCyF")); - assert!(lock.contains(" version: left-pad@1.2.0\n"), "aliased 1.2.0 importer untouched"); + assert!( + lock.contains(" version: left-pad@1.2.0\n"), + "aliased 1.2.0 importer untouched" + ); } #[tokio::test] @@ -1881,11 +2023,14 @@ snapshots: let entry = entry.unwrap(); assert_eq!(fx.read(PACKAGE_JSON).await, P7_AFTER_PKG); - let expected_lock = - P7_AFTER_LOCK.replace(SPIKE_INTEGRITY, &fx.actual_integrity().await); + let expected_lock = P7_AFTER_LOCK.replace(SPIKE_INTEGRITY, &fx.actual_integrity().await); assert_eq!(fx.read(PNPM_LOCK).await, expected_lock); - let dep = entry.wiring.iter().find(|r| r.kind == KIND_LOCK_IMPORTER_DEP).unwrap(); + let dep = entry + .wiring + .iter() + .find(|r| r.kind == KIND_LOCK_IMPORTER_DEP) + .unwrap(); assert_eq!(dep.key.as_deref(), Some("packages/app|left-pad")); assert_eq!( dep.new.as_ref().unwrap()["specifier"], @@ -1910,15 +2055,16 @@ snapshots: let fx = fixture_with(&pkg, P1_BEFORE_LOCK).await; let detail = expect_refused(fx.vendor(false).await, "vendor_override_conflict"); assert!(detail.contains(key), "{detail}"); - assert!(!fx.root().join(".socket/vendor").exists(), "refusal writes nothing"); + assert!( + !fx.root().join(".socket/vendor").exists(), + "refusal writes nothing" + ); assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK, "lock untouched"); } // Lock-side desynced override conflicts too. - let lock = P1_BEFORE_LOCK.replace( - "importers:", - "overrides:\n left-pad: 1.2.0\n\nimporters:", - ); + let lock = + P1_BEFORE_LOCK.replace("importers:", "overrides:\n left-pad: 1.2.0\n\nimporters:"); let fx = fixture_with(P1_BEFORE_PKG, &lock).await; expect_refused(fx.vendor(false).await, "vendor_override_conflict"); @@ -1933,17 +2079,18 @@ snapshots: } } "#; - let lock = P1_BEFORE_LOCK.replace( - "importers:", - "overrides:\n other-pkg: 2.0.0\n\nimporters:", - ); + let lock = + P1_BEFORE_LOCK.replace("importers:", "overrides:\n other-pkg: 2.0.0\n\nimporters:"); let fx = fixture_with(pkg, &lock).await; let (result, entry, _) = expect_done(fx.vendor(false).await); assert!(result.success, "{:?}", result.error); let entry = entry.unwrap(); assert_eq!( entry.pnpm, - Some(PnpmMeta { created_overrides_table: false, created_pnpm_table: false }) + Some(PnpmMeta { + created_overrides_table: false, + created_pnpm_table: false + }) ); // Our entry extends the existing overrides section, theirs intact. let live = fx.read(PNPM_LOCK).await; @@ -1952,9 +2099,11 @@ snapshots: // Revert removes only ours, keeping the user's table + section. let outcome = revert_pnpm(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - let live: Value = - serde_json::from_str(&fx.read(PACKAGE_JSON).await).unwrap(); - assert_eq!(live["pnpm"]["overrides"]["other-pkg"], Value::String("2.0.0".into())); + let live: Value = serde_json::from_str(&fx.read(PACKAGE_JSON).await).unwrap(); + assert_eq!( + live["pnpm"]["overrides"]["other-pkg"], + Value::String("2.0.0".into()) + ); assert!(live["pnpm"]["overrides"].get("left-pad@1.3.0").is_none()); let live_lock = fx.read(PNPM_LOCK).await; assert!(live_lock.contains("overrides:\n other-pkg: 2.0.0\n\nimporters:")); @@ -1977,13 +2126,19 @@ snapshots: let entry = entry.unwrap(); assert_eq!( entry.pnpm, - Some(PnpmMeta { created_overrides_table: true, created_pnpm_table: false }) + Some(PnpmMeta { + created_overrides_table: true, + created_pnpm_table: false + }) ); let outcome = revert_pnpm(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); let live: Value = serde_json::from_str(&fx.read(PACKAGE_JSON).await).unwrap(); - assert!(live["pnpm"].get("overrides").is_none(), "created overrides table pruned"); + assert!( + live["pnpm"].get("overrides").is_none(), + "created overrides table pruned" + ); assert!( live["pnpm"].get("onlyBuiltDependencies").is_some(), "pre-existing pnpm table kept: {live}" @@ -2001,7 +2156,9 @@ snapshots: async fn commit_pair_unwinds_package_json_on_lock_write_failure() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - tokio::fs::write(root.join(PACKAGE_JSON), P1_BEFORE_PKG).await.unwrap(); + tokio::fs::write(root.join(PACKAGE_JSON), P1_BEFORE_PKG) + .await + .unwrap(); // A directory where the lock should be makes the atomic rename fail // AFTER package.json was already written. tokio::fs::create_dir(root.join(PNPM_LOCK)).await.unwrap(); @@ -2016,7 +2173,9 @@ snapshots: .unwrap_err(); assert!(err.contains(PNPM_LOCK), "{err}"); assert_eq!( - tokio::fs::read_to_string(root.join(PACKAGE_JSON)).await.unwrap(), + tokio::fs::read_to_string(root.join(PACKAGE_JSON)) + .await + .unwrap(), P1_BEFORE_PKG, "package.json restored byte-for-byte after the lock failure" ); @@ -2035,7 +2194,10 @@ snapshots: assert!(result.success); assert!(entry.is_none(), "in-sync re-run records nothing"); assert!( - result.files_verified.iter().all(|v| v.status == VerifyStatus::AlreadyPatched), + result + .files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), "{:?}", result.files_verified ); @@ -2060,7 +2222,9 @@ snapshots: assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK); assert!(!fx.root().join(".socket/vendor").exists()); assert_eq!( - tokio::fs::read(fx.installed().join("index.js")).await.unwrap(), + tokio::fs::read(fx.installed().join("index.js")) + .await + .unwrap(), ORIG_INDEX, "vendor never patches in place" ); @@ -2083,10 +2247,21 @@ snapshots: let outcome = revert_pnpm(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); - assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG, "package.json byte-restored"); - assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK, "lock byte-restored"); + assert_eq!( + fx.read(PACKAGE_JSON).await, + P1_BEFORE_PKG, + "package.json byte-restored" + ); + assert_eq!( + fx.read(PNPM_LOCK).await, + P1_BEFORE_LOCK, + "lock byte-restored" + ); assert!(!tgz_path.exists()); - assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists()); } #[tokio::test] @@ -2095,7 +2270,9 @@ snapshots: let (_, entry, _) = expect_done(fx.vendor(false).await); let mut entry = entry.unwrap(); // A poisoned ledger names a file outside the pair. - tokio::fs::write(fx.root().join("Cargo.toml"), b"[package]\n").await.unwrap(); + tokio::fs::write(fx.root().join("Cargo.toml"), b"[package]\n") + .await + .unwrap(); entry.wiring.push(WiringRecord { file: "Cargo.toml".to_string(), kind: KIND_LOCK_OVERRIDES.to_string(), @@ -2139,14 +2316,21 @@ snapshots: ), " left-pad:\n specifier: 1.3.1\n version: 1.3.1\n", ); - assert_ne!(drifted_lock, live, "test setup must actually drift the entry"); - tokio::fs::write(fx.root().join(PNPM_LOCK), &drifted_lock).await.unwrap(); + assert_ne!( + drifted_lock, live, + "test setup must actually drift the entry" + ); + tokio::fs::write(fx.root().join(PNPM_LOCK), &drifted_lock) + .await + .unwrap(); let outcome = revert_pnpm(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted" - && w.detail.contains(".|left-pad")), + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted" && w.detail.contains(".|left-pad")), "{:?}", outcome.warnings ); @@ -2157,26 +2341,35 @@ snapshots: ); // Non-drifted fragments still restored. assert!(after.contains(" left-pad@1.3.0:\n resolution: {integrity: sha512-XI5MPzVN")); - assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists()); } #[tokio::test] async fn preflight_refusals_fire_before_any_write() { // Missing lock. let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; - tokio::fs::remove_file(fx.root().join(PNPM_LOCK)).await.unwrap(); + tokio::fs::remove_file(fx.root().join(PNPM_LOCK)) + .await + .unwrap(); let detail = expect_refused(fx.vendor(false).await, "vendor_lockfile_missing"); assert!(detail.contains("pnpm install"), "{detail}"); // Unsupported lockfileVersion. let fx = fixture_with(P1_BEFORE_PKG, &P1_BEFORE_LOCK.replace("'9.0'", "'6.0'")).await; - let detail = - expect_refused(fx.vendor(false).await, "vendor_lockfile_version_unsupported"); + let detail = expect_refused( + fx.vendor(false).await, + "vendor_lockfile_version_unsupported", + ); assert!(detail.contains("6.0"), "{detail}"); // Missing package.json (the PAIR requirement). let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; - tokio::fs::remove_file(fx.root().join(PACKAGE_JSON)).await.unwrap(); + tokio::fs::remove_file(fx.root().join(PACKAGE_JSON)) + .await + .unwrap(); expect_refused(fx.vendor(false).await, "vendor_lockfile_missing"); // Lock knows only another version of the package. @@ -2184,7 +2377,10 @@ snapshots: let fx = fixture_with(P1_BEFORE_PKG, &lock).await; let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); assert!(detail.contains("left-pad@1.3.0"), "{detail}"); - assert!(!fx.root().join(".socket/vendor").exists(), "refusals write nothing"); + assert!( + !fx.root().join(".socket/vendor").exists(), + "refusals write nothing" + ); } #[test] @@ -2201,29 +2397,53 @@ snapshots: fn key_line_parser_handles_both_quote_styles_and_file_specs() { assert_eq!( parse_key_line(" left-pad@1.3.0:", 2), - Some(("left-pad@1.3.0".into(), "left-pad@1.3.0".into(), String::new())) + Some(( + "left-pad@1.3.0".into(), + "left-pad@1.3.0".into(), + String::new() + )) ); assert_eq!( parse_key_line(" left-pad@1.3.0: {}", 2), - Some(("left-pad@1.3.0".into(), "left-pad@1.3.0".into(), "{}".into())) + Some(( + "left-pad@1.3.0".into(), + "left-pad@1.3.0".into(), + "{}".into() + )) ); // Keys containing `:` (file: specs) split at the colon+space/EOL. assert_eq!( parse_key_line(" left-pad@file:x/y.tgz:", 2), - Some(("left-pad@file:x/y.tgz".into(), "left-pad@file:x/y.tgz".into(), String::new())) + Some(( + "left-pad@file:x/y.tgz".into(), + "left-pad@file:x/y.tgz".into(), + String::new() + )) ); // pnpm's quoted @-keys (both majors single-quote them). assert_eq!( parse_key_line(" '@scope/a@1.0.0':", 2), - Some(("@scope/a@1.0.0".into(), "'@scope/a@1.0.0'".into(), String::new())) + Some(( + "@scope/a@1.0.0".into(), + "'@scope/a@1.0.0'".into(), + String::new() + )) ); assert_eq!( parse_key_line(" \"@scope/a@1.0.0\": {}", 2), - Some(("@scope/a@1.0.0".into(), "\"@scope/a@1.0.0\"".into(), "{}".into())) + Some(( + "@scope/a@1.0.0".into(), + "\"@scope/a@1.0.0\"".into(), + "{}".into() + )) ); // Wrong indent / deeper lines are not keys at this level. assert_eq!(parse_key_line(" resolution: {}", 2), None); - assert_eq!(parse_key_line(" - left-pad", 6), None, "list items are not keys"); + assert_eq!( + parse_key_line(" - left-pad", 6), + None, + "list items are not keys" + ); assert_eq!(yaml_key("@scope/a@file:x"), "'@scope/a@file:x'"); assert_eq!(yaml_key("left-pad@1.3.0"), "left-pad@1.3.0"); diff --git a/crates/socket-patch-core/src/patch/vendor/pypi.rs b/crates/socket-patch-core/src/patch/vendor/pypi.rs index 823a751..66b1337 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi.rs @@ -435,10 +435,10 @@ pub async fn vendor_pypi( let platform_locked = dist.wheel_tags.iter().any(|t| tag_is_platform_specific(t)); if platform_locked { let per_flavor = match flavor { - PypiFlavor::UvProject => { - "uv.lock now resolves it from this single-platform wheel only" + PypiFlavor::UvProject => "uv.lock now resolves it from this single-platform wheel only", + PypiFlavor::Poetry => { + "poetry.lock now resolves it from this single-platform wheel only" } - PypiFlavor::Poetry => "poetry.lock now resolves it from this single-platform wheel only", PypiFlavor::Pdm => "pdm.lock now resolves it from this single-platform wheel only", PypiFlavor::Pipenv => { "Pipfile.lock now resolves it from this single-platform wheel only" @@ -699,18 +699,32 @@ mod tests { assert_eq!(f, PypiFlavor::Poetry); assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].code, "pypi_multiple_lockfiles"); - assert!(warnings[0].detail.contains("Pipfile.lock"), "{}", warnings[0].detail); + assert!( + warnings[0].detail.contains("Pipfile.lock"), + "{}", + warnings[0].detail + ); // 5. Lock-less tool markers refuse with the per-tool pointer... let tmp = tempfile::tempdir().unwrap(); - touch(tmp.path(), "pyproject.toml", "[project]\nname = \"x\"\n\n[tool.uv]\ndev = true\n").await; + touch( + tmp.path(), + "pyproject.toml", + "[project]\nname = \"x\"\n\n[tool.uv]\ndev = true\n", + ) + .await; let err = detect_pypi_flavor(tmp.path()).await.unwrap_err(); assert_eq!(err.0, "pypi_uv_no_lockfile"); assert!(err.1.contains("uv lock")); assert!(err.1.contains("socket-patch setup")); let tmp = tempfile::tempdir().unwrap(); - touch(tmp.path(), "pyproject.toml", "[tool.poetry]\nname = \"x\"\n").await; + touch( + tmp.path(), + "pyproject.toml", + "[tool.poetry]\nname = \"x\"\n", + ) + .await; let err = detect_pypi_flavor(tmp.path()).await.unwrap_err(); assert_eq!(err.0, "pypi_poetry_no_lockfile"); assert!(err.1.contains("poetry lock")); @@ -918,7 +932,9 @@ mod tests { // The installed site-packages tree was never touched. assert_eq!( - tokio::fs::read(fx.site_packages.join("six.py")).await.unwrap(), + tokio::fs::read(fx.site_packages.join("six.py")) + .await + .unwrap(), ORIG ); @@ -927,7 +943,9 @@ mod tests { assert!(reverted.success, "{:?}", reverted.error); assert!(reverted.warnings.is_empty(), "{:?}", reverted.warnings); assert_eq!( - tokio::fs::read_to_string(fx.root.join("requirements.txt")).await.unwrap(), + tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(), "six==1.16.0\n" ); assert!(!fx.root.join(format!(".socket/vendor/pypi/{UUID}")).exists()); @@ -956,7 +974,9 @@ mod tests { assert_eq!(code, "vendor_unsafe_uuid"); assert!(!fx.root.join(".socket").exists(), "nothing may be written"); assert_eq!( - tokio::fs::read_to_string(fx.root.join("requirements.txt")).await.unwrap(), + tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(), "six==1.16.0\n" ); } @@ -983,7 +1003,9 @@ mod tests { assert!(entry.is_none(), "dry run yields no entry to persist"); assert!(!fx.root.join(".socket").exists()); assert_eq!( - tokio::fs::read_to_string(fx.root.join("requirements.txt")).await.unwrap(), + tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(), "six==1.16.0\n" ); } @@ -1061,7 +1083,9 @@ mod tests { fn platform_specific_tag_detection() { assert!(!tag_is_platform_specific("py3-none-any")); assert!(!tag_is_platform_specific("cp311-none-any")); - assert!(tag_is_platform_specific("cp311-cp311-manylinux_2_17_x86_64")); + assert!(tag_is_platform_specific( + "cp311-cp311-manylinux_2_17_x86_64" + )); assert!(tag_is_platform_specific("py3-none-macosx_11_0_arm64")); assert!(tag_is_platform_specific("py3-abi3-any")); assert!(tag_is_platform_specific("garbage")); diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs b/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs index a728422..480af06 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs @@ -153,7 +153,9 @@ pub async fn load_pdm_project(root: &Path) -> Result Ok(PdmTarget::InSync), + Some(parts) if parts.eco == "pypi" && parts.uuid == record_uuid => { + Ok(PdmTarget::InSync) + } // Ours, but a STALE patch generation: wiring over it would lose // the only recorded registry original — refuse with the repair // path (mirrors gem's stale-checksum refusal). @@ -321,9 +325,9 @@ pub(super) fn check_target_guards( .and_then(Item::as_array) .map(|arr| { !arr.is_empty() - && arr.iter().all(|v| { - v.as_inline_table().is_some_and(|t| t.contains_key("hash")) - }) + && arr + .iter() + .all(|v| v.as_inline_table().is_some_and(|t| t.contains_key("hash"))) }) .unwrap_or(false); if !hashed_entries { @@ -566,14 +570,13 @@ fn rewrite_target_package_unit( wheel_file_name: &str, wheel_sha256_hex: &str, ) -> Result<(String, String), (&'static str, String)> { - let span = find_unit_span(lock_text, |lines| unit_has_canon_name(lines, canon)).ok_or_else( - || { + let span = + find_unit_span(lock_text, |lines| unit_has_canon_name(lines, canon)).ok_or_else(|| { ( "pypi_pdm_lock_package_missing", format!("{LOCK_FILE} has no [[package]] entry for {canon}"), ) - }, - )?; + })?; // `find_unit_span` ends a unit at the NEXT `[[package]]` or EOF; truncate // defensively at any foreign top-level header so the splice never // swallows a trailing section (pdm's [metadata] leads the file today, but @@ -849,7 +852,9 @@ distribution = false async fn write_project(lock: &str, pyproject: &str) -> tempfile::TempDir { let tmp = tempfile::tempdir().unwrap(); - tokio::fs::write(tmp.path().join("pdm.lock"), lock).await.unwrap(); + tokio::fs::write(tmp.path().join("pdm.lock"), lock) + .await + .unwrap(); tokio::fs::write(tmp.path().join("pyproject.toml"), pyproject) .await .unwrap(); @@ -857,7 +862,9 @@ distribution = false } async fn read_lock(root: &Path) -> String { - tokio::fs::read_to_string(root.join("pdm.lock")).await.unwrap() + tokio::fs::read_to_string(root.join("pdm.lock")) + .await + .unwrap() } fn entry_for(wiring: Vec, meta: PdmMeta) -> VendorEntry { @@ -884,9 +891,11 @@ distribution = false } async fn wire_default(p: &PdmProject, root: &Path) -> (Vec, PdmMeta) { - wire_pdm(p, root, "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UUID) - .await - .unwrap() + wire_pdm( + p, root, "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UUID, + ) + .await + .unwrap() } /// The load-bearing oracle: wiring the registry lock must produce the @@ -895,7 +904,12 @@ distribution = false #[tokio::test] async fn wiring_matches_fixtures_byte_identically() { let cases = [ - (LOCK_DIRECT_REGISTRY, LOCK_DIRECT_VENDORED, PYPROJECT_DIRECT, "direct"), + ( + LOCK_DIRECT_REGISTRY, + LOCK_DIRECT_VENDORED, + PYPROJECT_DIRECT, + "direct", + ), ( LOCK_TRANSITIVE_REGISTRY, LOCK_TRANSITIVE_VENDORED, @@ -923,7 +937,9 @@ distribution = false ); // pyproject + content_hash are NEVER touched (lock-only splice). assert_eq!( - tokio::fs::read_to_string(tmp.path().join("pyproject.toml")).await.unwrap(), + tokio::fs::read_to_string(tmp.path().join("pyproject.toml")) + .await + .unwrap(), pyproject ); @@ -1016,8 +1032,10 @@ distribution = false let err = load_pdm_project(tmp.path()).await.unwrap_err(); assert_eq!(err.0, "pypi_pdm_lock_version_unsupported"); for bad in ["4.4.1", "3.0", "5.0.0", "garbage"] { - let lock = LOCK_DIRECT_REGISTRY - .replace("lock_version = \"4.5.0\"", &format!("lock_version = \"{bad}\"")); + let lock = LOCK_DIRECT_REGISTRY.replace( + "lock_version = \"4.5.0\"", + &format!("lock_version = \"{bad}\""), + ); let tmp = write_project(&lock, PYPROJECT_DIRECT).await; let err = load_pdm_project(tmp.path()).await.unwrap_err(); assert_eq!(err.0, "pypi_pdm_lock_version_unsupported", "{bad}"); @@ -1068,11 +1086,24 @@ distribution = false // wire re-runs the guards itself (refusal before any write) let before = read_lock(tmp.path()).await; - let err = wire_pdm(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UUID) - .await - .unwrap_err(); + let err = wire_pdm( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); assert_eq!(err.0, "pypi_pdm_source_already_exists"); - assert_eq!(read_lock(tmp.path()).await, before, "refusal writes nothing"); + assert_eq!( + read_lock(tmp.path()).await, + before, + "refusal writes nothing" + ); } #[tokio::test] @@ -1120,14 +1151,22 @@ distribution = false warnings: Vec::new(), }; // PEP 621 dependency specs (with PEP 503 canonicalization). - assert_eq!(classify_dependency(&p(Some(PYPROJECT_DIRECT)), "six"), "direct"); assert_eq!( - classify_dependency(&p(Some("[project]\ndependencies = [\"Six_Pkg>=1\"]\n")), "six-pkg"), + classify_dependency(&p(Some(PYPROJECT_DIRECT)), "six"), "direct" ); assert_eq!( classify_dependency( - &p(Some("[project.optional-dependencies]\nextra = [\"six==1.16.0\"]\n")), + &p(Some("[project]\ndependencies = [\"Six_Pkg>=1\"]\n")), + "six-pkg" + ), + "direct" + ); + assert_eq!( + classify_dependency( + &p(Some( + "[project.optional-dependencies]\nextra = [\"six==1.16.0\"]\n" + )), "six" ), "direct" @@ -1145,7 +1184,10 @@ distribution = false "direct" ); // Not declared / no pyproject → transitive (diagnostics-only). - assert_eq!(classify_dependency(&p(Some(PYPROJECT_TRANSITIVE)), "six"), "transitive"); + assert_eq!( + classify_dependency(&p(Some(PYPROJECT_TRANSITIVE)), "six"), + "transitive" + ); assert_eq!(classify_dependency(&p(None), "six"), "transitive"); } @@ -1160,7 +1202,9 @@ distribution = false let _ = check_target_guards(&p, "six", "1.16.0", UUID).unwrap(); assert_eq!(read_lock(tmp.path()).await, LOCK_DIRECT_REGISTRY); assert_eq!( - tokio::fs::read_to_string(tmp.path().join("pyproject.toml")).await.unwrap(), + tokio::fs::read_to_string(tmp.path().join("pyproject.toml")) + .await + .unwrap(), PYPROJECT_DIRECT ); } @@ -1203,9 +1247,13 @@ distribution = false let outer = tempfile::tempdir().unwrap(); let root = outer.path().join("project"); tokio::fs::create_dir_all(&root).await.unwrap(); - tokio::fs::write(root.join("pdm.lock"), LOCK_DIRECT_REGISTRY).await.unwrap(); + tokio::fs::write(root.join("pdm.lock"), LOCK_DIRECT_REGISTRY) + .await + .unwrap(); let precious = outer.path().join("precious.txt"); - tokio::fs::write(&precious, "keep me intact\n").await.unwrap(); + tokio::fs::write(&precious, "keep me intact\n") + .await + .unwrap(); for bad in ["pyproject.toml", "../precious.txt", "/etc/hosts"] { let wiring = vec![WiringRecord { @@ -1222,9 +1270,15 @@ distribution = false strategy: vec!["inherit_metadata".into()], }; let outcome = revert_pdm(&entry_for(wiring, meta), &root, false).await; - assert!(outcome.success, "skipped fail-closed, not a hard error: {bad}"); assert!( - outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + outcome.success, + "skipped fail-closed, not a hard error: {bad}" + ); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), "skip surfaced for {bad}: {:?}", outcome.warnings ); @@ -1235,7 +1289,9 @@ distribution = false "out-of-tree file byte-untouched" ); assert_eq!( - tokio::fs::read_to_string(root.join("pdm.lock")).await.unwrap(), + tokio::fs::read_to_string(root.join("pdm.lock")) + .await + .unwrap(), LOCK_DIRECT_REGISTRY, "the lock itself is untouched too (no record matched it)" ); @@ -1259,8 +1315,12 @@ distribution = false }); // Drift: someone re-hashed the vendored files entry. - let drifted = read_lock(tmp.path()).await.replace(WHEEL_SHA, &"0".repeat(64)); - tokio::fs::write(tmp.path().join("pdm.lock"), &drifted).await.unwrap(); + let drifted = read_lock(tmp.path()) + .await + .replace(WHEEL_SHA, &"0".repeat(64)); + tokio::fs::write(tmp.path().join("pdm.lock"), &drifted) + .await + .unwrap(); let outcome = revert_pdm(&entry_for(wiring, meta), tmp.path(), false).await; assert!(outcome.success); @@ -1274,17 +1334,42 @@ distribution = false "drifted fragment + unknown kind: {:?}", outcome.warnings ); - assert_eq!(read_lock(tmp.path()).await, drifted, "drifted lock left alone"); + assert_eq!( + read_lock(tmp.path()).await, + drifted, + "drifted lock left alone" + ); } #[test] fn lock_version_series_classifier() { - assert!(matches!(lock_version_series("4.5.0"), LockVersionSeries::Supported)); - assert!(matches!(lock_version_series("4.5.1"), LockVersionSeries::Supported)); - assert!(matches!(lock_version_series("4.6.0"), LockVersionSeries::NewerMinor)); - assert!(matches!(lock_version_series("4.10.2"), LockVersionSeries::NewerMinor)); - assert!(matches!(lock_version_series("4.4.1"), LockVersionSeries::Unsupported)); - assert!(matches!(lock_version_series("5.0.0"), LockVersionSeries::Unsupported)); - assert!(matches!(lock_version_series("garbage"), LockVersionSeries::Unsupported)); + assert!(matches!( + lock_version_series("4.5.0"), + LockVersionSeries::Supported + )); + assert!(matches!( + lock_version_series("4.5.1"), + LockVersionSeries::Supported + )); + assert!(matches!( + lock_version_series("4.6.0"), + LockVersionSeries::NewerMinor + )); + assert!(matches!( + lock_version_series("4.10.2"), + LockVersionSeries::NewerMinor + )); + assert!(matches!( + lock_version_series("4.4.1"), + LockVersionSeries::Unsupported + )); + assert!(matches!( + lock_version_series("5.0.0"), + LockVersionSeries::Unsupported + )); + assert!(matches!( + lock_version_series("garbage"), + LockVersionSeries::Unsupported + )); } } diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs b/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs index 8681033..6c03d34 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs @@ -642,12 +642,16 @@ mod tests { async fn write_lock(lock: &str) -> tempfile::TempDir { let tmp = tempfile::tempdir().unwrap(); - tokio::fs::write(tmp.path().join("Pipfile.lock"), lock).await.unwrap(); + tokio::fs::write(tmp.path().join("Pipfile.lock"), lock) + .await + .unwrap(); tmp } async fn read_lock(root: &Path) -> String { - tokio::fs::read_to_string(root.join("Pipfile.lock")).await.unwrap() + tokio::fs::read_to_string(root.join("Pipfile.lock")) + .await + .unwrap() } fn entry_for(wiring: Vec, meta: PipenvMeta) -> VendorEntry { @@ -674,7 +678,9 @@ mod tests { } async fn wire_default(p: &PipenvProject, root: &Path) -> (Vec, PipenvMeta) { - wire_pipenv(p, root, "six", REL_WHEEL, WHEEL_SHA, UUID).await.unwrap() + wire_pipenv(p, root, "six", REL_WHEEL, WHEEL_SHA, UUID) + .await + .unwrap() } /// The load-bearing oracle: wiring the registry lock must produce the @@ -684,7 +690,11 @@ mod tests { async fn wiring_matches_fixtures_byte_identically() { let cases = [ (LOCK_DIRECT_REGISTRY, LOCK_DIRECT_VENDORED, "direct"), - (LOCK_TRANSITIVE_REGISTRY, LOCK_TRANSITIVE_VENDORED, "transitive"), + ( + LOCK_TRANSITIVE_REGISTRY, + LOCK_TRANSITIVE_VENDORED, + "transitive", + ), ]; for (before, after, label) in cases { let tmp = write_lock(before).await; @@ -741,7 +751,10 @@ mod tests { assert_eq!(wiring.len(), 2); let keys: Vec<&str> = wiring.iter().filter_map(|w| w.key.as_deref()).collect(); assert_eq!(keys, vec!["default:six", "develop:six"]); - assert_eq!(meta.sections, vec!["default".to_string(), "develop".to_string()]); + assert_eq!( + meta.sections, + vec!["default".to_string(), "develop".to_string()] + ); // Round trip: both entries restored byte-identically. let outcome = revert_pipenv(&entry_for(wiring, meta), tmp.path(), false).await; @@ -759,7 +772,9 @@ mod tests { assert_eq!(p.warnings.len(), 1); assert_eq!(p.warnings[0].code, "vendor_integrity_unverified"); assert!( - p.warnings[0].detail.contains("protected only by the committed wheel itself"), + p.warnings[0] + .detail + .contains("protected only by the committed wheel itself"), "{}", p.warnings[0].detail ); @@ -808,7 +823,9 @@ mod tests { assert_eq!(err.0, "pypi_pipenv_lock_parse_failed"); // pipfile-spec != 6 (and missing entirely) - let tmp = write_lock(&LOCK_DIRECT_REGISTRY.replace("\"pipfile-spec\": 6", "\"pipfile-spec\": 7")).await; + let tmp = + write_lock(&LOCK_DIRECT_REGISTRY.replace("\"pipfile-spec\": 6", "\"pipfile-spec\": 7")) + .await; let err = load_pipenv_project(tmp.path()).await.unwrap_err(); assert_eq!(err.0, "pypi_pipenv_spec_unsupported"); let tmp = write_lock("{\"default\": {}}").await; @@ -849,7 +866,11 @@ mod tests { .await .unwrap_err(); assert_eq!(err.0, "pypi_pipenv_source_already_exists"); - assert_eq!(read_lock(tmp.path()).await, before, "refusal writes nothing"); + assert_eq!( + read_lock(tmp.path()).await, + before, + "refusal writes nothing" + ); } /// Re-running vendor on an already-wired lock with the SAME uuid is the @@ -920,7 +941,9 @@ mod tests { .await .unwrap(); let precious = outer.path().join("precious.txt"); - tokio::fs::write(&precious, "keep me intact\n").await.unwrap(); + tokio::fs::write(&precious, "keep me intact\n") + .await + .unwrap(); let bad_records = [ ("Pipfile", "default:six"), @@ -938,11 +961,19 @@ mod tests { original: Some(serde_json::json!({"malicious": true})), new: Some(serde_json::json!("keep me intact")), }]; - let meta = PipenvMeta { sections: vec!["default".into()] }; + let meta = PipenvMeta { + sections: vec!["default".into()], + }; let outcome = revert_pipenv(&entry_for(wiring, meta), &root, false).await; - assert!(outcome.success, "skipped fail-closed, not a hard error: {file}/{key}"); assert!( - outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + outcome.success, + "skipped fail-closed, not a hard error: {file}/{key}" + ); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), "skip surfaced for {file}/{key}: {:?}", outcome.warnings ); @@ -953,7 +984,9 @@ mod tests { "out-of-tree file byte-untouched" ); assert_eq!( - tokio::fs::read_to_string(root.join("Pipfile.lock")).await.unwrap(), + tokio::fs::read_to_string(root.join("Pipfile.lock")) + .await + .unwrap(), LOCK_DIRECT_REGISTRY, "no record matched: the lock is not even re-serialized" ); @@ -977,8 +1010,12 @@ mod tests { }); // Drift: someone replaced our hash in the vendored entry. - let drifted = read_lock(tmp.path()).await.replace(WHEEL_SHA, &"0".repeat(64)); - tokio::fs::write(tmp.path().join("Pipfile.lock"), &drifted).await.unwrap(); + let drifted = read_lock(tmp.path()) + .await + .replace(WHEEL_SHA, &"0".repeat(64)); + tokio::fs::write(tmp.path().join("Pipfile.lock"), &drifted) + .await + .unwrap(); let outcome = revert_pipenv(&entry_for(wiring, meta), tmp.path(), false).await; assert!(outcome.success); @@ -992,6 +1029,10 @@ mod tests { "drifted entry + unknown kind: {:?}", outcome.warnings ); - assert_eq!(read_lock(tmp.path()).await, drifted, "drifted lock left alone"); + assert_eq!( + read_lock(tmp.path()).await, + drifted, + "drifted lock left alone" + ); } } diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs b/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs index 4d5d0d7..176282a 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs @@ -116,7 +116,9 @@ pub async fn load_poetry_project(root: &Path) -> Result Result<(String, String), (&'static str, String)> { - let span = find_unit_span(lock_text, |lines| unit_has_canon_name(lines, canon)).ok_or_else( - || { + let span = + find_unit_span(lock_text, |lines| unit_has_canon_name(lines, canon)).ok_or_else(|| { ( "pypi_poetry_lock_package_missing", format!("{LOCK_FILE} has no [[package]] entry for {canon}"), ) - }, - )?; + })?; // `find_unit_span` ends a unit at the NEXT `[[package]]` or EOF, but // poetry's `[metadata]` section trails the LAST unit — truncate at the // first top-level header that is not a `[package.*]` subtable so the @@ -845,7 +848,9 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca async fn write_project(lock: &str, pyproject: &str) -> tempfile::TempDir { let tmp = tempfile::tempdir().unwrap(); - tokio::fs::write(tmp.path().join("poetry.lock"), lock).await.unwrap(); + tokio::fs::write(tmp.path().join("poetry.lock"), lock) + .await + .unwrap(); tokio::fs::write(tmp.path().join("pyproject.toml"), pyproject) .await .unwrap(); @@ -853,7 +858,9 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca } async fn read_lock(root: &Path) -> String { - tokio::fs::read_to_string(root.join("poetry.lock")).await.unwrap() + tokio::fs::read_to_string(root.join("poetry.lock")) + .await + .unwrap() } fn entry_for(wiring: Vec, meta: PoetryMeta) -> VendorEntry { @@ -880,9 +887,11 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca } async fn wire_default(p: &PoetryProject, root: &Path) -> (Vec, PoetryMeta) { - wire_poetry(p, root, "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UUID) - .await - .unwrap() + wire_poetry( + p, root, "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UUID, + ) + .await + .unwrap() } /// The load-bearing oracle: wiring the registry lock must produce the @@ -891,7 +900,13 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca #[tokio::test] async fn wiring_matches_fixtures_byte_identically_both_lock_versions() { let cases = [ - ("2.1", LOCK21_DIRECT_REGISTRY, LOCK21_DIRECT_VENDORED, PYPROJECT_DIRECT, "direct"), + ( + "2.1", + LOCK21_DIRECT_REGISTRY, + LOCK21_DIRECT_VENDORED, + PYPROJECT_DIRECT, + "direct", + ), ( "2.1", LOCK21_TRANSITIVE_REGISTRY, @@ -899,7 +914,13 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca PYPROJECT_TRANSITIVE, "transitive", ), - ("2.0", LOCK20_DIRECT_REGISTRY, LOCK20_DIRECT_VENDORED, PYPROJECT_DIRECT, "direct"), + ( + "2.0", + LOCK20_DIRECT_REGISTRY, + LOCK20_DIRECT_VENDORED, + PYPROJECT_DIRECT, + "direct", + ), ( "2.0", LOCK20_TRANSITIVE_REGISTRY, @@ -927,7 +948,9 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca ); // pyproject + content-hash are NEVER touched (lock-only splice). assert_eq!( - tokio::fs::read_to_string(tmp.path().join("pyproject.toml")).await.unwrap(), + tokio::fs::read_to_string(tmp.path().join("pyproject.toml")) + .await + .unwrap(), pyproject ); @@ -956,8 +979,10 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca let err = load_poetry_project(tmp.path()).await.unwrap_err(); assert_eq!(err.0, "pypi_poetry_lock_version_unsupported"); for bad in ["1.1", "3.0"] { - let lock = LOCK21_DIRECT_REGISTRY - .replace("lock-version = \"2.1\"", &format!("lock-version = \"{bad}\"")); + let lock = LOCK21_DIRECT_REGISTRY.replace( + "lock-version = \"2.1\"", + &format!("lock-version = \"{bad}\""), + ); let tmp = write_project(&lock, PYPROJECT_DIRECT).await; let err = load_poetry_project(tmp.path()).await.unwrap_err(); assert_eq!(err.0, "pypi_poetry_lock_version_unsupported", "{bad}"); @@ -998,17 +1023,30 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca // wire re-runs the guards itself (refusal before any write) let before = read_lock(tmp.path()).await; - let err = - wire_poetry(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UUID) - .await - .unwrap_err(); + let err = wire_poetry( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); assert_eq!(err.0, "pypi_poetry_source_already_exists"); - assert_eq!(read_lock(tmp.path()).await, before, "refusal writes nothing"); + assert_eq!( + read_lock(tmp.path()).await, + before, + "refusal writes nothing" + ); } #[tokio::test] async fn newer_2x_lock_version_warns_not_refuses() { - let lock = LOCK21_DIRECT_REGISTRY.replace("lock-version = \"2.1\"", "lock-version = \"2.5\""); + let lock = + LOCK21_DIRECT_REGISTRY.replace("lock-version = \"2.1\"", "lock-version = \"2.5\""); let tmp = write_project(&lock, PYPROJECT_DIRECT).await; let p = load_poetry_project(tmp.path()).await.unwrap(); assert_eq!(p.warnings.len(), 1); @@ -1050,7 +1088,10 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca warnings: Vec::new(), }; // [tool.poetry.dependencies] key (with PEP 503 canonicalization). - assert_eq!(classify_dependency(&p(Some(PYPROJECT_DIRECT)), "six"), "direct"); + assert_eq!( + classify_dependency(&p(Some(PYPROJECT_DIRECT)), "six"), + "direct" + ); assert_eq!( classify_dependency( &p(Some("[tool.poetry.dependencies]\nPyYAML = \"6.0.1\"\n")), @@ -1061,29 +1102,42 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca // group + dev-dependencies keys. assert_eq!( classify_dependency( - &p(Some("[tool.poetry.group.dev.dependencies]\nsix = \"1.16.0\"\n")), + &p(Some( + "[tool.poetry.group.dev.dependencies]\nsix = \"1.16.0\"\n" + )), "six" ), "direct" ); assert_eq!( - classify_dependency(&p(Some("[tool.poetry.dev-dependencies]\nsix = \"*\"\n")), "six"), + classify_dependency( + &p(Some("[tool.poetry.dev-dependencies]\nsix = \"*\"\n")), + "six" + ), "direct" ); // PEP 621 dependency specs. assert_eq!( - classify_dependency(&p(Some("[project]\ndependencies = [\"six==1.16.0\"]\n")), "six"), + classify_dependency( + &p(Some("[project]\ndependencies = [\"six==1.16.0\"]\n")), + "six" + ), "direct" ); assert_eq!( classify_dependency( - &p(Some("[project.optional-dependencies]\nextra = [\"Six_Pkg>=1\"]\n")), + &p(Some( + "[project.optional-dependencies]\nextra = [\"Six_Pkg>=1\"]\n" + )), "six-pkg" ), "direct" ); // Not declared / no pyproject → transitive (diagnostics-only). - assert_eq!(classify_dependency(&p(Some(PYPROJECT_TRANSITIVE)), "six"), "transitive"); + assert_eq!( + classify_dependency(&p(Some(PYPROJECT_TRANSITIVE)), "six"), + "transitive" + ); assert_eq!(classify_dependency(&p(None), "six"), "transitive"); } @@ -1098,7 +1152,9 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca let _ = check_target_guards(&p, "six", "1.16.0", UUID).unwrap(); assert_eq!(read_lock(tmp.path()).await, LOCK21_DIRECT_REGISTRY); assert_eq!( - tokio::fs::read_to_string(tmp.path().join("pyproject.toml")).await.unwrap(), + tokio::fs::read_to_string(tmp.path().join("pyproject.toml")) + .await + .unwrap(), PYPROJECT_DIRECT ); } @@ -1145,7 +1201,9 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca .await .unwrap(); let precious = outer.path().join("precious.txt"); - tokio::fs::write(&precious, "keep me intact\n").await.unwrap(); + tokio::fs::write(&precious, "keep me intact\n") + .await + .unwrap(); for bad in ["pyproject.toml", "../precious.txt", "/etc/hosts"] { let wiring = vec![WiringRecord { @@ -1156,11 +1214,20 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca original: Some(serde_json::json!("malicious payload")), new: Some(serde_json::json!("keep me intact")), }]; - let meta = PoetryMeta { dep_class: "direct".into(), lock_version: "2.1".into() }; + let meta = PoetryMeta { + dep_class: "direct".into(), + lock_version: "2.1".into(), + }; let outcome = revert_poetry(&entry_for(wiring, meta), &root, false).await; - assert!(outcome.success, "skipped fail-closed, not a hard error: {bad}"); assert!( - outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + outcome.success, + "skipped fail-closed, not a hard error: {bad}" + ); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), "skip surfaced for {bad}: {:?}", outcome.warnings ); @@ -1171,7 +1238,9 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca "out-of-tree file byte-untouched" ); assert_eq!( - tokio::fs::read_to_string(root.join("poetry.lock")).await.unwrap(), + tokio::fs::read_to_string(root.join("poetry.lock")) + .await + .unwrap(), LOCK21_DIRECT_REGISTRY, "the lock itself is untouched too (no record matched it)" ); @@ -1195,8 +1264,12 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca }); // Drift: someone re-hashed the vendored files entry. - let drifted = read_lock(tmp.path()).await.replace(WHEEL_SHA, &"0".repeat(64)); - tokio::fs::write(tmp.path().join("poetry.lock"), &drifted).await.unwrap(); + let drifted = read_lock(tmp.path()) + .await + .replace(WHEEL_SHA, &"0".repeat(64)); + tokio::fs::write(tmp.path().join("poetry.lock"), &drifted) + .await + .unwrap(); let outcome = revert_poetry(&entry_for(wiring, meta), tmp.path(), false).await; assert!(outcome.success); @@ -1210,7 +1283,11 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca "drifted fragment + unknown kind: {:?}", outcome.warnings ); - assert_eq!(read_lock(tmp.path()).await, drifted, "drifted lock left alone"); + assert_eq!( + read_lock(tmp.path()).await, + drifted, + "drifted lock left alone" + ); } #[test] diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs index a556fb8..140f67f 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs @@ -68,7 +68,11 @@ pub fn find_pin(content: &str, canon_name: &str, version: &str) -> PinSearch { found_extras = true; continue; } - let spec_no_ws: String = req.specifier.chars().filter(|c| !c.is_whitespace()).collect(); + let spec_no_ws: String = req + .specifier + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); if spec_no_ws == format!("=={version}") { if exact.is_none() { exact = Some(PinSearch::Exact { @@ -99,7 +103,9 @@ pub(super) async fn preflight_requirements( canon_name: &str, version: &str, ) -> Result<(), (&'static str, String)> { - plan_requirements(root, canon_name, version, "", "").await.map(|_| ()) + plan_requirements(root, canon_name, version, "", "") + .await + .map(|_| ()) } /// Rewrite every exact pin across the root `requirements.txt` and its `-r` @@ -133,11 +139,7 @@ pub async fn wire_requirements( /// what vendor wrote are left alone with `vendor_revert_line_drifted`; any /// surviving reference to the vendored uuid dir afterwards raises /// `vendor_revert_residual_reference`. -pub async fn revert_requirements( - entry: &VendorEntry, - root: &Path, - dry_run: bool, -) -> RevertOutcome { +pub async fn revert_requirements(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { let mut warnings: Vec = Vec::new(); // Group records per file, preserving application order within each. @@ -348,7 +350,11 @@ async fn plan_requirements( if canonicalize_pypi_name(&req.name) != canon_name || req.extras.is_some() { continue; } - let spec: String = req.specifier.chars().filter(|c| !c.is_whitespace()).collect(); + let spec: String = req + .specifier + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); if spec == format!("=={version}") { spans.push((ll.start, ll.physical.len(), req.marker)); } @@ -361,7 +367,14 @@ async fn plan_requirements( let mut lines = original_lines.clone(); let mut records = Vec::new(); for (start, count, marker) in spans.iter().rev() { - let line = vendor_line(rel_wheel, wheel_sha256_hex, canon_name, version, marker, false); + let line = vendor_line( + rel_wheel, + wheel_sha256_hex, + canon_name, + version, + marker, + false, + ); let replaced: Vec = original_lines[*start..*start + *count].to_vec(); lines.splice(*start..*start + *count, [line.clone()]); records.push(WiringRecord { @@ -370,7 +383,10 @@ async fn plan_requirements( action: WiringAction::Rewritten, key: Some(format!("{}:{}", file.rel, start + 1)), original: Some(serde_json::Value::Array( - replaced.into_iter().map(serde_json::Value::String).collect(), + replaced + .into_iter() + .map(serde_json::Value::String) + .collect(), )), new: Some(serde_json::Value::String(line)), }); @@ -395,7 +411,14 @@ async fn plan_requirements( let root_file = files .first() .expect("collect_requirements_files always yields the root file first"); - let line = vendor_line(rel_wheel, wheel_sha256_hex, canon_name, version, &None, true); + let line = vendor_line( + rel_wheel, + wheel_sha256_hex, + canon_name, + version, + &None, + true, + ); let nl = newline_of(&root_file.content); let mut new_content = root_file.content.clone(); if !new_content.is_empty() && !new_content.ends_with('\n') { @@ -444,9 +467,7 @@ fn vendor_line( /// cycle guard). `-c` constraints files are never followed — they may not /// introduce requirements, so a pin there is pip's problem, not ours, and we /// must never edit them. The root file is always element 0. -async fn collect_requirements_files( - root: &Path, -) -> Result, (&'static str, String)> { +async fn collect_requirements_files(root: &Path) -> Result, (&'static str, String)> { let mut out: Vec = Vec::new(); let mut visited: HashSet = HashSet::new(); let mut stack: Vec<(String, PathBuf)> = vec![( @@ -761,16 +782,28 @@ mod tests { find_pin("Six_Pkg==1.0\n", "six-pkg", "1.0"), PinSearch::Exact { .. } )); - assert_eq!(find_pin("six[socks]==1.16.0\n", "six", "1.16.0"), PinSearch::Extras); + assert_eq!( + find_pin("six[socks]==1.16.0\n", "six", "1.16.0"), + PinSearch::Extras + ); assert_eq!(find_pin("six>=1.0\n", "six", "1.16.0"), PinSearch::Range); assert_eq!(find_pin("six\n", "six", "1.16.0"), PinSearch::Range); // Pinned, but to a different version than the one being vendored. assert_eq!(find_pin("six==1.15.0\n", "six", "1.16.0"), PinSearch::Range); - assert_eq!(find_pin("requests==2.31.0\n", "six", "1.16.0"), PinSearch::Absent); + assert_eq!( + find_pin("requests==2.31.0\n", "six", "1.16.0"), + PinSearch::Absent + ); // `sixty` must not match `six` (name boundary). - assert_eq!(find_pin("sixty==1.16.0\n", "six", "1.16.0"), PinSearch::Absent); + assert_eq!( + find_pin("sixty==1.16.0\n", "six", "1.16.0"), + PinSearch::Absent + ); // Comment-only and option lines are not requirements. - assert_eq!(find_pin("# six==1.16.0\n-r other.txt\n", "six", "1.16.0"), PinSearch::Absent); + assert_eq!( + find_pin("# six==1.16.0\n-r other.txt\n", "six", "1.16.0"), + PinSearch::Absent + ); } // ── wiring ─────────────────────────────────────────────────────────── @@ -793,7 +826,11 @@ mod tests { let outcome = revert_requirements(&entry_for(wiring), tmp.path(), false).await; assert!(outcome.success); assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); - assert_eq!(read_root(tmp.path()).await, original, "byte-identical revert"); + assert_eq!( + read_root(tmp.path()).await, + original, + "byte-identical revert" + ); } #[tokio::test] @@ -856,7 +893,9 @@ mod tests { #[tokio::test] async fn follows_dash_r_includes_and_rewrites_pin_in_place() { let tmp = write_root("-r deps/pinned.txt\nrequests==2.31.0\n").await; - tokio::fs::create_dir_all(tmp.path().join("deps")).await.unwrap(); + tokio::fs::create_dir_all(tmp.path().join("deps")) + .await + .unwrap(); tokio::fs::write(tmp.path().join("deps/pinned.txt"), "six==1.16.0\n") .await .unwrap(); @@ -865,9 +904,14 @@ mod tests { .unwrap(); // The pin is rewritten where it lives; the root stays untouched (no // duplicate appended). - assert_eq!(read_root(tmp.path()).await, "-r deps/pinned.txt\nrequests==2.31.0\n"); assert_eq!( - tokio::fs::read_to_string(tmp.path().join("deps/pinned.txt")).await.unwrap(), + read_root(tmp.path()).await, + "-r deps/pinned.txt\nrequests==2.31.0\n" + ); + assert_eq!( + tokio::fs::read_to_string(tmp.path().join("deps/pinned.txt")) + .await + .unwrap(), format!("{}\n", expected_line()) ); assert_eq!(wiring.len(), 1); @@ -876,7 +920,9 @@ mod tests { let outcome = revert_requirements(&entry_for(wiring), tmp.path(), false).await; assert!(outcome.success); assert_eq!( - tokio::fs::read_to_string(tmp.path().join("deps/pinned.txt")).await.unwrap(), + tokio::fs::read_to_string(tmp.path().join("deps/pinned.txt")) + .await + .unwrap(), "six==1.16.0\n" ); } @@ -890,7 +936,11 @@ mod tests { let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) .await .unwrap(); - assert_eq!(wiring.len(), 1, "cycle guard must not duplicate the rewrite"); + assert_eq!( + wiring.len(), + 1, + "cycle guard must not duplicate the rewrite" + ); } #[tokio::test] @@ -927,7 +977,9 @@ mod tests { assert_eq!(err.0, "pypi_requirements_outside_root"); // The out-of-root file is never edited. assert_eq!( - tokio::fs::read_to_string(outer.path().join("shared.txt")).await.unwrap(), + tokio::fs::read_to_string(outer.path().join("shared.txt")) + .await + .unwrap(), "six==1.16.0\n" ); } @@ -947,7 +999,9 @@ mod tests { tokio::fs::create_dir_all(&root).await.unwrap(); // A precious sibling OUTSIDE the project root. let precious = outer.path().join("precious.txt"); - tokio::fs::write(&precious, "keep me intact\n").await.unwrap(); + tokio::fs::write(&precious, "keep me intact\n") + .await + .unwrap(); for bad in ["../precious.txt", "/etc/hosts", "a/../../precious.txt"] { let wiring = vec![WiringRecord { @@ -1002,7 +1056,11 @@ mod tests { .warnings .iter() .any(|w| w.code == "vendor_revert_residual_reference")); - assert_eq!(read_root(tmp.path()).await, drifted, "drifted line left alone"); + assert_eq!( + read_root(tmp.path()).await, + drifted, + "drifted line left alone" + ); } #[tokio::test] diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs b/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs index ec8be62..94a05ac 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs @@ -74,7 +74,12 @@ pub async fn load_uv_project(root: &Path) -> Result Result UvDepClass { } } } - if let Some(groups) = p.pyproject.get("dependency-groups").and_then(Item::as_table_like) { + if let Some(groups) = p + .pyproject + .get("dependency-groups") + .and_then(Item::as_table_like) + { for (_, item) in groups.iter() { if let Some(arr) = item.as_array() { // Non-string members are `{include-group = "..."}` includes; @@ -355,10 +368,7 @@ pub async fn wire_uv( // ── pyproject.toml (computed in memory; committed before the lock) ──── let mut doc = p.pyproject.clone(); - let had_uv_table = doc - .get("tool") - .and_then(|t| item_get(t, "uv")) - .is_some(); + let had_uv_table = doc.get("tool").and_then(|t| item_get(t, "uv")).is_some(); let created_sources_table = doc .get("tool") .and_then(|t| item_get(t, "uv")) @@ -380,7 +390,10 @@ pub async fn wire_uv( format!("cannot build override value: {e}"), ) })?; - uv_table.insert("override-dependencies", Item::Value(value.decorated(" ", ""))); + uv_table.insert( + "override-dependencies", + Item::Value(value.decorated(" ", "")), + ); wiring.push(record( "pyproject.toml", "uv_override", @@ -434,12 +447,14 @@ pub async fn wire_uv( sources_table.set_implicit(false); sources_table.decor_mut().set_prefix("\n"); } - let sources_value: Value = format!("{{ path = \"{rel_wheel}\" }}").parse().map_err(|e| { - ( - "pypi_uv_lock_parse_failed", - format!("cannot build sources value: {e}"), - ) - })?; + let sources_value: Value = format!("{{ path = \"{rel_wheel}\" }}") + .parse() + .map_err(|e| { + ( + "pypi_uv_lock_parse_failed", + format!("cannot build sources value: {e}"), + ) + })?; sources_table.insert(canon_name, Item::Value(sources_value.decorated(" ", ""))); wiring.push(record( "pyproject.toml", @@ -555,7 +570,10 @@ pub async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) -> Rever let drifted = |what: &str| { VendorWarning::new( "vendor_lock_entry_drifted", - format!("{what} fragment for {:?} changed since vendoring; left untouched", rec.key), + format!( + "{what} fragment for {:?} changed since vendoring; left untouched", + rec.key + ), ) }; match rec.kind.as_str() { @@ -729,7 +747,10 @@ fn ensure_table<'a>( .ok_or_else(|| { ( "pypi_uv_lock_parse_failed", - format!("pyproject.toml [{}] is not a standard table", path.join(".")), + format!( + "pyproject.toml [{}] is not a standard table", + path.join(".") + ), ) })?; } @@ -789,7 +810,9 @@ fn rewrite_target_package_unit( let unit: Vec<&str> = old_unit.lines().collect(); let wheels_lines = [ "wheels = [".to_string(), - format!(" {{ filename = \"{wheel_file_name}\", hash = \"sha256:{wheel_sha256_hex}\" }},"), + format!( + " {{ filename = \"{wheel_file_name}\", hash = \"sha256:{wheel_sha256_hex}\" }}," + ), "]".to_string(), ]; @@ -930,9 +953,7 @@ fn add_manifest_override( ) -> Result<(WiringRecord, String), (&'static str, String)> { let element = format!("{{ name = \"{canon}\", path = \"{rel_wheel}\" }}"); let index = line_index(lock_text); - let manifest_line = index - .iter() - .position(|(_, l)| l.trim_end() == "[manifest]"); + let manifest_line = index.iter().position(|(_, l)| l.trim_end() == "[manifest]"); let Some(h) = manifest_line else { // No [manifest] yet: create it between the lock header and the first @@ -1215,14 +1236,20 @@ wheels = [ tokio::fs::write(tmp.path().join("pyproject.toml"), pyproject) .await .unwrap(); - tokio::fs::write(tmp.path().join("uv.lock"), lock).await.unwrap(); + tokio::fs::write(tmp.path().join("uv.lock"), lock) + .await + .unwrap(); tmp } async fn read_pair(root: &Path) -> (String, String) { ( - tokio::fs::read_to_string(root.join("pyproject.toml")).await.unwrap(), - tokio::fs::read_to_string(root.join("uv.lock")).await.unwrap(), + tokio::fs::read_to_string(root.join("pyproject.toml")) + .await + .unwrap(), + tokio::fs::read_to_string(root.join("uv.lock")) + .await + .unwrap(), ) } @@ -1272,8 +1299,14 @@ wheels = [ .unwrap(); let (pyproject, lock) = read_pair(tmp.path()).await; - assert_eq!(pyproject, DIRECT_PATH_PYPROJECT, "pyproject.toml must byte-match uv's own output"); - assert_eq!(lock, DIRECT_PATH_LOCK, "uv.lock must byte-match uv's own output"); + assert_eq!( + pyproject, DIRECT_PATH_PYPROJECT, + "pyproject.toml must byte-match uv's own output" + ); + assert_eq!( + lock, DIRECT_PATH_LOCK, + "uv.lock must byte-match uv's own output" + ); assert_eq!(meta.dep_class, "direct"); assert_eq!(meta.original_specifier.as_deref(), Some("==1.16.0")); @@ -1282,7 +1315,11 @@ wheels = [ let kinds: Vec<&str> = wiring.iter().map(|w| w.kind.as_str()).collect(); assert_eq!( kinds, - vec!["uv_sources_entry", "uv_lock_package", "uv_lock_requires_dist"] + vec![ + "uv_sources_entry", + "uv_lock_package", + "uv_lock_requires_dist" + ] ); } @@ -1367,7 +1404,10 @@ wheels = [ // missing root [[package]] let tmp = write_pair( DIRECT_REGISTRY_PYPROJECT, - &DIRECT_REGISTRY_LOCK.replace("source = { virtual = \".\" }", "source = { registry = \"x\" }"), + &DIRECT_REGISTRY_LOCK.replace( + "source = { virtual = \".\" }", + "source = { registry = \"x\" }", + ), ) .await; let err = load_uv_project(tmp.path()).await.unwrap_err(); @@ -1391,17 +1431,35 @@ wheels = [ ); let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, &fork).await; let p = load_uv_project(tmp.path()).await.unwrap(); - let err = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) - .await - .unwrap_err(); + let err = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UvDepClass::Direct, + ) + .await + .unwrap_err(); assert_eq!(err.0, "pypi_uv_lock_forked_package"); // target absent from the lock entirely let tmp2 = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; let p2 = load_uv_project(tmp2.path()).await.unwrap(); - let err = wire_uv(&p2, tmp2.path(), "absent-pkg", "1.0.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Transitive) - .await - .unwrap_err(); + let err = wire_uv( + &p2, + tmp2.path(), + "absent-pkg", + "1.0.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UvDepClass::Transitive, + ) + .await + .unwrap_err(); assert_eq!(err.0, "pypi_uv_lock_package_missing"); // user-authored sources entry for the package @@ -1411,9 +1469,18 @@ wheels = [ ) .await; let p = load_uv_project(tmp.path()).await.unwrap(); - let err = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) - .await - .unwrap_err(); + let err = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UvDepClass::Direct, + ) + .await + .unwrap_err(); assert_eq!(err.0, "pypi_uv_source_already_exists"); assert!(err.1.contains("user-authored"), "{}", err.1); @@ -1424,9 +1491,18 @@ wheels = [ ) .await; let p = load_uv_project(tmp.path()).await.unwrap(); - let err = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) - .await - .unwrap_err(); + let err = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UvDepClass::Direct, + ) + .await + .unwrap_err(); assert_eq!(err.0, "pypi_uv_source_already_exists"); assert!(err.1.contains("--revert"), "{}", err.1); @@ -1437,9 +1513,18 @@ wheels = [ ) .await; let p = load_uv_project(tmp.path()).await.unwrap(); - let err = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Transitive) - .await - .unwrap_err(); + let err = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UvDepClass::Transitive, + ) + .await + .unwrap_err(); assert_eq!(err.0, "pypi_uv_source_already_exists"); } @@ -1464,33 +1549,61 @@ wheels = [ let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; let p = load_uv_project(tmp.path()).await.unwrap(); // Make the lock unwritable: a directory can't be renamed over. - tokio::fs::remove_file(tmp.path().join("uv.lock")).await.unwrap(); - tokio::fs::create_dir(tmp.path().join("uv.lock")).await.unwrap(); - - let err = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) + tokio::fs::remove_file(tmp.path().join("uv.lock")) + .await + .unwrap(); + tokio::fs::create_dir(tmp.path().join("uv.lock")) .await - .unwrap_err(); + .unwrap(); + + let err = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UvDepClass::Direct, + ) + .await + .unwrap_err(); assert_eq!(err.0, "pypi_uv_write_failed"); let pyproject = tokio::fs::read_to_string(tmp.path().join("pyproject.toml")) .await .unwrap(); - assert_eq!(pyproject, DIRECT_REGISTRY_PYPROJECT, "pyproject must be unwound"); + assert_eq!( + pyproject, DIRECT_REGISTRY_PYPROJECT, + "pyproject must be unwound" + ); } #[tokio::test] async fn revert_direct_restores_originals_byte_identically() { let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; let p = load_uv_project(tmp.path()).await.unwrap(); - let (wiring, meta) = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) - .await - .unwrap(); + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UvDepClass::Direct, + ) + .await + .unwrap(); let entry = entry_for(wiring, meta); let outcome = revert_uv(&entry, tmp.path(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); let (pyproject, lock) = read_pair(tmp.path()).await; - assert_eq!(pyproject, DIRECT_REGISTRY_PYPROJECT, "requires-dist specifier restored"); + assert_eq!( + pyproject, DIRECT_REGISTRY_PYPROJECT, + "requires-dist specifier restored" + ); assert_eq!(lock, DIRECT_REGISTRY_LOCK); } @@ -1498,26 +1611,50 @@ wheels = [ async fn revert_override_restores_originals_byte_identically() { let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, TRANSITIVE_REGISTRY_LOCK).await; let p = load_uv_project(tmp.path()).await.unwrap(); - let (wiring, meta) = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Transitive) - .await - .unwrap(); + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UvDepClass::Transitive, + ) + .await + .unwrap(); let entry = entry_for(wiring, meta); let outcome = revert_uv(&entry, tmp.path(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); let (pyproject, lock) = read_pair(tmp.path()).await; - assert_eq!(pyproject, TRANSITIVE_REGISTRY_PYPROJECT, "[tool.uv] removed when created by vendor"); - assert_eq!(lock, TRANSITIVE_REGISTRY_LOCK, "[manifest] removed when created by vendor"); + assert_eq!( + pyproject, TRANSITIVE_REGISTRY_PYPROJECT, + "[tool.uv] removed when created by vendor" + ); + assert_eq!( + lock, TRANSITIVE_REGISTRY_LOCK, + "[manifest] removed when created by vendor" + ); } #[tokio::test] async fn revert_dry_run_changes_nothing() { let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; let p = load_uv_project(tmp.path()).await.unwrap(); - let (wiring, meta) = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) - .await - .unwrap(); + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UvDepClass::Direct, + ) + .await + .unwrap(); let entry = entry_for(wiring, meta); let (before_py, before_lock) = read_pair(tmp.path()).await; @@ -1534,20 +1671,36 @@ wheels = [ async fn revert_warns_and_skips_on_drifted_lock_fragment() { let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; let p = load_uv_project(tmp.path()).await.unwrap(); - let (wiring, meta) = wire_uv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_NAME, WHEEL_SHA, UvDepClass::Direct) - .await - .unwrap(); + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UvDepClass::Direct, + ) + .await + .unwrap(); let entry = entry_for(wiring, meta); // Drift: someone re-hashed the vendored wheel entry. - let lock = tokio::fs::read_to_string(tmp.path().join("uv.lock")).await.unwrap(); + let lock = tokio::fs::read_to_string(tmp.path().join("uv.lock")) + .await + .unwrap(); let drifted = lock.replace(WHEEL_SHA, &"0".repeat(64)); - tokio::fs::write(tmp.path().join("uv.lock"), &drifted).await.unwrap(); + tokio::fs::write(tmp.path().join("uv.lock"), &drifted) + .await + .unwrap(); let outcome = revert_uv(&entry, tmp.path(), false).await; assert!(outcome.success); assert!( - outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), "{:?}", outcome.warnings ); diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs b/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs index 933159a..69d4ffb 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs @@ -275,12 +275,11 @@ pub async fn build_patched_wheel( .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); - let script_names = match tokio::fs::read_to_string(dist.dist_info_dir.join("entry_points.txt")) - .await - { - Ok(text) => console_script_names(&text), - Err(_) => HashSet::new(), - }; + let script_names = + match tokio::fs::read_to_string(dist.dist_info_dir.join("entry_points.txt")).await { + Ok(text) => console_script_names(&text), + Err(_) => HashSet::new(), + }; // Select the wheel members from the installed RECORD. let mut members: Vec = Vec::new(); @@ -435,21 +434,20 @@ pub async fn build_patched_wheel( executable: false, }); - let zip_bytes = match tokio::task::spawn_blocking(move || build_deterministic_zip(&entries)) - .await - { - Ok(Ok(bytes)) => bytes, - Ok(Err(e)) => { - result.success = false; - result.error = Some(format!("wheel zip assembly failed: {e}")); - return Ok((result, None)); - } - Err(e) => { - result.success = false; - result.error = Some(format!("wheel zip task failed: {e}")); - return Ok((result, None)); - } - }; + let zip_bytes = + match tokio::task::spawn_blocking(move || build_deterministic_zip(&entries)).await { + Ok(Ok(bytes)) => bytes, + Ok(Err(e)) => { + result.success = false; + result.error = Some(format!("wheel zip assembly failed: {e}")); + return Ok((result, None)); + } + Err(e) => { + result.success = false; + result.error = Some(format!("wheel zip task failed: {e}")); + return Ok((result, None)); + } + }; if let Some(parent) = dest.parent() { if let Err(e) = tokio::fs::create_dir_all(parent).await { @@ -715,16 +713,18 @@ mod tests { ); tokio::fs::write(di.join("RECORD"), record).await.unwrap(); if let Some(ep) = entry_points { - tokio::fs::write(di.join("entry_points.txt"), ep).await.unwrap(); + tokio::fs::write(di.join("entry_points.txt"), ep) + .await + .unwrap(); } let blobs = tmp.path().join("blobs"); tokio::fs::create_dir_all(&blobs).await.unwrap(); tokio::fs::write(blobs.join(compute_git_sha256_from_bytes(PATCHED)), PATCHED) .await .unwrap(); - let dest = tmp - .path() - .join(format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl")); + let dest = tmp.path().join(format!( + ".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl" + )); Fixture { _tmp: tmp, site_packages: sp, @@ -805,12 +805,16 @@ mod tests { record: vec![], wheel_tags: vec!["py2-none-any".into(), "py3-none-any".into()], }; - assert_eq!(wheel_file_name(&dist).unwrap(), "six-1.16.0-py2.py3-none-any.whl"); + assert_eq!( + wheel_file_name(&dist).unwrap(), + "six-1.16.0-py2.py3-none-any.whl" + ); // A tag set that is NOT a cross product of its components must refuse // rather than fabricate compatibility. - let err = compress_wheel_tags(&["py2-none-any".into(), "py3-abi3-manylinux1_x86_64".into()]) - .unwrap_err(); + let err = + compress_wheel_tags(&["py2-none-any".into(), "py3-abi3-manylinux1_x86_64".into()]) + .unwrap_err(); assert_eq!(err.0, "pypi_wheel_tags_unrecoverable"); // Malformed (non-triple) tag. let err = compress_wheel_tags(&["py3".into()]).unwrap_err(); @@ -861,7 +865,9 @@ mod tests { .await .unwrap_err(); assert_eq!(err.0, "pypi_missing_wheel_metadata"); - tokio::fs::write(di.join("WHEEL"), wheel_backup).await.unwrap(); + tokio::fs::write(di.join("WHEEL"), wheel_backup) + .await + .unwrap(); tokio::fs::remove_file(di.join("RECORD")).await.unwrap(); let err = locate_installed_dist(&fx.site_packages, "six", "1.16.0") @@ -914,7 +920,10 @@ mod tests { "__pycache__/six.cpython-314.pyc", "../../../bin/six-cmd", ] { - assert!(!names.contains(&forbidden.to_string()), "{forbidden} leaked"); + assert!( + !names.contains(&forbidden.to_string()), + "{forbidden} leaked" + ); } // Patched bytes actually landed in the wheel. assert_eq!(zip_file(&bytes, "six.py"), PATCHED); @@ -995,8 +1004,12 @@ mod tests { // RECORD is the final zip entry and self-describes with `path,,`. let names = zip_names(&bytes1); - assert_eq!(names.last().map(String::as_str), Some("six-1.16.0.dist-info/RECORD")); - let record_text = String::from_utf8(zip_file(&bytes1, "six-1.16.0.dist-info/RECORD")).unwrap(); + assert_eq!( + names.last().map(String::as_str), + Some("six-1.16.0.dist-info/RECORD") + ); + let record_text = + String::from_utf8(zip_file(&bytes1, "six-1.16.0.dist-info/RECORD")).unwrap(); assert!(record_text.ends_with("six-1.16.0.dist-info/RECORD,,\n")); // RECORD hash of six.py matches the patched bytes. let digest = sha2::Sha256::digest(PATCHED); @@ -1038,7 +1051,8 @@ mod tests { let bytes = tokio::fs::read(&fx.dest).await.unwrap(); assert!(zip_names(&bytes).contains(&"six_extra.py".to_string())); assert_eq!(zip_file(&bytes, "six_extra.py"), created); - let record_text = String::from_utf8(zip_file(&bytes, "six-1.16.0.dist-info/RECORD")).unwrap(); + let record_text = + String::from_utf8(zip_file(&bytes, "six-1.16.0.dist-info/RECORD")).unwrap(); assert!(record_text.contains("six_extra.py,sha256=")); // The created file must NOT exist in the real site-packages. assert!(!fx.site_packages.join("six_extra.py").exists()); @@ -1099,7 +1113,9 @@ mod tests { assert!(!fx.dest.exists()); // Installed tree untouched. assert_eq!( - tokio::fs::read(fx.site_packages.join("six.py")).await.unwrap(), + tokio::fs::read(fx.site_packages.join("six.py")) + .await + .unwrap(), ORIG ); } diff --git a/crates/socket-patch-core/src/patch/vendor/state.rs b/crates/socket-patch-core/src/patch/vendor/state.rs index 0a3737c..4b74aec 100644 --- a/crates/socket-patch-core/src/patch/vendor/state.rs +++ b/crates/socket-patch-core/src/patch/vendor/state.rs @@ -407,7 +407,10 @@ mod tests { assert!(!text.contains("tookOverGoPatches")); assert!(!text.contains("\"flavor\"")); for absent in ["\"uv\"", "\"pnpm\"", "\"poetry\"", "\"pdm\"", "\"pipenv\""] { - assert!(!text.contains(absent), "{absent} must not serialize when None"); + assert!( + !text.contains(absent), + "{absent} must not serialize when None" + ); } assert!(text.contains("\"basePurl\""), "camelCase keys: {text}"); } @@ -441,7 +444,9 @@ mod tests { let loaded = load_state(root).await.unwrap(); assert_eq!(loaded, state, "every meta survives the round trip"); - let text = tokio::fs::read_to_string(root.join(VENDOR_STATE_REL)).await.unwrap(); + let text = tokio::fs::read_to_string(root.join(VENDOR_STATE_REL)) + .await + .unwrap(); // camelCase keys on the wire. for key in [ "\"createdOverridesTable\"", @@ -453,7 +458,10 @@ mod tests { assert!(text.contains(key), "{key} missing: {text}"); } // Skip-empty inner fields: the false bool and any empty vec vanish. - assert!(!text.contains("createdPnpmTable"), "false bool omitted: {text}"); + assert!( + !text.contains("createdPnpmTable"), + "false bool omitted: {text}" + ); } #[test] @@ -465,7 +473,10 @@ mod tests { .unwrap(); assert_eq!(pnpm, "{}", "all-default PnpmMeta serializes empty"); - let pipenv = serde_json::to_string(&PipenvMeta { sections: Vec::new() }).unwrap(); + let pipenv = serde_json::to_string(&PipenvMeta { + sections: Vec::new(), + }) + .unwrap(); assert_eq!(pipenv, "{}", "empty sections omitted"); let pdm = serde_json::to_string(&PdmMeta { @@ -480,7 +491,10 @@ mod tests { let back: PnpmMeta = serde_json::from_str("{}").unwrap(); assert_eq!( back, - PnpmMeta { created_overrides_table: false, created_pnpm_table: false } + PnpmMeta { + created_overrides_table: false, + created_pnpm_table: false + } ); let back: PipenvMeta = serde_json::from_str("{}").unwrap(); assert!(back.sections.is_empty()); @@ -492,8 +506,12 @@ mod tests { let root = tmp.path(); assert!(load_state(root).await.unwrap().is_empty()); - tokio::fs::create_dir_all(root.join(".socket/vendor")).await.unwrap(); - tokio::fs::write(root.join(VENDOR_STATE_REL), b"{not json").await.unwrap(); + tokio::fs::create_dir_all(root.join(".socket/vendor")) + .await + .unwrap(); + tokio::fs::write(root.join(VENDOR_STATE_REL), b"{not json") + .await + .unwrap(); let err = load_state(root).await.unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); } @@ -523,13 +541,18 @@ mod tests { .entries .insert("pkg:npm/lodash@4.17.21".into(), sample_entry()); save_state(root, &state).await.unwrap(); - tokio::fs::create_dir_all(root.join(".socket/vendor/npm")).await.unwrap(); + tokio::fs::create_dir_all(root.join(".socket/vendor/npm")) + .await + .unwrap(); tokio::fs::write(root.join(".socket/vendor/npm/stray.tgz"), b"x") .await .unwrap(); state.entries.clear(); save_state(root, &state).await.unwrap(); - assert!(root.join(".socket/vendor/npm").exists(), "non-empty dir kept"); + assert!( + root.join(".socket/vendor/npm").exists(), + "non-empty dir kept" + ); } #[tokio::test] diff --git a/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs b/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs index e18da78..f3f5ee1 100644 --- a/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs +++ b/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs @@ -237,24 +237,29 @@ mod tests { fn find_unit_span_selects_the_matching_package_unit() { // The first unit includes its [package.*] sub-table but not the // trailing blank separator. - let span = find_unit_span(LOCK, |lines| { - lines.iter().any(|l| *l == "name = \"proj\"") - }) - .unwrap(); + let span = + find_unit_span(LOCK, |lines| lines.iter().any(|l| *l == "name = \"proj\"")).unwrap(); let unit = &LOCK[span]; assert!(unit.starts_with("[[package]]")); assert!(unit.contains("[package.metadata]"), "sub-table included"); - assert!(unit.ends_with("requires-dist = [{ name = \"six\" }]"), "no trailing blank: {unit:?}"); + assert!( + unit.ends_with("requires-dist = [{ name = \"six\" }]"), + "no trailing blank: {unit:?}" + ); // The second (last) unit ends at the last non-blank line. - let span = find_unit_span(LOCK, |lines| { - lines.iter().any(|l| *l == "name = \"six\"") - }) - .unwrap(); - assert_eq!(&LOCK[span], "[[package]]\nname = \"six\"\nversion = \"1.16.0\""); + let span = + find_unit_span(LOCK, |lines| lines.iter().any(|l| *l == "name = \"six\"")).unwrap(); + assert_eq!( + &LOCK[span], + "[[package]]\nname = \"six\"\nversion = \"1.16.0\"" + ); // No match → None. - assert!(find_unit_span(LOCK, |lines| lines.iter().any(|l| *l == "name = \"absent\"")).is_none()); + assert!(find_unit_span(LOCK, |lines| lines + .iter() + .any(|l| *l == "name = \"absent\"")) + .is_none()); } #[test] @@ -289,7 +294,11 @@ mod tests { Some("b\na\n"), "only the FIRST exact match is removed; trailing newline kept" ); - assert_eq!(remove_exact_line("a\nb\n", "ab"), None, "no partial-line matches"); + assert_eq!( + remove_exact_line("a\nb\n", "ab"), + None, + "no partial-line matches" + ); // Empty section: header + preceding blank dropped. assert_eq!( diff --git a/crates/socket-patch-core/src/patch/vendor/verify.rs b/crates/socket-patch-core/src/patch/vendor/verify.rs index b207dc5..b98d2e1 100644 --- a/crates/socket-patch-core/src/patch/vendor/verify.rs +++ b/crates/socket-patch-core/src/patch/vendor/verify.rs @@ -264,21 +264,31 @@ mod tests { let rel = format!(".socket/vendor/cargo/{UUID}/serde-1.0.0"); let dir = root.join(&rel); tokio::fs::create_dir_all(dir.join("src")).await.unwrap(); - tokio::fs::write(dir.join("src/lib.rs"), PATCHED).await.unwrap(); + tokio::fs::write(dir.join("src/lib.rs"), PATCHED) + .await + .unwrap(); let rec = record(UUID, "src/lib.rs"); let ent = entry("cargo", UUID, &rel); assert!(verify_vendored_patch_record(root, &ent, &rec).await.is_ok()); - tokio::fs::write(dir.join("src/lib.rs"), b"tampered").await.unwrap(); + tokio::fs::write(dir.join("src/lib.rs"), b"tampered") + .await + .unwrap(); assert_eq!( - verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), "vendor_hash_mismatch" ); - tokio::fs::remove_file(dir.join("src/lib.rs")).await.unwrap(); + tokio::fs::remove_file(dir.join("src/lib.rs")) + .await + .unwrap(); assert_eq!( - verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), "file_not_found" ); } @@ -301,21 +311,29 @@ mod tests { // One tampered byte inside the archive flips the verdict. write_tgz(&root.join(&rel), "package/index.js", b"tampered"); assert_eq!( - verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), "vendor_hash_mismatch" ); // Member missing entirely. write_tgz(&root.join(&rel), "package/other.js", PATCHED); assert_eq!( - verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), "file_not_found" ); // Truncated/corrupt gzip is unreadable, not a crash. - tokio::fs::write(root.join(&rel), b"\x1f\x8b00garbage").await.unwrap(); + tokio::fs::write(root.join(&rel), b"\x1f\x8b00garbage") + .await + .unwrap(); assert_eq!( - verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), "vendor_artifact_unreadable" ); } @@ -336,7 +354,9 @@ mod tests { write_whl(&root.join(&rel), "six.py", b"tampered"); assert_eq!( - verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), "vendor_hash_mismatch" ); } @@ -352,7 +372,9 @@ mod tests { rec.files.clear(); let ent = entry("npm", UUID, &rel); assert_eq!( - verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), "no_files" ); @@ -368,7 +390,9 @@ mod tests { ] { let ent = entry("npm", UUID, bad); assert_eq!( - verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), "vendor_path_unsafe", "path {bad} must be rejected" ); @@ -389,7 +413,9 @@ mod tests { let ent = entry("npm", UUID, &rel); let rec = record(UUID, "package/index.js"); assert_eq!( - verify_vendored_patch_record(root, &ent, &rec).await.unwrap_err(), + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), "vendor_artifact_missing" ); } diff --git a/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs index ac1eb17..96ddda3 100644 --- a/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs @@ -39,9 +39,7 @@ use crate::patch::copy_tree::remove_tree; use crate::utils::fs::atomic_write_bytes; use super::berry_zip::berry_cache_checksum_10c0; -use super::npm_common::{ - done_failure, guard_coordinates, refused, stage_patch_pack, tgz_rel_leaf, -}; +use super::npm_common::{done_failure, guard_coordinates, refused, stage_patch_pack, tgz_rel_leaf}; use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, @@ -205,7 +203,9 @@ pub async fn vendor_yarn_berry( ); }; for (selector, value) in res_obj { - let sel_name = split_pattern(selector).map(|(n, _)| n).unwrap_or(selector.as_str()); + let sel_name = split_pattern(selector) + .map(|(n, _)| n) + .unwrap_or(selector.as_str()); if sel_name != name { continue; } @@ -262,7 +262,11 @@ pub async fn vendor_yarn_berry( }; let Some(staged) = staged else { // Failed patch (wiring is last — project byte-untouched) or dry run. - return VendorOutcome::Done { result, entry: None, warnings }; + return VendorOutcome::Done { + result, + entry: None, + warnings, + }; }; debug_assert_eq!(staged.rel_tgz, rel_tgz); let packed = staged.packed; @@ -290,8 +294,7 @@ pub async fn vendor_yarn_berry( // ── 9. The replacement lock entry (verbatim B3 shape) ───────────────── let locator = encode_uri_component(&format!("{workspace}@workspace:.")); let lock_key = format!("\"{name}@file:./{rel_tgz}::locator={locator}\""); - let resolution = - format!("{name}@file:./{rel_tgz}#./{rel_tgz}::hash={hash6}&locator={locator}"); + let resolution = format!("{name}@file:./{rel_tgz}#./{rel_tgz}::hash={hash6}&locator={locator}"); // Sections beyond the five we own (dependencies:, peerDependencies:, // bin:, …) describe the same package version and carry over verbatim. let carried = carried_sections(&target.lines); @@ -314,7 +317,11 @@ pub async fn vendor_yarn_berry( .and_then(Value::as_str) == Some(spec.as_str()); if pkg_in_sync && target.is_ours && target.lines == new_lines { - let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + let verified = record + .files + .keys() + .map(|f| already_patched_verify(f)) + .collect(); return VendorOutcome::Done { result: synthesized_result(purl, &dest, verified, true, None), entry: None, @@ -353,8 +360,13 @@ pub async fn vendor_yarn_berry( }; replace_block(&lock_text, block, &new_lines, detect_eol(&lock_text)) }; - if let Err(e) = - commit_pair(project_root, &new_pkg_bytes, &pkg_bytes, new_lock_text.as_bytes()).await + if let Err(e) = commit_pair( + project_root, + &new_pkg_bytes, + &pkg_bytes, + new_lock_text.as_bytes(), + ) + .await { return done_failure(purl, e); } @@ -384,7 +396,11 @@ pub async fn vendor_yarn_berry( // Rewritten only when replacing our own stale entry — and then // there is deliberately no `original` (never record our own edit // as a pre-vendor fragment). - action: if existing_entry { WiringAction::Rewritten } else { WiringAction::Added }, + action: if existing_entry { + WiringAction::Rewritten + } else { + WiringAction::Added + }, key: Some(name.to_string()), original: None, new: Some(Value::String(spec)), @@ -394,7 +410,11 @@ pub async fn vendor_yarn_berry( kind: KIND_LOCK_ENTRY.to_string(), action: WiringAction::Rewritten, key: Some(lock_key), - original: if target.is_ours { None } else { Some(lines_to_json(&target.lines)) }, + original: if target.is_ours { + None + } else { + Some(lines_to_json(&target.lines)) + }, new: Some(lines_to_json(&new_lines)), }, ]; @@ -418,7 +438,11 @@ pub async fn vendor_yarn_berry( pdm: None, pipenv: None, }; - VendorOutcome::Done { result, entry: Some(entry), warnings } + VendorOutcome::Done { + result, + entry: Some(entry), + warnings, + } } /// Undo one yarn-berry vendored package: restore the recorded lock entry, @@ -687,7 +711,9 @@ async fn commit_pair( .map_err(|e| format!("cannot write {PACKAGE_JSON}: {e}"))?; if let Err(e) = atomic_write_bytes(&project_root.join(YARN_LOCK), new_lock).await { return match atomic_write_bytes(&pkg_path, orig_pkg).await { - Ok(()) => Err(format!("cannot write {YARN_LOCK}: {e} ({PACKAGE_JSON} restored)")), + Ok(()) => Err(format!( + "cannot write {YARN_LOCK}: {e} ({PACKAGE_JSON} restored)" + )), Err(e2) => Err(format!( "cannot write {YARN_LOCK}: {e} — and restoring {PACKAGE_JSON} failed too: \ {e2}; restore {PACKAGE_JSON} from version control" @@ -720,8 +746,7 @@ fn scan_berry_target( continue; } let patterns = split_key_patterns(&block.key); - let parsed: Vec<(&str, &str)> = - patterns.iter().filter_map(|p| split_pattern(p)).collect(); + let parsed: Vec<(&str, &str)> = patterns.iter().filter_map(|p| split_pattern(p)).collect(); if parsed.len() != patterns.len() || parsed.is_empty() { continue; // not a descriptor key we understand; not ours to touch } @@ -786,8 +811,10 @@ fn scan_berry_target( 1 => Ok(found.into_iter().next()), _ => Err(( "vendor_override_conflict", - format!("multiple yarn.lock entries resolve {name}@{version}; refusing the \ - ambiguous rewrite"), + format!( + "multiple yarn.lock entries resolve {name}@{version}; refusing the \ + ambiguous rewrite" + ), )), } } @@ -815,7 +842,13 @@ fn build_entry_lines( /// Body sections of a lock entry that are NOT the five scalar fields we own /// — dependency sub-maps, bin:, conditions:, … — verbatim, in order. fn carried_sections(lines: &[String]) -> Vec { - const OWNED: [&str; 5] = ["version", "resolution", "checksum", "languageName", "linkType"]; + const OWNED: [&str; 5] = [ + "version", + "resolution", + "checksum", + "languageName", + "linkType", + ]; let mut out = Vec::new(); let mut i = 1; while i < lines.len() { @@ -843,9 +876,15 @@ fn carried_sections(lines: &[String]) -> Vec { /// Read a berry scalar field (`: `, value possibly quoted). fn berry_field<'a>(lines: &'a [String], field: &str) -> Option<&'a str> { for line in lines.iter().skip(1) { - let Some(rest) = body_field_line(line) else { continue }; - let Some(value) = rest.strip_prefix(field) else { continue }; - let Some(value) = value.strip_prefix(':') else { continue }; + let Some(rest) = body_field_line(line) else { + continue; + }; + let Some(value) = rest.strip_prefix(field) else { + continue; + }; + let Some(value) = value.strip_prefix(':') else { + continue; + }; return Some(value.trim().trim_matches('"')); } None @@ -1008,7 +1047,8 @@ __metadata: const SPIKE_HASH6: &str = "39ea9b"; const SPIKE_CHECKSUM: &str = "10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3"; - const YARNRC_DEFAULT: &str = "nodeLinker: node-modules\nenableGlobalCache: true\nenableTelemetry: false\n"; + const YARNRC_DEFAULT: &str = + "nodeLinker: node-modules\nenableGlobalCache: true\nenableTelemetry: false\n"; fn spike_after_lock(hash6: &str, checksum: &str) -> String { B3_AFTER_LOCK @@ -1073,8 +1113,14 @@ __metadata: } async fn assert_untouched(&self) { - assert_eq!(tokio::fs::read(self.pkg_path()).await.unwrap(), self.pkg_bytes); - assert_eq!(tokio::fs::read(self.lock_path()).await.unwrap(), self.lock_bytes); + assert_eq!( + tokio::fs::read(self.pkg_path()).await.unwrap(), + self.pkg_bytes + ); + assert_eq!( + tokio::fs::read(self.lock_path()).await.unwrap(), + self.lock_bytes + ); assert!(!self.root().join(".socket/vendor").exists()); } } @@ -1091,16 +1137,26 @@ __metadata: ) .await .unwrap(); - tokio::fs::write(installed.join("index.js"), ORIG_INDEX).await.unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX) + .await + .unwrap(); let blobs = root.join(".socket/blobs"); tokio::fs::create_dir_all(&blobs).await.unwrap(); let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); - tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX).await.unwrap(); + tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX) + .await + .unwrap(); - tokio::fs::write(root.join(PACKAGE_JSON), pkg.as_bytes()).await.unwrap(); - tokio::fs::write(root.join(YARN_LOCK), lock.as_bytes()).await.unwrap(); - tokio::fs::write(root.join(YARNRC), YARNRC_DEFAULT).await.unwrap(); + tokio::fs::write(root.join(PACKAGE_JSON), pkg.as_bytes()) + .await + .unwrap(); + tokio::fs::write(root.join(YARN_LOCK), lock.as_bytes()) + .await + .unwrap(); + tokio::fs::write(root.join(YARNRC), YARNRC_DEFAULT) + .await + .unwrap(); let mut files = HashMap::new(); files.insert( @@ -1136,7 +1192,11 @@ __metadata: outcome: VendorOutcome, ) -> (ApplyResult, Option, Vec) { match outcome { - VendorOutcome::Done { result, entry, warnings } => (result, entry, warnings), + VendorOutcome::Done { + result, + entry, + warnings, + } => (result, entry, warnings), VendorOutcome::Refused { code, detail } => { panic!("expected Done, got Refused {code}: {detail}") } @@ -1150,7 +1210,10 @@ __metadata: detail } VendorOutcome::Done { result, .. } => { - panic!("expected Refused {want_code}, got Done (success={})", result.success) + panic!( + "expected Refused {want_code}, got Done (success={})", + result.success + ) } } } @@ -1181,7 +1244,10 @@ __metadata: assert_eq!(entry.flavor.as_deref(), Some("yarn-berry")); assert_eq!(entry.wiring.len(), 2); let pkg_rec = &entry.wiring[0]; - assert_eq!((pkg_rec.file.as_str(), pkg_rec.kind.as_str()), (PACKAGE_JSON, KIND_RESOLUTION)); + assert_eq!( + (pkg_rec.file.as_str(), pkg_rec.kind.as_str()), + (PACKAGE_JSON, KIND_RESOLUTION) + ); assert_eq!(pkg_rec.action, WiringAction::Added); assert_eq!(pkg_rec.key.as_deref(), Some("left-pad")); assert_eq!( @@ -1191,7 +1257,10 @@ __metadata: ))) ); let lock_rec = &entry.wiring[1]; - assert_eq!((lock_rec.file.as_str(), lock_rec.kind.as_str()), (YARN_LOCK, KIND_LOCK_ENTRY)); + assert_eq!( + (lock_rec.file.as_str(), lock_rec.kind.as_str()), + (YARN_LOCK, KIND_LOCK_ENTRY) + ); assert_eq!(lock_rec.action, WiringAction::Rewritten); assert_eq!( lock_rec.key.as_deref(), @@ -1214,11 +1283,16 @@ __metadata: // Artifact facts + marker. let tgz = tokio::fs::read(fx.tgz_path()).await.unwrap(); - assert_eq!(entry.artifact.sha256, hex::encode(sha2::Sha256::digest(&tgz))); + assert_eq!( + entry.artifact.sha256, + hex::encode(sha2::Sha256::digest(&tgz)) + ); assert_eq!(entry.artifact.size, Some(tgz.len() as u64)); assert!(fx .root() - .join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")) + .join(format!( + ".socket/vendor/npm/{UUID}/socket-patch.vendor.json" + )) .exists()); } @@ -1226,8 +1300,14 @@ __metadata: async fn non_10c0_cache_key_is_refused_before_any_write() { let lock = B3_BEFORE_LOCK.replace("cacheKey: 10c0", "cacheKey: 10"); let fx = fixture_with(B3_BEFORE_PKG, &lock).await; - let detail = expect_refused(fx.vendor(false).await, "vendor_yarn_berry_cache_unsupported"); - assert!(detail.contains("`10`"), "names the found cacheKey: {detail}"); + let detail = expect_refused( + fx.vendor(false).await, + "vendor_yarn_berry_cache_unsupported", + ); + assert!( + detail.contains("`10`"), + "names the found cacheKey: {detail}" + ); fx.assert_untouched().await; } @@ -1240,8 +1320,14 @@ __metadata: ) .await .unwrap(); - let detail = expect_refused(fx.vendor(false).await, "vendor_yarn_berry_cache_unsupported"); - assert!(detail.contains("compressionLevel"), "names the knob: {detail}"); + let detail = expect_refused( + fx.vendor(false).await, + "vendor_yarn_berry_cache_unsupported", + ); + assert!( + detail.contains("compressionLevel"), + "names the knob: {detail}" + ); fx.assert_untouched().await; // An explicit `compressionLevel: 0` (the default) is fine. @@ -1283,7 +1369,10 @@ __metadata: ); let fx = fixture_with(B3_BEFORE_PKG, &lock).await; let detail = expect_refused(fx.vendor(false).await, "vendor_override_conflict"); - assert!(detail.contains("1.2.0"), "names the other version: {detail}"); + assert!( + detail.contains("1.2.0"), + "names the other version: {detail}" + ); fx.assert_untouched().await; } @@ -1298,7 +1387,10 @@ __metadata: let (result, entry, warnings) = expect_done(fx.vendor(false).await); assert!(result.success, "{:?}", result.error); - assert!(entry.is_none(), "in-sync re-run must not produce a new ledger entry"); + assert!( + entry.is_none(), + "in-sync re-run must not produce a new ledger entry" + ); assert!(warnings.is_empty(), "{warnings:?}"); assert!( result @@ -1322,7 +1414,9 @@ __metadata: assert!(result.files_patched.is_empty()); fx.assert_untouched().await; assert_eq!( - tokio::fs::read(fx.installed().join("index.js")).await.unwrap(), + tokio::fs::read(fx.installed().join("index.js")) + .await + .unwrap(), ORIG_INDEX, "vendor never patches the installed copy in place" ); @@ -1345,13 +1439,18 @@ __metadata: .unwrap(); fx.record.files.insert( "package/package.json".to_string(), - PatchFileInfo { before_hash: compute_git_sha256_from_bytes(before), after_hash }, + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(before), + after_hash, + }, ); let (result, _, warnings) = expect_done(fx.vendor(false).await); assert!(result.success, "{:?}", result.error); assert!( - warnings.iter().any(|w| w.code == "vendor_dep_manifest_stale"), + warnings + .iter() + .any(|w| w.code == "vendor_dep_manifest_stale"), "{warnings:?}" ); @@ -1369,7 +1468,9 @@ __metadata: async fn commit_pair_unwinds_package_json_when_the_lock_write_fails() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - tokio::fs::write(root.join(PACKAGE_JSON), b"orig-pkg").await.unwrap(); + tokio::fs::write(root.join(PACKAGE_JSON), b"orig-pkg") + .await + .unwrap(); // A directory at the lock path makes the atomic rename fail. tokio::fs::create_dir(root.join(YARN_LOCK)).await.unwrap(); @@ -1408,7 +1509,10 @@ __metadata: fx.lock_bytes, "yarn.lock restored byte-for-byte" ); - assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists()); } #[tokio::test] @@ -1438,7 +1542,10 @@ __metadata: let outcome = revert_yarn_berry(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), "{:?}", outcome.warnings ); @@ -1516,11 +1623,18 @@ __metadata: .iter() .filter(|w| w.detail.contains("allowlist")) .count(); - assert_eq!(allow, 2, "every foreign file warned: {:?}", outcome.warnings); + assert_eq!( + allow, 2, + "every foreign file warned: {:?}", + outcome.warnings + ); // The legitimate records still reverted both files; the foreign // paths were never created or touched. assert_eq!(tokio::fs::read(fx.pkg_path()).await.unwrap(), fx.pkg_bytes); - assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes + ); assert!(!fx.root().join("Cargo.toml").exists()); assert!(!fx.root().parent().unwrap().join("x").exists()); } @@ -1549,12 +1663,21 @@ __metadata: // Root workspace name extraction + berry field reads. let blocks = scan_blocks(B3_BEFORE_LOCK); - assert_eq!(root_workspace_name(&blocks).as_deref(), Some("vendor-spike")); + assert_eq!( + root_workspace_name(&blocks).as_deref(), + Some("vendor-spike") + ); let meta = blocks.iter().find(|b| b.key == "__metadata").unwrap(); assert_eq!(berry_field(&meta.lines, "cacheKey"), Some("10c0")); - let lp = blocks.iter().find(|b| b.key == "\"left-pad@npm:1.3.0\"").unwrap(); + let lp = blocks + .iter() + .find(|b| b.key == "\"left-pad@npm:1.3.0\"") + .unwrap(); assert_eq!(berry_field(&lp.lines, "version"), Some("1.3.0")); - assert_eq!(berry_field(&lp.lines, "resolution"), Some("left-pad@npm:1.3.0")); + assert_eq!( + berry_field(&lp.lines, "resolution"), + Some("left-pad@npm:1.3.0") + ); // Carried sections: dep sub-maps survive, owned scalars do not. let lines: Vec = [ @@ -1572,7 +1695,10 @@ __metadata: .collect(); assert_eq!( carried_sections(&lines), - vec![" dependencies:".to_string(), " wow: \"npm:^1.0.0\"".to_string()] + vec![ + " dependencies:".to_string(), + " wow: \"npm:^1.0.0\"".to_string() + ] ); } } diff --git a/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs index 825c18d..5635d37 100644 --- a/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs @@ -143,7 +143,11 @@ pub async fn vendor_yarn_classic( }; let Some(staged) = staged else { // Failed patch (no lock writes — wiring is last) or a dry run. - return VendorOutcome::Done { result, entry: None, warnings }; + return VendorOutcome::Done { + result, + entry: None, + warnings, + }; }; let rel_tgz = staged.rel_tgz; let packed = staged.packed; @@ -184,7 +188,11 @@ pub async fn vendor_yarn_classic( kind: KIND_LOCK_BLOCK.to_string(), action: WiringAction::Rewritten, key: Some(key.clone()), - original: if was_vendored { None } else { Some(lines_to_json(&block.lines)) }, + original: if was_vendored { + None + } else { + Some(lines_to_json(&block.lines)) + }, new: Some(lines_to_json(&new_lines)), }; Some((replace_block(&new_text, block, &new_lines, eol), rec)) @@ -213,7 +221,11 @@ pub async fn vendor_yarn_classic( // Every block already points at this uuid with the packed hashes: // in sync. Touch nothing (the tarball re-pack above was // byte-identical by determinism) and synthesize AlreadyPatched. - let verified = record.files.keys().map(|f| already_patched_verify(f)).collect(); + let verified = record + .files + .keys() + .map(|f| already_patched_verify(f)) + .collect(); return VendorOutcome::Done { result: synthesized_result(purl, &dest, verified, true, None), entry: None, @@ -263,7 +275,11 @@ pub async fn vendor_yarn_classic( pdm: None, pipenv: None, }; - VendorOutcome::Done { result, entry: Some(entry), warnings } + VendorOutcome::Done { + result, + entry: Some(entry), + warnings, + } } /// Undo one yarn-classic vendored package: restore the recorded lock blocks @@ -324,7 +340,13 @@ pub async fn revert_yarn_classic( if let Some(mut text) = text { let mut changed = false; for rec in records { - revert_one_block(&mut text, rec, &entry.uuid, &mut changed, &mut outcome.warnings); + revert_one_block( + &mut text, + rec, + &entry.uuid, + &mut changed, + &mut outcome.warnings, + ); } if changed { if let Err(e) = atomic_write_bytes(&lock_path, text.as_bytes()).await { @@ -509,7 +531,9 @@ fn rewrite_classic_block( } if let Some(pkg) = staged_pkg { for field in ["dependencies", "optionalDependencies"] { - let Some(map) = pkg.get(field).and_then(Value::as_object) else { continue }; + let Some(map) = pkg.get(field).and_then(Value::as_object) else { + continue; + }; if map.is_empty() { continue; } @@ -630,7 +654,12 @@ pub(super) fn replace_block( if block.terminated { replacement.push_str(eol); } - format!("{}{}{}", &text[..block.start], replacement, &text[block.end..]) + format!( + "{}{}{}", + &text[..block.start], + replacement, + &text[block.end..] + ) } /// A 2-space body field line (`version "1.3.0"` / `resolution: "..."`), @@ -646,9 +675,15 @@ pub(super) fn body_field_line(line: &str) -> Option<&str> { /// Read a classic scalar field (` ""`, integrity unquoted). pub(super) fn classic_field<'a>(lines: &'a [String], field: &str) -> Option<&'a str> { for line in lines.iter().skip(1) { - let Some(rest) = body_field_line(line) else { continue }; - let Some(value) = rest.strip_prefix(field) else { continue }; - let Some(value) = value.strip_prefix(' ') else { continue }; + let Some(rest) = body_field_line(line) else { + continue; + }; + let Some(value) = rest.strip_prefix(field) else { + continue; + }; + let Some(value) = value.strip_prefix(' ') else { + continue; + }; return Some(value.trim().trim_matches('"')); } None @@ -932,14 +967,20 @@ left-pad@^1.3.0, left-pad@~1.3.0: ) .await .unwrap(); - tokio::fs::write(installed.join("index.js"), ORIG_INDEX).await.unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX) + .await + .unwrap(); let blobs = root.join(".socket/blobs"); tokio::fs::create_dir_all(&blobs).await.unwrap(); let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); - tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX).await.unwrap(); + tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX) + .await + .unwrap(); - tokio::fs::write(root.join(YARN_LOCK), lock_text.as_bytes()).await.unwrap(); + tokio::fs::write(root.join(YARN_LOCK), lock_text.as_bytes()) + .await + .unwrap(); let mut files = HashMap::new(); files.insert( @@ -959,14 +1000,22 @@ left-pad@^1.3.0, left-pad@~1.3.0: tier: "free".to_string(), }; - Fixture { tmp, record, lock_bytes: lock_text.as_bytes().to_vec() } + Fixture { + tmp, + record, + lock_bytes: lock_text.as_bytes().to_vec(), + } } fn expect_done( outcome: VendorOutcome, ) -> (ApplyResult, Option, Vec) { match outcome { - VendorOutcome::Done { result, entry, warnings } => (result, entry, warnings), + VendorOutcome::Done { + result, + entry, + warnings, + } => (result, entry, warnings), VendorOutcome::Refused { code, detail } => { panic!("expected Done, got Refused {code}: {detail}") } @@ -980,7 +1029,10 @@ left-pad@^1.3.0, left-pad@~1.3.0: detail } VendorOutcome::Done { result, .. } => { - panic!("expected Refused {want_code}, got Done (success={})", result.success) + panic!( + "expected Refused {want_code}, got Done (success={})", + result.success + ) } } } @@ -1006,7 +1058,10 @@ left-pad@^1.3.0, left-pad@~1.3.0: ); let tgz = tokio::fs::read(fx.tgz_path()).await.unwrap(); assert_eq!(entry.artifact.size, Some(tgz.len() as u64)); - assert_eq!(entry.artifact.sha256, hex::encode(sha2::Sha256::digest(&tgz))); + assert_eq!( + entry.artifact.sha256, + hex::encode(sha2::Sha256::digest(&tgz)) + ); assert_eq!(entry.wiring.len(), 1); let rec = &entry.wiring[0]; assert_eq!(rec.file, YARN_LOCK); @@ -1024,12 +1079,15 @@ left-pad@^1.3.0, left-pad@~1.3.0: "original must be the verbatim pre-vendor block" ); let new_lines = rec.new.as_ref().unwrap().as_array().unwrap(); - assert!(new_lines[2].as_str().unwrap().contains("file:./.socket/vendor/npm/")); + assert!(new_lines[2] + .as_str() + .unwrap() + .contains("file:./.socket/vendor/npm/")); // The marker sits next to the artifact. - let marker = tokio::fs::read_to_string( - fx.root().join(format!(".socket/vendor/npm/{UUID}/socket-patch.vendor.json")), - ) + let marker = tokio::fs::read_to_string(fx.root().join(format!( + ".socket/vendor/npm/{UUID}/socket-patch.vendor.json" + ))) .await .unwrap(); assert!(marker.contains("pkg:npm/left-pad@1.3.0")); @@ -1049,12 +1107,18 @@ left-pad@^1.3.0, left-pad@~1.3.0: assert_eq!(fx.lock_text().await, spike_after(Y5_AFTER, &sha1, &sri)); // One record per block: the alias block AND the merged block. - let mut keys: Vec<&str> = - entry.wiring.iter().map(|r| r.key.as_deref().unwrap()).collect(); + let mut keys: Vec<&str> = entry + .wiring + .iter() + .map(|r| r.key.as_deref().unwrap()) + .collect(); keys.sort_unstable(); assert_eq!( keys, - vec!["\"alias@npm:left-pad@^1.3.0\"", "left-pad@^1.3.0, left-pad@~1.3.0"], + vec![ + "\"alias@npm:left-pad@^1.3.0\"", + "left-pad@^1.3.0, left-pad@~1.3.0" + ], "verbatim key lines (no colon), quotes preserved" ); } @@ -1078,11 +1142,13 @@ left-pad@^1.3.0: let lines: Vec<&str> = text.lines().collect(); assert_eq!( lines[4], - format!( - " resolved \"file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz#{sha1}\"" - ) + format!(" resolved \"file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz#{sha1}\"") + ); + assert_eq!( + lines[5], + format!(" integrity {sri}"), + "integrity line gained" ); - assert_eq!(lines[5], format!(" integrity {sri}"), "integrity line gained"); // The record's original is the 3-line block, new is the 4-line one. let rec = &entry.unwrap().wiring[0]; @@ -1112,20 +1178,28 @@ left-pad@^1.3.0: .unwrap(); fx.record.files.insert( "package/package.json".to_string(), - PatchFileInfo { before_hash: compute_git_sha256_from_bytes(before), after_hash }, + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(before), + after_hash, + }, ); let (result, _, warnings) = expect_done(fx.vendor(false).await); assert!(result.success, "{:?}", result.error); assert!( - warnings.iter().any(|w| w.code == "vendor_dep_manifest_rewritten"), + warnings + .iter() + .any(|w| w.code == "vendor_dep_manifest_rewritten"), "{warnings:?}" ); let text = fx.lock_text().await; assert!(!text.contains("old-dep"), "stale sub-map dropped: {text}"); let want = " dependencies:\n wow \"^1.0.0\"\n optionalDependencies:\n \"@scope/opt\" \"^2.0.0\"\n"; - assert!(text.contains(want), "recomputed sub-maps (scoped key quoted): {text}"); + assert!( + text.contains(want), + "recomputed sub-maps (scoped key quoted): {text}" + ); } #[tokio::test] @@ -1138,7 +1212,10 @@ left-pad@^1.3.0: let (result, entry, warnings) = expect_done(fx.vendor(false).await); assert!(result.success); - assert!(entry.is_none(), "in-sync re-run must not produce a new ledger entry"); + assert!( + entry.is_none(), + "in-sync re-run must not produce a new ledger entry" + ); assert!(warnings.is_empty(), "{warnings:?}"); assert!( result @@ -1168,10 +1245,15 @@ left-pad@^1.3.0: assert!(entry.is_none()); assert!(result.files_patched.is_empty()); - assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes + ); assert!(!fx.root().join(".socket/vendor").exists()); assert_eq!( - tokio::fs::read(fx.installed().join("index.js")).await.unwrap(), + tokio::fs::read(fx.installed().join("index.js")) + .await + .unwrap(), ORIG_INDEX, "vendor never patches the installed copy in place" ); @@ -1190,10 +1272,16 @@ left-pad@^1.3.0: let fx = fixture_with_lock(&lock).await; let (result, entry, warnings) = expect_done(fx.vendor(false).await); assert!(result.success, "{:?}", result.error); - assert_eq!(entry.unwrap().wiring.len(), 1, "only the registry block rewritten"); + assert_eq!( + entry.unwrap().wiring.len(), + 1, + "only the registry block rewritten" + ); - let link_warnings: Vec<&VendorWarning> = - warnings.iter().filter(|w| w.code == "vendor_link_entry_skipped").collect(); + let link_warnings: Vec<&VendorWarning> = warnings + .iter() + .filter(|w| w.code == "vendor_link_entry_skipped") + .collect(); assert_eq!(link_warnings.len(), 2, "{warnings:?}"); // Skipped blocks byte-untouched. @@ -1208,15 +1296,27 @@ left-pad@^1.3.0: let lock = Y2_BEFORE.replace("1.3.0", "1.2.0"); let fx = fixture_with_lock(&lock).await; let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); - assert!(detail.contains("yarn install"), "actionable detail: {detail}"); - assert!(!fx.root().join(".socket/vendor").exists(), "refusal writes nothing"); - assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + assert!( + detail.contains("yarn install"), + "actionable detail: {detail}" + ); + assert!( + !fx.root().join(".socket/vendor").exists(), + "refusal writes nothing" + ); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes + ); } #[tokio::test] async fn berry_lock_and_missing_lock_are_refused() { let fx = fixture_with_lock("__metadata:\n version: 8\n cacheKey: 10c0\n").await; - expect_refused(fx.vendor(false).await, "vendor_lockfile_version_unsupported"); + expect_refused( + fx.vendor(false).await, + "vendor_lockfile_version_unsupported", + ); let fx = fixture_with_lock(Y2_BEFORE).await; tokio::fs::remove_file(fx.lock_path()).await.unwrap(); @@ -1235,7 +1335,10 @@ left-pad@^1.3.0: let outcome = revert_yarn_classic(&entry, fx.root(), true).await; assert!(outcome.success); assert!(fx.tgz_path().exists()); - assert_ne!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + assert_ne!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes + ); let outcome = revert_yarn_classic(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); @@ -1245,7 +1348,10 @@ left-pad@^1.3.0: fx.lock_bytes, "lock restored byte-for-byte" ); - assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists()); } #[tokio::test] @@ -1257,9 +1363,8 @@ left-pad@^1.3.0: // The user re-resolved the ALIAS block (first occurrence of our // resolved line) behind our back. let (sha1, _) = fx.packed_hashes().await; - let ours = format!( - " resolved \"file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz#{sha1}\"" - ); + let ours = + format!(" resolved \"file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz#{sha1}\""); let theirs = " resolved \"https://example.com/their-fork.tgz#0000000000000000000000000000000000000000\""; let text = fx.lock_text().await.replacen(&ours, theirs, 1); tokio::fs::write(fx.lock_path(), text).await.unwrap(); @@ -1267,7 +1372,10 @@ left-pad@^1.3.0: let outcome = revert_yarn_classic(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.iter().any(|w| w.code == "vendor_lock_entry_drifted"), + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), "{:?}", outcome.warnings ); @@ -1278,7 +1386,10 @@ left-pad@^1.3.0: after.contains("left-pad@^1.3.0, left-pad@~1.3.0:\n version \"1.3.0\"\n resolved \"https://registry.yarnpkg.com/"), "non-drifted block restored: {after}" ); - assert!(!fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists()); + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists()); } #[tokio::test] @@ -1305,10 +1416,17 @@ left-pad@^1.3.0: .iter() .filter(|w| w.detail.contains("allowlist")) .count(); - assert_eq!(allow, 2, "every foreign file warned: {:?}", outcome.warnings); + assert_eq!( + allow, 2, + "every foreign file warned: {:?}", + outcome.warnings + ); // The legitimate record still restored the lock; nothing was written // to (or read from) the foreign paths. - assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes + ); assert!(!fx.root().join("package.json").exists()); assert!(!fx.root().parent().unwrap().join("x").exists()); } @@ -1333,7 +1451,10 @@ left-pad@^1.3.0: let (sha1, sri) = fx.packed_hashes().await; let expected = spike_after(Y2_AFTER, &sha1, &sri).replace('\n', "\r\n"); let text = fx.lock_text().await; - assert_eq!(text, expected, "every line (edited and untouched) stays CRLF"); + assert_eq!( + text, expected, + "every line (edited and untouched) stays CRLF" + ); assert_eq!( text.matches('\n').count(), text.matches("\r\n").count(), @@ -1368,8 +1489,14 @@ left-pad@^1.3.0: // Real-name extraction, incl. the alias-range and scoped forms. assert_eq!(pattern_real_name("left-pad@^1.3.0"), Some("left-pad")); assert_eq!(pattern_real_name("@scope/pkg@^1.0.0"), Some("@scope/pkg")); - assert_eq!(pattern_real_name("alias@npm:left-pad@^1.3.0"), Some("left-pad")); - assert_eq!(pattern_real_name("alias@npm:@scope/pkg@^1.0.0"), Some("@scope/pkg")); + assert_eq!( + pattern_real_name("alias@npm:left-pad@^1.3.0"), + Some("left-pad") + ); + assert_eq!( + pattern_real_name("alias@npm:@scope/pkg@^1.0.0"), + Some("@scope/pkg") + ); assert_eq!(pattern_real_name("alias@npm:left-pad"), Some("left-pad")); assert_eq!(pattern_real_name("no-at-sign"), None); diff --git a/crates/socket-patch-core/src/pth_hook/detect.rs b/crates/socket-patch-core/src/pth_hook/detect.rs index ba8d40b..8819165 100644 --- a/crates/socket-patch-core/src/pth_hook/detect.rs +++ b/crates/socket-patch-core/src/pth_hook/detect.rs @@ -14,7 +14,11 @@ pub const HOOK_DEP: &str = "socket-patch[hook]"; /// declared — the `socket-patch[hook]` extra, the standalone wheel, or the /// underscore spelling. (The Poetry `extras = ["hook"]` form is detected /// structurally by [`super::edit`], not by this textual check.) -const HOOK_MARKERS: &[&str] = &["socket-patch[hook]", "socket-patch-hook", "socket_patch_hook"]; +const HOOK_MARKERS: &[&str] = &[ + "socket-patch[hook]", + "socket-patch-hook", + "socket_patch_hook", +]; /// Which Python dependency-management style a project uses. Drives both which /// manifest/table `setup` edits and which lockfile (if any) to refresh. @@ -171,8 +175,7 @@ mod tests { // still detected (intra-line spaces tolerated). let requirements = "requests==2.31.0\nsocket-patch [hook]\nflask\n"; assert!(deps_contain_hook(requirements)); - let pyproject = - "dependencies = [\n \"requests\",\n \"socket-patch[hook]>=3.3.0\",\n]\n"; + let pyproject = "dependencies = [\n \"requests\",\n \"socket-patch[hook]>=3.3.0\",\n]\n"; assert!(deps_contain_hook(pyproject)); } @@ -204,7 +207,9 @@ mod tests { #[tokio::test] async fn test_detect_uv_by_lock() { let dir = tempfile::tempdir().unwrap(); - tokio::fs::write(dir.path().join("uv.lock"), "").await.unwrap(); + tokio::fs::write(dir.path().join("uv.lock"), "") + .await + .unwrap(); assert_eq!(detect_python_pm(dir.path()).await, PythonPackageManager::Uv); } @@ -229,12 +234,18 @@ mod tests { tokio::fs::write(dir.path().join("requirements.txt"), "requests\n") .await .unwrap(); - assert_eq!(detect_python_pm(dir.path()).await, PythonPackageManager::Pip); + assert_eq!( + detect_python_pm(dir.path()).await, + PythonPackageManager::Pip + ); } #[test] fn test_lock_command() { - assert_eq!(PythonPackageManager::Uv.lock_command(), Some(("uv", &["lock"][..]))); + assert_eq!( + PythonPackageManager::Uv.lock_command(), + Some(("uv", &["lock"][..])) + ); assert_eq!(PythonPackageManager::Pip.lock_command(), None); assert_eq!(PythonPackageManager::Hatch.lock_command(), None); } diff --git a/crates/socket-patch-core/src/pth_hook/edit.rs b/crates/socket-patch-core/src/pth_hook/edit.rs index ab2236a..4626877 100644 --- a/crates/socket-patch-core/src/pth_hook/edit.rs +++ b/crates/socket-patch-core/src/pth_hook/edit.rs @@ -119,8 +119,7 @@ pub async fn add_hook_dependency(path: &Path, kind: ManifestKind, dry_run: bool) // A missing requirements.txt is created (the pip-from-scratch path); // a missing pyproject.toml is an error (we don't synthesize one). Err(e) - if e.kind() == std::io::ErrorKind::NotFound - && kind == ManifestKind::Requirements => + if e.kind() == std::io::ErrorKind::NotFound && kind == ManifestKind::Requirements => { String::new() } @@ -286,7 +285,11 @@ fn pyproject_remove(content: &str) -> Result, String> { /// Ensure `parent[key]` is a table, creating it if absent. Errors if present /// but a non-table. -fn ensure_table<'a>(parent: &'a mut Table, key: &str, implicit: bool) -> Result<&'a mut Table, String> { +fn ensure_table<'a>( + parent: &'a mut Table, + key: &str, + implicit: bool, +) -> Result<&'a mut Table, String> { if !parent.contains_key(key) { let mut t = Table::new(); t.set_implicit(implicit); @@ -360,7 +363,10 @@ fn poetry_add(doc: &mut DocumentMut) -> Result { extras.push("hook"); tbl.insert("extras", Item::Value(Value::Array(extras))); } else { - let version = item.as_str().map(str::to_string).unwrap_or_else(|| "*".to_string()); + let version = item + .as_str() + .map(str::to_string) + .unwrap_or_else(|| "*".to_string()); deps.insert("socket-patch", Item::Value(hook_inline_table(&version))); } return Ok(true); @@ -389,7 +395,10 @@ fn poetry_remove(doc: &mut DocumentMut) -> bool { } // Strip the `hook` extra from a `socket-patch` dep table, leaving the rest // of the spec intact. - if let Some(tbl) = deps.get_mut("socket-patch").and_then(Item::as_table_like_mut) { + if let Some(tbl) = deps + .get_mut("socket-patch") + .and_then(Item::as_table_like_mut) + { if let Some(extras) = tbl.get_mut("extras").and_then(Item::as_array_mut) { let before = extras.len(); extras.retain(|v| v.as_str() != Some("hook")); @@ -507,7 +516,9 @@ mod tests { // The extra, the standalone wheel, and a pinned variant are all recognized. assert!(requirements_add("socket-patch[hook]\n").unwrap().is_none()); assert!(requirements_add("socket-patch-hook\n").unwrap().is_none()); - assert!(requirements_add("socket-patch-hook==3.3.0\n").unwrap().is_none()); + assert!(requirements_add("socket-patch-hook==3.3.0\n") + .unwrap() + .is_none()); } #[test] @@ -541,7 +552,9 @@ mod tests { let out = pyproject_add(toml).unwrap().unwrap(); let doc = out.parse::().unwrap(); let deps = doc["project"]["dependencies"].as_array().unwrap(); - assert!(deps.iter().any(|v| v.as_str() == Some("socket-patch[hook]"))); + assert!(deps + .iter() + .any(|v| v.as_str() == Some("socket-patch[hook]"))); } #[test] @@ -581,13 +594,16 @@ mod tests { #[test] fn test_poetry_merges_extra_into_existing_dep() { // An existing `socket-patch = "^3.3.0"` gains the hook extra, version kept. - let toml = "[tool.poetry]\nname = \"x\"\n[tool.poetry.dependencies]\nsocket-patch = \"^3.3.0\"\n"; + let toml = + "[tool.poetry]\nname = \"x\"\n[tool.poetry.dependencies]\nsocket-patch = \"^3.3.0\"\n"; let out = pyproject_add(toml).unwrap().unwrap(); let doc = out.parse::().unwrap(); let item = &doc["tool"]["poetry"]["dependencies"]["socket-patch"]; assert!(item_has_hook_extra(item), "hook extra must be added"); assert_eq!( - item.as_table_like().and_then(|t| t.get("version")).and_then(Item::as_str), + item.as_table_like() + .and_then(|t| t.get("version")) + .and_then(Item::as_str), Some("^3.3.0"), "existing version must be preserved" ); @@ -603,7 +619,9 @@ mod tests { let sp = &doc["tool"]["poetry"]["dependencies"]["socket-patch"]; assert!(item_has_hook_extra(sp), "hook extra must be added"); assert_eq!( - sp.as_table_like().and_then(|t| t.get("git")).and_then(Item::as_str), + sp.as_table_like() + .and_then(|t| t.get("git")) + .and_then(Item::as_str), Some("https://example.com/x.git"), "sub-table keys must survive" ); @@ -619,7 +637,9 @@ mod tests { assert!(!item_has_hook_extra( &doc["tool"]["poetry"]["dependencies"]["socket-patch"] )); - assert!(doc["tool"]["poetry"]["dependencies"].get("python").is_some()); + assert!(doc["tool"]["poetry"]["dependencies"] + .get("python") + .is_some()); } #[test] @@ -652,7 +672,10 @@ mod tests { item_has_hook_extra(&doc["tool"]["poetry"]["dependencies"]["socket-patch"]), "must edit the poetry table, not create [project].dependencies; got:\n{out}" ); - assert!(doc.get("project").and_then(|p| p.get("dependencies")).is_none()); + assert!(doc + .get("project") + .and_then(|p| p.get("dependencies")) + .is_none()); } #[test] @@ -739,7 +762,9 @@ mod tests { assert!(body.contains("[build-system]")); let deps = doc["project"]["dependencies"].as_array().unwrap(); assert!(deps.iter().any(|v| v.as_str() == Some("requests"))); - assert!(deps.iter().any(|v| v.as_str() == Some("socket-patch[hook]"))); + assert!(deps + .iter() + .any(|v| v.as_str() == Some("socket-patch[hook]"))); } #[tokio::test] @@ -753,10 +778,7 @@ mod tests { let res = remove_hook_dependency(&req, ManifestKind::Requirements, false).await; assert_eq!(res.status, PthStatus::Updated); assert_eq!(count_stage_litter(dir.path()).await, 0); - assert_eq!( - tokio::fs::read_to_string(&req).await.unwrap(), - "requests\n" - ); + assert_eq!(tokio::fs::read_to_string(&req).await.unwrap(), "requests\n"); } // ── structural hook detection (pyproject_contains_hook) ────────── @@ -821,7 +843,9 @@ mod tests { #[test] fn test_pyproject_contains_hook_malformed_falls_back_to_textual() { // Unparseable TOML: fall back to the textual probe rather than hard-fail. - assert!(pyproject_contains_hook("this = = not toml [[[ socket-patch[hook]")); + assert!(pyproject_contains_hook( + "this = = not toml [[[ socket-patch[hook]" + )); assert!(!pyproject_contains_hook("this = = not toml [[[ requests")); } diff --git a/crates/socket-patch-core/src/utils/cleanup_blobs.rs b/crates/socket-patch-core/src/utils/cleanup_blobs.rs index 309a858..9c30a55 100644 --- a/crates/socket-patch-core/src/utils/cleanup_blobs.rs +++ b/crates/socket-patch-core/src/utils/cleanup_blobs.rs @@ -210,7 +210,10 @@ mod tests { }, ); - PatchManifest { patches, setup: None } + PatchManifest { + patches, + setup: None, + } } #[tokio::test] diff --git a/crates/socket-patch-core/src/utils/telemetry.rs b/crates/socket-patch-core/src/utils/telemetry.rs index b3e908a..43c8872 100644 --- a/crates/socket-patch-core/src/utils/telemetry.rs +++ b/crates/socket-patch-core/src/utils/telemetry.rs @@ -1258,7 +1258,10 @@ mod tests { fn test_resolve_telemetry_endpoint_empty_strings_fall_back() { let (url, auth) = resolve_telemetry_endpoint(Some("tok"), Some("")); assert!(!auth, "empty slug must not authenticate"); - assert!(!url.contains("/orgs//"), "empty slug leaked into URL: {url}"); + assert!( + !url.contains("/orgs//"), + "empty slug leaked into URL: {url}" + ); assert!(url.ends_with("/patch/telemetry"), "got {url}"); let (_url, auth) = resolve_telemetry_endpoint(Some(""), Some("acme")); diff --git a/crates/socket-patch-core/src/vex/build.rs b/crates/socket-patch-core/src/vex/build.rs index db142a5..14bb7dd 100644 --- a/crates/socket-patch-core/src/vex/build.rs +++ b/crates/socket-patch-core/src/vex/build.rs @@ -66,8 +66,7 @@ pub fn build_document_with_vendored( opts: &BuildOptions, ) -> Option { let timestamp = now_rfc3339(); - let applied_set: std::collections::HashSet<&str> = - applied.iter().map(|s| s.as_str()).collect(); + let applied_set: std::collections::HashSet<&str> = applied.iter().map(|s| s.as_str()).collect(); let vendored_set: std::collections::HashSet<&str> = vendored.iter().map(|s| s.as_str()).collect(); @@ -88,11 +87,13 @@ pub fn build_document_with_vendored( } } entry.subcomponents.insert(purl.clone()); - entry.impact_parts.push(if vendored_set.contains(purl.as_str()) { - format!("Patched via Socket patch {} (vendored)", record.uuid) - } else { - format!("Patched via Socket patch {}", record.uuid) - }); + entry + .impact_parts + .push(if vendored_set.contains(purl.as_str()) { + format!("Patched via Socket patch {} (vendored)", record.uuid) + } else { + format!("Patched via Socket patch {}", record.uuid) + }); } } @@ -243,12 +244,8 @@ mod tests { "pkg:npm/lodash@4.0.0".to_string(), record("u1", vec![("GHSA-aaaa", vec!["CVE-2024-1"])]), ); - let doc = build_document( - &manifest, - &["pkg:npm/lodash@4.0.0".to_string()], - &opts(), - ) - .unwrap(); + let doc = + build_document(&manifest, &["pkg:npm/lodash@4.0.0".to_string()], &opts()).unwrap(); assert_eq!(doc.statements.len(), 1); let st = &doc.statements[0]; @@ -262,10 +259,7 @@ mod tests { assert_eq!(st.products.len(), 1); assert_eq!(st.products[0].id, "pkg:npm/app@1.0.0"); assert_eq!(st.products[0].subcomponents.len(), 1); - assert_eq!( - st.products[0].subcomponents[0].id, - "pkg:npm/lodash@4.0.0" - ); + assert_eq!(st.products[0].subcomponents[0].id, "pkg:npm/lodash@4.0.0"); assert!(st.impact_statement.as_ref().unwrap().contains("u1")); } @@ -274,13 +268,9 @@ mod tests { let mut manifest = PatchManifest::new(); manifest.patches.insert( "pkg:npm/x@1.0.0".to_string(), - record( - "u1", - vec![("GHSA-bbbb", vec!["CVE-2024-2", "CVE-2024-3"])], - ), + record("u1", vec![("GHSA-bbbb", vec!["CVE-2024-2", "CVE-2024-3"])]), ); - let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()) - .unwrap(); + let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()).unwrap(); let aliases = &doc.statements[0].vulnerability.aliases; assert_eq!(aliases.len(), 2); // Sorted for determinism. @@ -302,10 +292,7 @@ mod tests { let doc = build_document( &manifest, - &[ - "pkg:npm/x@1.0.0".to_string(), - "pkg:npm/y@2.0.0".to_string(), - ], + &["pkg:npm/x@1.0.0".to_string(), "pkg:npm/y@2.0.0".to_string()], &opts(), ) .unwrap(); @@ -329,15 +316,11 @@ mod tests { "pkg:npm/x@1.0.0".to_string(), record( "u1", - vec![ - ("GHSA-aaaa", vec!["CVE-1"]), - ("GHSA-bbbb", vec!["CVE-2"]), - ], + vec![("GHSA-aaaa", vec!["CVE-1"]), ("GHSA-bbbb", vec!["CVE-2"])], ), ); - let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()) - .unwrap(); + let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()).unwrap(); assert_eq!(doc.statements.len(), 2); // BTreeMap order → sorted by vuln id. assert_eq!(doc.statements[0].vulnerability.name, "GHSA-aaaa"); @@ -351,8 +334,7 @@ mod tests { "pkg:npm/x@1.0.0".to_string(), record("u1", vec![("GHSA-aaaa", vec![])]), ); - let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()) - .unwrap(); + let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()).unwrap(); assert_eq!(doc.context, OPENVEX_CONTEXT_V0_2_0); assert_eq!(doc.id, "urn:uuid:test"); assert_eq!(doc.author, "Socket"); @@ -398,10 +380,9 @@ mod tests { "pkg:npm/with-vuln@1.0.0".to_string(), record("u1", vec![("GHSA-aaaa", vec!["CVE-1"])]), ); - manifest.patches.insert( - "pkg:npm/no-vuln@2.0.0".to_string(), - record("u2", vec![]), - ); + manifest + .patches + .insert("pkg:npm/no-vuln@2.0.0".to_string(), record("u2", vec![])); let doc = build_document( &manifest, @@ -428,8 +409,7 @@ mod tests { "pkg:npm/x@1.0.0".to_string(), record("u1", vec![("GHSA-no-cves", vec![])]), ); - let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()) - .unwrap(); + let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()).unwrap(); assert_eq!(doc.statements[0].vulnerability.aliases.len(), 0); // Serialize and verify the JSON omits the `aliases` key. @@ -463,10 +443,7 @@ mod tests { let doc = build_document( &manifest, - &[ - "pkg:npm/x@1.0.0".to_string(), - "pkg:npm/y@2.0.0".to_string(), - ], + &["pkg:npm/x@1.0.0".to_string(), "pkg:npm/y@2.0.0".to_string()], &opts(), ) .unwrap(); @@ -503,10 +480,7 @@ mod tests { let doc = build_document( &manifest, - &[ - "pkg:npm/x@1.0.0".to_string(), - "pkg:npm/x@1.0.1".to_string(), - ], + &["pkg:npm/x@1.0.0".to_string(), "pkg:npm/x@1.0.1".to_string()], &opts(), ) .unwrap(); @@ -535,9 +509,7 @@ mod tests { author: "Socket".to_string(), tooling: None, }; - let doc = - build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts) - .unwrap(); + let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts).unwrap(); assert!(doc.tooling.is_none()); let v = serde_json::to_value(&doc).unwrap(); @@ -559,9 +531,7 @@ mod tests { author: String::new(), tooling: None, }; - let doc = - build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts) - .unwrap(); + let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts).unwrap(); assert_eq!(doc.author, ""); } @@ -587,10 +557,7 @@ mod tests { record("u2", vec![("GHSA-aaaa", vec!["CVE-3"])]), ); - let applied = vec![ - "pkg:npm/x@1.0.0".to_string(), - "pkg:npm/y@2.0.0".to_string(), - ]; + let applied = vec!["pkg:npm/x@1.0.0".to_string(), "pkg:npm/y@2.0.0".to_string()]; let a = build_document(&manifest, &applied, &opts()).unwrap(); let b = build_document(&manifest, &applied, &opts()).unwrap(); @@ -619,9 +586,7 @@ mod tests { vec![("GHSA-a", vec!["CVE-1"]), ("GHSA-b", vec!["CVE-2"])], ), ); - let doc = - build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()) - .unwrap(); + let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()).unwrap(); for st in &doc.statements { assert_eq!(st.timestamp.as_deref(), Some(doc.timestamp.as_str())); } @@ -635,23 +600,21 @@ mod tests { #[test] fn all_applied_patches_vuln_free_returns_none() { let mut manifest = PatchManifest::new(); - manifest.patches.insert( - "pkg:npm/a@1.0.0".to_string(), - record("u1", vec![]), - ); - manifest.patches.insert( - "pkg:npm/b@2.0.0".to_string(), - record("u2", vec![]), - ); + manifest + .patches + .insert("pkg:npm/a@1.0.0".to_string(), record("u1", vec![])); + manifest + .patches + .insert("pkg:npm/b@2.0.0".to_string(), record("u2", vec![])); let doc = build_document( &manifest, - &[ - "pkg:npm/a@1.0.0".to_string(), - "pkg:npm/b@2.0.0".to_string(), - ], + &["pkg:npm/a@1.0.0".to_string(), "pkg:npm/b@2.0.0".to_string()], &opts(), ); - assert!(doc.is_none(), "no vuln records anywhere → None, not an empty doc"); + assert!( + doc.is_none(), + "no vuln records anywhere → None, not an empty doc" + ); } /// Order-independence: the `statements` payload is fully determined @@ -680,7 +643,13 @@ mod tests { ); a.patches.insert( "pkg:npm/zzz@9.0.0".to_string(), - record("u-z", vec![("GHSA-shared", vec!["CVE-3"]), ("GHSA-only-z", vec!["CVE-9"])]), + record( + "u-z", + vec![ + ("GHSA-shared", vec!["CVE-3"]), + ("GHSA-only-z", vec!["CVE-9"]), + ], + ), ); // Manifest B: same logical content, reversed insertion order @@ -688,7 +657,13 @@ mod tests { let mut b = PatchManifest::new(); b.patches.insert( "pkg:npm/zzz@9.0.0".to_string(), - record("u-z", vec![("GHSA-only-z", vec!["CVE-9"]), ("GHSA-shared", vec!["CVE-3"])]), + record( + "u-z", + vec![ + ("GHSA-only-z", vec!["CVE-9"]), + ("GHSA-shared", vec!["CVE-3"]), + ], + ), ); b.patches.insert( "pkg:npm/aaa@1.0.0".to_string(), @@ -752,8 +727,7 @@ mod tests { record("u-vend", vec![("GHSA-vvvv", vec!["CVE-2024-7"])]), ); let applied = vec!["pkg:cargo/serde@1.0.0".to_string()]; - let doc = - build_document_with_vendored(&manifest, &applied, &applied, &opts()).unwrap(); + let doc = build_document_with_vendored(&manifest, &applied, &applied, &opts()).unwrap(); let st = &doc.statements[0]; assert_eq!( st.impact_statement.as_deref(), @@ -785,9 +759,7 @@ mod tests { d }; let a = strip(build_document(&manifest, &applied, &opts()).unwrap()); - let b = strip( - build_document_with_vendored(&manifest, &applied, &[], &opts()).unwrap(), - ); + let b = strip(build_document_with_vendored(&manifest, &applied, &[], &opts()).unwrap()); assert_eq!(a, b); assert!(!a.statements[0] .impact_statement @@ -810,13 +782,9 @@ mod tests { "pkg:npm/x@1.0.1".to_string(), record("shared-uuid", vec![("GHSA-shared", vec!["CVE-1"])]), ); - let applied = vec![ - "pkg:npm/x@1.0.0".to_string(), - "pkg:npm/x@1.0.1".to_string(), - ]; + let applied = vec!["pkg:npm/x@1.0.0".to_string(), "pkg:npm/x@1.0.1".to_string()]; let vendored = vec!["pkg:npm/x@1.0.1".to_string()]; - let doc = - build_document_with_vendored(&manifest, &applied, &vendored, &opts()).unwrap(); + let doc = build_document_with_vendored(&manifest, &applied, &vendored, &opts()).unwrap(); let imp = doc.statements[0].impact_statement.as_ref().unwrap(); assert!( imp.contains("Patched via Socket patch shared-uuid (vendored)"), @@ -827,7 +795,11 @@ mod tests { || imp.ends_with("Patched via Socket patch shared-uuid"), "plain phrasing missing: {imp}" ); - assert_eq!(imp.matches("shared-uuid").count(), 2, "both forms kept: {imp}"); + assert_eq!( + imp.matches("shared-uuid").count(), + 2, + "both forms kept: {imp}" + ); } /// Same UUID across two VENDORED PURLs sharing a GHSA: identical @@ -844,12 +816,8 @@ mod tests { "pkg:npm/x@1.0.1".to_string(), record("shared-uuid", vec![("GHSA-shared", vec!["CVE-1"])]), ); - let applied = vec![ - "pkg:npm/x@1.0.0".to_string(), - "pkg:npm/x@1.0.1".to_string(), - ]; - let doc = - build_document_with_vendored(&manifest, &applied, &applied, &opts()).unwrap(); + let applied = vec!["pkg:npm/x@1.0.0".to_string(), "pkg:npm/x@1.0.1".to_string()]; + let doc = build_document_with_vendored(&manifest, &applied, &applied, &opts()).unwrap(); let imp = doc.statements[0].impact_statement.as_ref().unwrap(); assert_eq!( imp.matches("shared-uuid").count(), diff --git a/crates/socket-patch-core/src/vex/conformance_tests.rs b/crates/socket-patch-core/src/vex/conformance_tests.rs index 9472b16..571f274 100644 --- a/crates/socket-patch-core/src/vex/conformance_tests.rs +++ b/crates/socket-patch-core/src/vex/conformance_tests.rs @@ -11,9 +11,7 @@ //! integration boundary. use super::*; -use crate::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, -}; +use crate::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo}; use std::collections::HashMap; fn vuln(cves: &[&str]) -> VulnerabilityInfo { @@ -62,10 +60,7 @@ fn sample_doc() -> Document { let mut manifest = PatchManifest::new(); manifest.patches.insert( "pkg:npm/lodash@4.17.20".to_string(), - record( - "uuid-1", - &[("GHSA-aaaa", &["CVE-2024-1", "CVE-2024-2"])], - ), + record("uuid-1", &[("GHSA-aaaa", &["CVE-2024-1", "CVE-2024-2"])]), ); manifest.patches.insert( "pkg:npm/minimist@1.2.0".to_string(), @@ -540,7 +535,11 @@ fn vulnerability_aliases_are_unique_within_statement() { // Non-vacuous guard: the merged statement carries multiple aliases // with the overlapping CVE present exactly once. If alias dedup // regressed, the loop above would fire on `CVE-DUP`. - assert_eq!(doc.statements.len(), 1, "fixture must merge to one statement"); + assert_eq!( + doc.statements.len(), + 1, + "fixture must merge to one statement" + ); assert_eq!( doc.statements[0].vulnerability.aliases, vec![ @@ -594,7 +593,11 @@ fn merged_statement_emits_all_subcomponents_with_at_id_in_serialized_json() { let doc = merged_doc(); let v = serde_json::to_value(&doc).unwrap(); let statements = v["statements"].as_array().unwrap(); - assert_eq!(statements.len(), 1, "two patches sharing a vuln → one statement"); + assert_eq!( + statements.len(), + 1, + "two patches sharing a vuln → one statement" + ); let subs = statements[0]["products"][0]["subcomponents"] .as_array() @@ -648,7 +651,10 @@ fn statement_level_id_renders_under_at_sign() { s.id = None; let v = serde_json::to_value(&s).unwrap(); let obj = v.as_object().unwrap(); - assert!(!obj.contains_key("@id"), "absent statement id must omit @id"); + assert!( + !obj.contains_key("@id"), + "absent statement id must omit @id" + ); assert!(!obj.contains_key("id")); } diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index fa8f6bd..ae40485 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -334,10 +334,10 @@ fn parse_toml_string_kv(line: &str, key: &str) -> Option { return None; } let rhs = rhs[1..].trim(); // drop the leading '=' and surrounding ws - // The value must open with a string delimiter; match it to its twin. - // `'` is a literal string (no escapes), `"` a basic string — for our - // purposes (names/versions, which never contain escaped quotes) the - // first matching delimiter terminates the value in both cases. + // The value must open with a string delimiter; match it to its twin. + // `'` is a literal string (no escapes), `"` a basic string — for our + // purposes (names/versions, which never contain escaped quotes) the + // first matching delimiter terminates the value in both cases. let quote = rhs.chars().next().filter(|c| *c == '"' || *c == '\'')?; let stripped = &rhs[quote.len_utf8()..]; let end = stripped.find(quote)?; @@ -555,7 +555,8 @@ mod tests { /// the real `url = ...` line that follows it is read. #[test] fn scan_origin_url_ignores_url_prefixed_key_and_keeps_scanning() { - let cfg = "[remote \"origin\"]\n\turlsuffix = nonsense\n\turl = git@github.com:foo/bar.git\n"; + let cfg = + "[remote \"origin\"]\n\turlsuffix = nonsense\n\turl = git@github.com:foo/bar.git\n"; assert_eq!( scan_remote_origin_url(cfg).as_deref(), Some("git@github.com:foo/bar.git") diff --git a/crates/socket-patch-core/src/vex/schema.rs b/crates/socket-patch-core/src/vex/schema.rs index 4773c0b..8257745 100644 --- a/crates/socket-patch-core/src/vex/schema.rs +++ b/crates/socket-patch-core/src/vex/schema.rs @@ -775,7 +775,10 @@ mod tests { ] }"#; let r: Result = serde_json::from_str(bad); - assert!(r.is_err(), "unknown nested status literal must fail to parse"); + assert!( + r.is_err(), + "unknown nested status literal must fail to parse" + ); } /// A document `version` supplied as a JSON string (`"1"`) must be diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index a1d789c..b1b648f 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -569,9 +569,8 @@ mod tests { "new.js".to_string(), PatchFileInfo { before_hash: String::new(), // new file, not yet created - after_hash: - "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - .to_string(), + after_hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + .to_string(), }, ); @@ -651,17 +650,27 @@ mod tests { let b = b"patched-b"; let hash_a = compute_git_sha256_from_bytes(a); let hash_b = compute_git_sha256_from_bytes(b); - tokio::fs::write(pkg_dir.path().join("a.js"), a).await.unwrap(); - tokio::fs::write(pkg_dir.path().join("b.js"), b).await.unwrap(); + tokio::fs::write(pkg_dir.path().join("a.js"), a) + .await + .unwrap(); + tokio::fs::write(pkg_dir.path().join("b.js"), b) + .await + .unwrap(); let mut files = HashMap::new(); files.insert( "a.js".to_string(), - PatchFileInfo { before_hash: "aaaa".to_string(), after_hash: hash_a }, + PatchFileInfo { + before_hash: "aaaa".to_string(), + after_hash: hash_a, + }, ); files.insert( "b.js".to_string(), - PatchFileInfo { before_hash: "bbbb".to_string(), after_hash: hash_b }, + PatchFileInfo { + before_hash: "bbbb".to_string(), + after_hash: hash_b, + }, ); let mut manifest = PatchManifest::new(); @@ -717,7 +726,10 @@ mod tests { let mut paths = HashMap::new(); paths.insert("pkg:npm/ok@1.0.0".to_string(), ok_dir.path().to_path_buf()); - paths.insert("pkg:npm/bad@1.0.0".to_string(), bad_dir.path().to_path_buf()); + paths.insert( + "pkg:npm/bad@1.0.0".to_string(), + bad_dir.path().to_path_buf(), + ); let out = applied_patches(&manifest, &paths).await; assert_eq!(out.applied, vec!["pkg:npm/ok@1.0.0".to_string()]); @@ -865,11 +877,17 @@ mod tests { let mut files = HashMap::new(); files.insert( "a.js".to_string(), - PatchFileInfo { before_hash: "aaaa".to_string(), after_hash: "deadbeef".to_string() }, + PatchFileInfo { + before_hash: "aaaa".to_string(), + after_hash: "deadbeef".to_string(), + }, ); files.insert( "b.js".to_string(), - PatchFileInfo { before_hash: "bbbb".to_string(), after_hash: "deadbeef".to_string() }, + PatchFileInfo { + before_hash: "bbbb".to_string(), + after_hash: "deadbeef".to_string(), + }, ); let mut manifest = PatchManifest::new(); @@ -891,9 +909,16 @@ mod tests { let out = applied_patches(&manifest, &paths).await; assert!(out.applied.is_empty()); - assert_eq!(out.failed.len(), 1, "one FailedPatch per PURL, not per file"); + assert_eq!( + out.failed.len(), + 1, + "one FailedPatch per PURL, not per file" + ); assert!( - matches!(out.failed[0].reason.as_str(), "hash_mismatch" | "file_not_found"), + matches!( + out.failed[0].reason.as_str(), + "hash_mismatch" | "file_not_found" + ), "unexpected reason: {}", out.failed[0].reason ); @@ -976,7 +1001,9 @@ mod tests { let hash = compute_git_sha256_from_bytes(patched); let dir = root.path().join(&rel); tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("index.js"), patched).await.unwrap(); + tokio::fs::write(dir.join("index.js"), patched) + .await + .unwrap(); let mut rec = record_with_one_file(&hash); rec.uuid = VUUID.to_string(); @@ -1010,7 +1037,9 @@ mod tests { let hash = compute_git_sha256_from_bytes(patched); let dir = root.path().join(&rel); tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("index.js"), patched).await.unwrap(); + tokio::fs::write(dir.join("index.js"), patched) + .await + .unwrap(); let mut rec = record_with_one_file(&hash); rec.uuid = VUUID.to_string(); @@ -1029,8 +1058,7 @@ mod tests { go_patches: HashMap::new(), }; - let out = - applied_patches_with_vendor(&manifest, &HashMap::new(), Some(&ctx)).await; + let out = applied_patches_with_vendor(&manifest, &HashMap::new(), Some(&ctx)).await; assert_eq!(out.applied, vec![purl.to_string()]); assert_eq!(out.vendored, vec![purl.to_string()]); } @@ -1052,7 +1080,9 @@ mod tests { // Vendored copy: patched. let vdir = root.path().join(&rel); tokio::fs::create_dir_all(&vdir).await.unwrap(); - tokio::fs::write(vdir.join("index.js"), patched).await.unwrap(); + tokio::fs::write(vdir.join("index.js"), patched) + .await + .unwrap(); // Installed tree: still original. let installed = root.path().join("installed"); tokio::fs::create_dir_all(&installed).await.unwrap(); @@ -1115,7 +1145,9 @@ mod tests { // Vendored copy: tampered. let vdir = root.path().join(&rel); tokio::fs::create_dir_all(&vdir).await.unwrap(); - tokio::fs::write(vdir.join("index.js"), b"tampered").await.unwrap(); + tokio::fs::write(vdir.join("index.js"), b"tampered") + .await + .unwrap(); // Installed tree: at afterHash (would verify if consulted). let installed = root.path().join("installed"); tokio::fs::create_dir_all(&installed).await.unwrap(); @@ -1180,8 +1212,7 @@ mod tests { // No installed tree (module cache absent) — the redirect copy is // the consumed bytes. - let out = - applied_patches_with_vendor(&manifest, &HashMap::new(), Some(&ctx)).await; + let out = applied_patches_with_vendor(&manifest, &HashMap::new(), Some(&ctx)).await; assert_eq!(out.applied, vec![purl.to_string()]); assert!( out.vendored.is_empty(), @@ -1194,8 +1225,7 @@ mod tests { tokio::fs::write(copy_dir.join("index.js"), b"tampered") .await .unwrap(); - let out = - applied_patches_with_vendor(&manifest, &HashMap::new(), Some(&ctx)).await; + let out = applied_patches_with_vendor(&manifest, &HashMap::new(), Some(&ctx)).await; assert!(out.applied.is_empty()); assert_eq!(out.failed.len(), 1); assert_eq!(out.failed[0].reason, "hash_mismatch"); diff --git a/crates/socket-patch-core/tests/binary_fetch_error_classification_e2e.rs b/crates/socket-patch-core/tests/binary_fetch_error_classification_e2e.rs index 02656ae..a714fbe 100644 --- a/crates/socket-patch-core/tests/binary_fetch_error_classification_e2e.rs +++ b/crates/socket-patch-core/tests/binary_fetch_error_classification_e2e.rs @@ -128,8 +128,14 @@ async fn fetch_blob_500_still_classifies_as_other() { match &err { ApiError::Other(msg) => { - assert!(msg.contains("500"), "Other must embed the status; got: {msg}"); - assert!(msg.contains("boom"), "Other must embed the body; got: {msg}"); + assert!( + msg.contains("500"), + "Other must embed the status; got: {msg}" + ); + assert!( + msg.contains("boom"), + "Other must embed the body; got: {msg}" + ); } other => panic!("500 must be Other; got: {other:?}"), } diff --git a/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs b/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs index 4906ade..5dcdf8f 100644 --- a/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs +++ b/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs @@ -58,7 +58,10 @@ fn manifest_with_after_hashes(after: &[&str]) -> PatchManifest { tier: "free".to_string(), }, ); - PatchManifest { patches, setup: None } + PatchManifest { + patches, + setup: None, + } } /// Count the directory entries under `dir` (used to prove a short-circuit @@ -104,7 +107,10 @@ async fn fetch_missing_blobs_nonempty_manifest_attempts_download() { let result = fetch_missing_blobs(&manifest, &blobs, &client, None).await; assert_eq!(result.total, 1, "one missing afterHash blob"); assert_eq!(result.downloaded, 0, "closed-port client cannot download"); - assert_eq!(result.failed, 1, "the download attempt must be recorded as failed"); + assert_eq!( + result.failed, 1, + "the download attempt must be recorded as failed" + ); assert_eq!(result.results.len(), 1); assert!(!result.results[0].success); } @@ -194,13 +200,20 @@ async fn fetch_missing_sources_package_mode_with_no_packages_path() { let result = fetch_missing_sources(&manifest, &sources, DownloadMode::Package, &client, None).await; - assert_eq!(result.total, 0, "Package mode w/o packages_path must short-circuit"); + assert_eq!( + result.total, 0, + "Package mode w/o packages_path must short-circuit" + ); assert_eq!(result.downloaded, 0); assert_eq!(result.failed, 0); assert_eq!(result.skipped, 0); assert!(result.results.is_empty()); // The short-circuit must not have written any blob. - assert_eq!(dir_entry_count(&blobs), 0, "Package-mode short-circuit did zero I/O"); + assert_eq!( + dir_entry_count(&blobs), + 0, + "Package-mode short-circuit did zero I/O" + ); } /// Same with `DownloadMode::Diff` and no diffs_path. @@ -225,12 +238,19 @@ async fn fetch_missing_sources_diff_mode_with_no_diffs_path() { let result = fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await; - assert_eq!(result.total, 0, "Diff mode w/o diffs_path must short-circuit"); + assert_eq!( + result.total, 0, + "Diff mode w/o diffs_path must short-circuit" + ); assert_eq!(result.downloaded, 0); assert_eq!(result.failed, 0); assert_eq!(result.skipped, 0); assert!(result.results.is_empty()); - assert_eq!(dir_entry_count(&blobs), 0, "Diff-mode short-circuit did zero I/O"); + assert_eq!( + dir_entry_count(&blobs), + 0, + "Diff-mode short-circuit did zero I/O" + ); } /// `DownloadMode::parse` accepts all documented values plus the @@ -238,17 +258,26 @@ async fn fetch_missing_sources_diff_mode_with_no_diffs_path() { #[test] fn download_mode_parse_covers_all_branches() { assert_eq!(DownloadMode::parse("diff").unwrap(), DownloadMode::Diff); - assert_eq!(DownloadMode::parse("package").unwrap(), DownloadMode::Package); + assert_eq!( + DownloadMode::parse("package").unwrap(), + DownloadMode::Package + ); assert_eq!(DownloadMode::parse("file").unwrap(), DownloadMode::File); assert_eq!(DownloadMode::parse("blob").unwrap(), DownloadMode::File); // Case-insensitive. assert_eq!(DownloadMode::parse("DIFF").unwrap(), DownloadMode::Diff); - assert_eq!(DownloadMode::parse("Package").unwrap(), DownloadMode::Package); + assert_eq!( + DownloadMode::parse("Package").unwrap(), + DownloadMode::Package + ); assert_eq!(DownloadMode::parse("FILE").unwrap(), DownloadMode::File); assert_eq!(DownloadMode::parse("Blob").unwrap(), DownloadMode::File); // Unknown value → Err, and the message names the offending input. let err = DownloadMode::parse("invalid").unwrap_err(); - assert!(err.contains("invalid"), "error should echo the bad value: {err}"); + assert!( + err.contains("invalid"), + "error should echo the bad value: {err}" + ); assert!(DownloadMode::parse("").is_err()); // A near-miss must not be silently coerced to a valid mode. assert!(DownloadMode::parse("diffs").is_err()); @@ -259,11 +288,18 @@ fn download_mode_parse_covers_all_branches() { /// each variant maps to a *distinct* tag. #[test] fn download_mode_as_tag_round_trips_with_parse() { - let variants = [DownloadMode::Diff, DownloadMode::Package, DownloadMode::File]; + let variants = [ + DownloadMode::Diff, + DownloadMode::Package, + DownloadMode::File, + ]; let mut seen_tags = HashSet::new(); for mode in variants { let tag = mode.as_tag(); - assert!(seen_tags.insert(tag), "tag {tag:?} must be unique per variant"); + assert!( + seen_tags.insert(tag), + "tag {tag:?} must be unique per variant" + ); assert_eq!(DownloadMode::parse(tag).unwrap(), mode); } // Pin the exact tag strings so a silent rename is caught. @@ -346,7 +382,10 @@ async fn fetch_blobs_by_hash_mixes_skip_and_download_attempt() { // The absent blob was never written (download failed); the present one // is untouched. - assert!(!blobs.join(absent).exists(), "failed download must not leave a file"); + assert!( + !blobs.join(absent).exists(), + "failed download must not leave a file" + ); assert_eq!(std::fs::read(blobs.join(present)).unwrap(), b"present"); } @@ -406,7 +445,11 @@ async fn fetch_missing_blobs_accepts_and_writes_matching_content() { .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .collect(); - assert_eq!(names, vec![hash], "exactly the blob, no temp files: {names:?}"); + assert_eq!( + names, + vec![hash], + "exactly the blob, no temp files: {names:?}" + ); } /// A server that returns bytes NOT matching the requested hash must be @@ -442,7 +485,11 @@ async fn fetch_missing_blobs_rejects_content_hash_mismatch_and_writes_nothing() assert_eq!(result.total, 1); assert_eq!(result.downloaded, 0, "mismatched content must be refused"); assert_eq!(result.failed, 1); - assert!(result.results[0].error.as_deref().unwrap().contains("mismatch")); + assert!(result.results[0] + .error + .as_deref() + .unwrap() + .contains("mismatch")); // The integrity invariant: nothing — not even a partial/tampered file — // may sit at the content-addressed path, or a subsequent run's presence @@ -502,7 +549,10 @@ async fn fetch_missing_blobs_accepts_uppercase_manifest_hash() { let content = b"content addressed by an uppercase manifest hash"; let hash_lower = compute_git_sha256_from_bytes(content); let hash_upper = hash_lower.to_ascii_uppercase(); - assert_ne!(hash_lower, hash_upper, "fixture: hash must have hex letters"); + assert_ne!( + hash_lower, hash_upper, + "fixture: hash must have hex letters" + ); let server = MockServer::start().await; // The request path carries the manifest's (uppercase) hash verbatim. @@ -520,7 +570,10 @@ async fn fetch_missing_blobs_accepts_uppercase_manifest_hash() { let client = proxy_client(&server.uri()); let result = fetch_missing_blobs(&manifest, &blobs, &client, None).await; - assert_eq!(result.downloaded, 1, "uppercase-hash content must be accepted"); + assert_eq!( + result.downloaded, 1, + "uppercase-hash content must be accepted" + ); assert_eq!(result.failed, 0); assert_eq!(std::fs::read(blobs.join(&hash_upper)).unwrap(), content); } @@ -551,7 +604,10 @@ fn manifest_with_uuids(uuids: &[&str]) -> PatchManifest { }, ); } - PatchManifest { patches, setup: None } + PatchManifest { + patches, + setup: None, + } } #[tokio::test] @@ -596,11 +652,14 @@ async fn fetch_missing_sources_diff_downloads_and_writes_archive() { .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .collect(); - assert_eq!(names, vec![format!("{uuid}.tar.gz")], "no temp files: {names:?}"); + assert_eq!( + names, + vec![format!("{uuid}.tar.gz")], + "no temp files: {names:?}" + ); // A re-run finds the archive present and short-circuits (no second GET; // the mock's `.expect(1)` would trip on a second request). - let again = - fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await; + let again = fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await; assert_eq!(again.total, 0, "already-present archive → nothing to do"); } @@ -675,7 +734,10 @@ async fn fetch_missing_sources_diff_404_is_failure_with_kind_message() { assert_eq!(result.failed, 1); let err = result.results[0].error.as_deref().unwrap(); assert!(err.contains("Diff"), "message should name the kind: {err}"); - assert!(err.contains("not found"), "message should say not found: {err}"); + assert!( + err.contains("not found"), + "message should say not found: {err}" + ); // Nothing written for a 404. assert_eq!(dir_entry_count(&diffs), 0); } @@ -754,5 +816,8 @@ async fn get_missing_blobs_reports_missing_afterhash() { std::fs::write(blobs.join(&hash), b"data").unwrap(); let missing = get_missing_blobs(&manifest, &blobs).await; - assert!(missing.is_empty(), "staged blob must not be reported missing"); + assert!( + missing.is_empty(), + "staged blob must not be reported missing" + ); } diff --git a/crates/socket-patch-core/tests/crawler_cargo_e2e.rs b/crates/socket-patch-core/tests/crawler_cargo_e2e.rs index b291e3e..a69f2c8 100644 --- a/crates/socket-patch-core/tests/crawler_cargo_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_cargo_e2e.rs @@ -328,7 +328,9 @@ async fn get_crate_source_paths_with_vendor_dir_returns_vendor() { #[tokio::test] async fn get_crate_source_paths_vendor_without_cargo_manifest_is_empty() { let tmp = tempfile::tempdir().unwrap(); - tokio::fs::create_dir(tmp.path().join("vendor")).await.unwrap(); + tokio::fs::create_dir(tmp.path().join("vendor")) + .await + .unwrap(); let crawler = CargoCrawler; let paths = crawler diff --git a/crates/socket-patch-core/tests/crawler_maven_e2e.rs b/crates/socket-patch-core/tests/crawler_maven_e2e.rs index 636e17a..94eda61 100644 --- a/crates/socket-patch-core/tests/crawler_maven_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_maven_e2e.rs @@ -379,7 +379,9 @@ async fn find_by_purls_finds_package_in_m2_layout() { .await .unwrap(); assert_eq!(result.len(), 1); - let pkg = result.get(purl).expect("requested purl must be the map key"); + let pkg = result + .get(purl) + .expect("requested purl must be the map key"); assert_eq!(pkg.path, pkg_dir, "path must point at the version dir"); assert_eq!(pkg.name, "commons-lang3", "name = artifactId"); assert_eq!(pkg.version, "3.12.0"); @@ -434,8 +436,7 @@ async fn crawl_all_discovers_packages_in_repo() { let result = crawler.crawl_all(&opts).await; // `>= 2` would pass on garbage/duplicate packages — assert the exact // coordinates were discovered and nothing extra leaked in. - let purls: std::collections::HashSet<&str> = - result.iter().map(|p| p.purl.as_str()).collect(); + let purls: std::collections::HashSet<&str> = result.iter().map(|p| p.purl.as_str()).collect(); assert!( purls.contains("pkg:maven/org.apache.commons/commons-lang3@3.12.0"), "commons-lang3 must be discovered; got {result:?}" diff --git a/crates/socket-patch-core/tests/crawler_monorepo_gaps.rs b/crates/socket-patch-core/tests/crawler_monorepo_gaps.rs index a7bfb73..67cc220 100644 --- a/crates/socket-patch-core/tests/crawler_monorepo_gaps.rs +++ b/crates/socket-patch-core/tests/crawler_monorepo_gaps.rs @@ -48,9 +48,12 @@ async fn stage_vendor_gem(subproject: &Path, name: &str, version: &str) { .join("lib"); tokio::fs::create_dir_all(&pkg).await.unwrap(); // Realistic Bundler project marker (the subproject dir now exists). - tokio::fs::write(subproject.join("Gemfile"), b"source 'https://rubygems.org'\n") - .await - .unwrap(); + tokio::fs::write( + subproject.join("Gemfile"), + b"source 'https://rubygems.org'\n", + ) + .await + .unwrap(); } // ── GREEN: per-subproject crawl works (the cwd-scoped model) ────────────── diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 0e54644..b0ec52c 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -684,12 +684,15 @@ async fn crawl_all_recurses_into_workspace_packages() { let crawler = NpmCrawler; let opts = options_at(tmp.path()); let result = crawler.crawl_all(&opts).await; - let lodash = result.iter().find(|p| p.name == "lodash").unwrap_or_else(|| { - panic!( - "workspace recursion must discover nested node_modules; got {:?}", - result.iter().map(|p| p.name.as_str()).collect::>() - ) - }); + let lodash = result + .iter() + .find(|p| p.name == "lodash") + .unwrap_or_else(|| { + panic!( + "workspace recursion must discover nested node_modules; got {:?}", + result.iter().map(|p| p.name.as_str()).collect::>() + ) + }); assert_eq!(lodash.version, "4.17.21"); assert_eq!(lodash.purl, "pkg:npm/lodash@4.17.21"); assert_eq!( @@ -921,7 +924,10 @@ async fn crawl_all_discovers_deeply_nested_transitive_deps() { let result = crawler.crawl_all(&options_at(tmp.path())).await; let ver = |n: &str| -> Option<&str> { - result.iter().find(|p| p.name == n).map(|p| p.version.as_str()) + result + .iter() + .find(|p| p.name == n) + .map(|p| p.version.as_str()) }; assert_eq!(ver("a"), Some("1.0.0"), "direct dep at depth 1"); assert_eq!(ver("b"), Some("2.0.0"), "transitive at depth 2"); @@ -932,7 +938,11 @@ async fn crawl_all_discovers_deeply_nested_transitive_deps() { "the depth-4 transitive dep must still be discovered (unbounded recursion)" ); let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect(); - assert_eq!(result.len(), 4, "exactly the four chained packages; got {names:?}"); + assert_eq!( + result.len(), + 4, + "exactly the four chained packages; got {names:?}" + ); } #[tokio::test] diff --git a/crates/socket-patch-core/tests/crawler_nuget_e2e.rs b/crates/socket-patch-core/tests/crawler_nuget_e2e.rs index 61b6251..b05d187 100644 --- a/crates/socket-patch-core/tests/crawler_nuget_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_nuget_e2e.rs @@ -185,7 +185,10 @@ async fn crawl_all_discovers_global_cache_layout() { // matching would accept a wrong version or a malformed PURL. let mut purls: Vec = result.iter().map(|p| p.purl.clone()).collect(); purls.sort_unstable(); - let mut expected = vec![ORG_PURL_A.to_ascii_lowercase(), ORG_PURL_B.to_ascii_lowercase()]; + let mut expected = vec![ + ORG_PURL_A.to_ascii_lowercase(), + ORG_PURL_B.to_ascii_lowercase(), + ]; expected.sort_unstable(); assert_eq!( purls, expected, @@ -517,7 +520,9 @@ async fn crawl_all_handles_unreadable_version_dir() { // `pkg:nuget/blocked-name@1.0.0`, proving the chmod — not an empty dir — is // what suppresses it. Otherwise the assertion would be vacuous. let ver_dir = pkg_name_dir.join("1.0.0"); - tokio::fs::create_dir_all(ver_dir.join("lib")).await.unwrap(); + tokio::fs::create_dir_all(ver_dir.join("lib")) + .await + .unwrap(); common::chmod_unreadable(&pkg_name_dir); // Stage a readable sibling package so we prove the top-level scan actually // ran and only the blocked name dir was dropped — not that scanning bailed diff --git a/crates/socket-patch-core/tests/crawler_python_e2e.rs b/crates/socket-patch-core/tests/crawler_python_e2e.rs index efe7a5b..5108422 100644 --- a/crates/socket-patch-core/tests/crawler_python_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_python_e2e.rs @@ -741,7 +741,10 @@ async fn find_by_purls_matches_canonicalized_name() { let pkg = result .get("pkg:pypi/requests@2.28.0") .expect("result must be keyed by the queried PURL"); - assert_eq!(pkg.name, "requests", "name must be canonicalized to lowercase"); + assert_eq!( + pkg.name, "requests", + "name must be canonicalized to lowercase" + ); assert_eq!(pkg.version, "2.28.0"); assert_eq!(pkg.purl, "pkg:pypi/requests@2.28.0"); assert_eq!(pkg.namespace, None); @@ -842,7 +845,11 @@ async fn crawl_all_via_site_packages_finds_dist_info_packages() { batch_size: 100, }; let result = crawler.crawl_all(&opts).await; - assert_eq!(result.len(), 2, "exactly the two dist-info dirs; got {result:?}"); + assert_eq!( + result.len(), + 2, + "exactly the two dist-info dirs; got {result:?}" + ); // Verify the full identity of each package, not just the name — a // regression that mangled the version or PURL (or canonicalization) diff --git a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs index 662f137..f834a57 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -102,7 +102,10 @@ async fn find_by_purls_accepts_gem_with_gemspec_only() { .unwrap(); assert_eq!(result.len(), 1); let pkg = result.get(ORG_PURL).unwrap(); - assert_eq!(pkg.path, pkg_dir, "gemspec-only dir must be the resolved path"); + assert_eq!( + pkg.path, pkg_dir, + "gemspec-only dir must be the resolved path" + ); assert_eq!(pkg.name, "rails"); assert_eq!(pkg.version, "7.1.0"); } diff --git a/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs b/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs index 1b93dd7..0e63ea3 100644 --- a/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs +++ b/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs @@ -197,7 +197,11 @@ async fn python_crawler_crawl_all_empty_returns_empty() { // Positive control: a populated .venv site-packages yields the package. let populated = tempfile::tempdir().unwrap(); #[cfg(windows)] - let sp = populated.path().join(".venv").join("Lib").join("site-packages"); + let sp = populated + .path() + .join(".venv") + .join("Lib") + .join("site-packages"); #[cfg(not(windows))] let sp = populated .path() diff --git a/crates/socket-patch-core/tests/diff_e2e.rs b/crates/socket-patch-core/tests/diff_e2e.rs index 96e6235..cf8b570 100644 --- a/crates/socket-patch-core/tests/diff_e2e.rs +++ b/crates/socket-patch-core/tests/diff_e2e.rs @@ -155,9 +155,12 @@ fn forged_max_u64_header_is_safe() { let huge: u64 = i64::MAX as u64; forged[24..32].copy_from_slice(&huge.to_le_bytes()); - let result = apply_diff(before, &forged) - .expect("clamped apply must succeed on a max-size forged hint"); - assert_eq!(result, after, "max-size forged hint must not corrupt output"); + let result = + apply_diff(before, &forged).expect("clamped apply must succeed on a max-size forged hint"); + assert_eq!( + result, after, + "max-size forged hint must not corrupt output" + ); } /// Security regression (mirrors the lib's diff --git a/crates/socket-patch-core/tests/fuzzy_match_e2e.rs b/crates/socket-patch-core/tests/fuzzy_match_e2e.rs index 85aa5e6..5ddb106 100644 --- a/crates/socket-patch-core/tests/fuzzy_match_e2e.rs +++ b/crates/socket-patch-core/tests/fuzzy_match_e2e.rs @@ -59,10 +59,7 @@ fn exact_name_match_wins_over_prefix() { 2, "both the exact and the prefix sibling match query 'node'" ); - assert_eq!( - results[0].name, "node", - "ExactName must outrank PrefixName" - ); + assert_eq!(results[0].name, "node", "ExactName must outrank PrefixName"); assert_eq!(results[0].namespace.as_deref(), Some("@types")); assert_eq!( results[1].name, "node-fetch", @@ -103,7 +100,11 @@ fn contains_match_returns_partial() { pkg("lodash", "4.17.21", None), ]; let results = fuzzy_match_packages("width", &packages, 20); - assert_eq!(results.len(), 1, "only the contains match survives filtering"); + assert_eq!( + results.len(), + 1, + "only the contains match survives filtering" + ); assert_eq!(results[0].name, "string-width"); } @@ -128,12 +129,13 @@ fn case_insensitive_match() { // The query case differs from the stored name; a non-matching decoy ensures // we're asserting the case-folded match actually fires, not that "any single // package is returned". - let packages = vec![ - pkg("React", "18.0.0", None), - pkg("lodash", "4.17.21", None), - ]; + let packages = vec![pkg("React", "18.0.0", None), pkg("lodash", "4.17.21", None)]; let results = fuzzy_match_packages("react", &packages, 20); - assert_eq!(results.len(), 1, "case-insensitive match selects exactly React"); + assert_eq!( + results.len(), + 1, + "case-insensitive match selects exactly React" + ); assert_eq!(results[0].name, "React"); // Uppercased query must resolve to the same package. let upper = fuzzy_match_packages("REACT", &packages, 20); diff --git a/crates/socket-patch-core/tests/package_e2e.rs b/crates/socket-patch-core/tests/package_e2e.rs index b04814f..7b98a0d 100644 --- a/crates/socket-patch-core/tests/package_e2e.rs +++ b/crates/socket-patch-core/tests/package_e2e.rs @@ -70,7 +70,9 @@ fn write_archive_with_regular_and_symlink( fhdr.set_size(file_data.len() as u64); fhdr.set_mode(0o644); fhdr.set_cksum(); - builder.append_data(&mut fhdr, file_name, file_data).unwrap(); + builder + .append_data(&mut fhdr, file_name, file_data) + .unwrap(); let mut lhdr = tar::Header::new_gnu(); lhdr.set_entry_type(tar::EntryType::Symlink); @@ -309,7 +311,11 @@ fn read_archive_filtered_keeps_only_listed_entries() { ); let filtered = read_archive_filtered(&archive, &make_file_info()).unwrap(); - assert_eq!(filtered.len(), 2, "exactly the two listed entries survive: {filtered:?}"); + assert_eq!( + filtered.len(), + 2, + "exactly the two listed entries survive: {filtered:?}" + ); // The listed `package/index.js` key must match the normalized // `index.js` entry, carrying its exact bytes through the filter. assert_eq!( diff --git a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs index cb9d363..b680d36 100644 --- a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs +++ b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs @@ -116,7 +116,10 @@ fn telemetry_disabled_when_vitest_env_is_true() { with_clean_env(|| { assert!(!is_telemetry_disabled(), "baseline must be enabled"); std::env::set_var("VITEST", "true"); - assert!(is_telemetry_disabled(), "VITEST=true must disable telemetry"); + assert!( + is_telemetry_disabled(), + "VITEST=true must disable telemetry" + ); std::env::remove_var("VITEST"); assert!( !is_telemetry_disabled(), @@ -278,7 +281,10 @@ fn sanitize_error_message_replaces_home_with_tilde() { // Every occurrence is redacted, not just the first. let multi = format!("read {home}/a failed; wrote {home}/b ok"); - assert_eq!(sanitize_error_message(&multi), "read ~/a failed; wrote ~/b ok"); + assert_eq!( + sanitize_error_message(&multi), + "read ~/a failed; wrote ~/b ok" + ); // The bare home path with nothing after it is also redacted. assert_eq!(sanitize_error_message(home), "~"); From 4a9309da83edb2352693110f3e1ff753a4028d18 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 12:22:28 -0400 Subject: [PATCH 28/31] chore(vendor): drop spikes/ scratch from the branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phase-0 fixture-oracle dirs (365 files incl. 16 binary wheels, ~34k lines) were development scratch — the backends embed the captured fixture content as inline test constants with provenance comments, so nothing reads spikes/ at build or test time. The spike methodology + results live in the commit history, the CLI_CONTRACT checksum/flavor tables, and those constants. Co-Authored-By: Claude Fable 5 --- spikes/PHASE0-FINDINGS.txt | 160 --- spikes/PHASE0-V2-FINDINGS.txt | 198 ---- spikes/bun/README.md | 76 -- spikes/bun/bn1-file-deps/after/bun.lock | 18 - spikes/bun/bn1-file-deps/after/package.json | 8 - spikes/bun/bn1-file-deps/before/package.json | 8 - spikes/bun/bn1-nested/after/bun.lock | 20 - spikes/bun/bn1-nested/after/package.json | 8 - spikes/bun/bn1-nested/before/package.json | 8 - spikes/bun/bn2-overrides/after/bun.lock | 18 - spikes/bun/bn2-overrides/after/package.json | 10 - spikes/bun/bn2-overrides/before/bun.lock | 15 - spikes/bun/bn2-overrides/before/package.json | 7 - spikes/bun/bn2-resolutions/after/bun.lock | 18 - spikes/bun/bn2-resolutions/after/package.json | 10 - spikes/bun/bn3-lock-only/after/bun.lock | 15 - spikes/bun/bn3-lock-only/after/package.json | 7 - spikes/bun/bn3-lock-only/before/bun.lock | 15 - spikes/bun/bn3-lock-only/before/package.json | 7 - .../bun/bn4-override-collapse/after/bun.lock | 21 - .../bn4-override-collapse/after/package.json | 11 - .../bun/bn4-override-collapse/before/bun.lock | 20 - .../bn4-override-collapse/before/package.json | 8 - .../bn4b-version-key-ignored/after/bun.lock | 23 - .../after/package.json | 11 - .../bun/bn4c-targeted-nested/after/bun.lock | 20 - .../bn4c-targeted-nested/after/package.json | 8 - .../bun/bn4c-targeted-nested/before/bun.lock | 20 - .../bn4c-targeted-nested/before/package.json | 8 - spikes/gem-checksums/README.md | 111 -- .../after/.bundle/config | 3 - .../bare-checksum-registry-gem/after/Gemfile | 3 - .../after/Gemfile.lock | 17 - .../before/.bundle/config | 3 - .../bare-checksum-registry-gem/before/Gemfile | 3 - .../before/Gemfile.lock | 17 - .../path-with-checksums/after/.bundle/config | 3 - .../path-with-checksums/after/Gemfile | 3 - .../path-with-checksums/after/Gemfile.lock | 21 - .../after/vendored/rack-3.1.8/CHANGELOG.md | 998 ------------------ .../after/vendored/rack-3.1.8/CONTRIBUTING.md | 144 --- .../after/vendored/rack-3.1.8/MIT-LICENSE | 20 - .../after/vendored/rack-3.1.8/README.md | 328 ------ .../after/vendored/rack-3.1.8/SPEC.rdoc | 365 ------- .../after/vendored/rack-3.1.8/lib/rack.rb | 66 -- .../lib/rack/auth/abstract/handler.rb | 41 - .../lib/rack/auth/abstract/request.rb | 49 - .../rack-3.1.8/lib/rack/auth/basic.rb | 58 - .../rack-3.1.8/lib/rack/bad_request.rb | 8 - .../rack-3.1.8/lib/rack/body_proxy.rb | 63 -- .../vendored/rack-3.1.8/lib/rack/builder.rb | 290 ----- .../vendored/rack-3.1.8/lib/rack/cascade.rb | 67 -- .../rack-3.1.8/lib/rack/common_logger.rb | 88 -- .../rack-3.1.8/lib/rack/conditional_get.rb | 86 -- .../vendored/rack-3.1.8/lib/rack/config.rb | 22 - .../vendored/rack-3.1.8/lib/rack/constants.rb | 67 -- .../rack-3.1.8/lib/rack/content_length.rb | 34 - .../rack-3.1.8/lib/rack/content_type.rb | 33 - .../vendored/rack-3.1.8/lib/rack/deflater.rb | 158 --- .../vendored/rack-3.1.8/lib/rack/directory.rb | 205 ---- .../vendored/rack-3.1.8/lib/rack/etag.rb | 68 -- .../vendored/rack-3.1.8/lib/rack/events.rb | 157 --- .../vendored/rack-3.1.8/lib/rack/files.rb | 216 ---- .../vendored/rack-3.1.8/lib/rack/head.rb | 26 - .../vendored/rack-3.1.8/lib/rack/headers.rb | 238 ----- .../vendored/rack-3.1.8/lib/rack/lint.rb | 991 ----------------- .../vendored/rack-3.1.8/lib/rack/lock.rb | 29 - .../vendored/rack-3.1.8/lib/rack/logger.rb | 23 - .../rack-3.1.8/lib/rack/media_type.rb | 48 - .../rack-3.1.8/lib/rack/method_override.rb | 56 - .../vendored/rack-3.1.8/lib/rack/mime.rb | 694 ------------ .../vendored/rack-3.1.8/lib/rack/mock.rb | 3 - .../rack-3.1.8/lib/rack/mock_request.rb | 161 --- .../rack-3.1.8/lib/rack/mock_response.rb | 124 --- .../vendored/rack-3.1.8/lib/rack/multipart.rb | 77 -- .../lib/rack/multipart/generator.rb | 99 -- .../rack-3.1.8/lib/rack/multipart/parser.rb | 502 --------- .../lib/rack/multipart/uploaded_file.rb | 45 - .../rack-3.1.8/lib/rack/null_logger.rb | 48 - .../rack-3.1.8/lib/rack/query_parser.rb | 200 ---- .../vendored/rack-3.1.8/lib/rack/recursive.rb | 66 -- .../vendored/rack-3.1.8/lib/rack/reloader.rb | 112 -- .../vendored/rack-3.1.8/lib/rack/request.rb | 796 -------------- .../vendored/rack-3.1.8/lib/rack/response.rb | 403 ------- .../rack-3.1.8/lib/rack/rewindable_input.rb | 113 -- .../vendored/rack-3.1.8/lib/rack/runtime.rb | 35 - .../vendored/rack-3.1.8/lib/rack/sendfile.rb | 167 --- .../rack-3.1.8/lib/rack/show_exceptions.rb | 407 ------- .../rack-3.1.8/lib/rack/show_status.rb | 123 --- .../vendored/rack-3.1.8/lib/rack/static.rb | 187 ---- .../rack-3.1.8/lib/rack/tempfile_reaper.rb | 33 - .../vendored/rack-3.1.8/lib/rack/urlmap.rb | 99 -- .../vendored/rack-3.1.8/lib/rack/utils.rb | 631 ----------- .../vendored/rack-3.1.8/lib/rack/version.rb | 21 - .../after/vendored/rack-3.1.8/rack.gemspec | 31 - .../path-with-checksums/before/.bundle/config | 3 - .../path-with-checksums/before/Gemfile | 3 - .../path-with-checksums/before/Gemfile.lock | 17 - .../after/.bundle/config | 3 - .../registry-with-checksums/after/Gemfile | 3 - .../after/Gemfile.lock | 17 - .../before/.bundle/config | 3 - .../registry-with-checksums/before/Gemfile | 3 - .../after/.bundle/config | 3 - .../stale-checksum-v1-bug/after/Gemfile | 3 - .../stale-checksum-v1-bug/after/Gemfile.lock | 21 - .../after/vendored/rack-3.1.8/CHANGELOG.md | 998 ------------------ .../after/vendored/rack-3.1.8/CONTRIBUTING.md | 144 --- .../after/vendored/rack-3.1.8/MIT-LICENSE | 20 - .../after/vendored/rack-3.1.8/README.md | 328 ------ .../after/vendored/rack-3.1.8/SPEC.rdoc | 365 ------- .../after/vendored/rack-3.1.8/lib/rack.rb | 66 -- .../lib/rack/auth/abstract/handler.rb | 41 - .../lib/rack/auth/abstract/request.rb | 49 - .../rack-3.1.8/lib/rack/auth/basic.rb | 58 - .../rack-3.1.8/lib/rack/bad_request.rb | 8 - .../rack-3.1.8/lib/rack/body_proxy.rb | 63 -- .../vendored/rack-3.1.8/lib/rack/builder.rb | 290 ----- .../vendored/rack-3.1.8/lib/rack/cascade.rb | 67 -- .../rack-3.1.8/lib/rack/common_logger.rb | 88 -- .../rack-3.1.8/lib/rack/conditional_get.rb | 86 -- .../vendored/rack-3.1.8/lib/rack/config.rb | 22 - .../vendored/rack-3.1.8/lib/rack/constants.rb | 67 -- .../rack-3.1.8/lib/rack/content_length.rb | 34 - .../rack-3.1.8/lib/rack/content_type.rb | 33 - .../vendored/rack-3.1.8/lib/rack/deflater.rb | 158 --- .../vendored/rack-3.1.8/lib/rack/directory.rb | 205 ---- .../vendored/rack-3.1.8/lib/rack/etag.rb | 68 -- .../vendored/rack-3.1.8/lib/rack/events.rb | 157 --- .../vendored/rack-3.1.8/lib/rack/files.rb | 216 ---- .../vendored/rack-3.1.8/lib/rack/head.rb | 26 - .../vendored/rack-3.1.8/lib/rack/headers.rb | 238 ----- .../vendored/rack-3.1.8/lib/rack/lint.rb | 991 ----------------- .../vendored/rack-3.1.8/lib/rack/lock.rb | 29 - .../vendored/rack-3.1.8/lib/rack/logger.rb | 23 - .../rack-3.1.8/lib/rack/media_type.rb | 48 - .../rack-3.1.8/lib/rack/method_override.rb | 56 - .../vendored/rack-3.1.8/lib/rack/mime.rb | 694 ------------ .../vendored/rack-3.1.8/lib/rack/mock.rb | 3 - .../rack-3.1.8/lib/rack/mock_request.rb | 161 --- .../rack-3.1.8/lib/rack/mock_response.rb | 124 --- .../vendored/rack-3.1.8/lib/rack/multipart.rb | 77 -- .../lib/rack/multipart/generator.rb | 99 -- .../rack-3.1.8/lib/rack/multipart/parser.rb | 502 --------- .../lib/rack/multipart/uploaded_file.rb | 45 - .../rack-3.1.8/lib/rack/null_logger.rb | 48 - .../rack-3.1.8/lib/rack/query_parser.rb | 200 ---- .../vendored/rack-3.1.8/lib/rack/recursive.rb | 66 -- .../vendored/rack-3.1.8/lib/rack/reloader.rb | 112 -- .../vendored/rack-3.1.8/lib/rack/request.rb | 796 -------------- .../vendored/rack-3.1.8/lib/rack/response.rb | 403 ------- .../rack-3.1.8/lib/rack/rewindable_input.rb | 113 -- .../vendored/rack-3.1.8/lib/rack/runtime.rb | 35 - .../vendored/rack-3.1.8/lib/rack/sendfile.rb | 167 --- .../rack-3.1.8/lib/rack/show_exceptions.rb | 407 ------- .../rack-3.1.8/lib/rack/show_status.rb | 123 --- .../vendored/rack-3.1.8/lib/rack/static.rb | 187 ---- .../rack-3.1.8/lib/rack/tempfile_reaper.rb | 33 - .../vendored/rack-3.1.8/lib/rack/urlmap.rb | 99 -- .../vendored/rack-3.1.8/lib/rack/utils.rb | 631 ----------- .../vendored/rack-3.1.8/lib/rack/version.rb | 21 - .../after/vendored/rack-3.1.8/rack.gemspec | 31 - .../before/.bundle/config | 3 - .../stale-checksum-v1-bug/before/Gemfile | 3 - .../stale-checksum-v1-bug/before/Gemfile.lock | 21 - .../before/vendored/rack-3.1.8/CHANGELOG.md | 998 ------------------ .../vendored/rack-3.1.8/CONTRIBUTING.md | 144 --- .../before/vendored/rack-3.1.8/MIT-LICENSE | 20 - .../before/vendored/rack-3.1.8/README.md | 328 ------ .../before/vendored/rack-3.1.8/SPEC.rdoc | 365 ------- .../before/vendored/rack-3.1.8/lib/rack.rb | 66 -- .../lib/rack/auth/abstract/handler.rb | 41 - .../lib/rack/auth/abstract/request.rb | 49 - .../rack-3.1.8/lib/rack/auth/basic.rb | 58 - .../rack-3.1.8/lib/rack/bad_request.rb | 8 - .../rack-3.1.8/lib/rack/body_proxy.rb | 63 -- .../vendored/rack-3.1.8/lib/rack/builder.rb | 290 ----- .../vendored/rack-3.1.8/lib/rack/cascade.rb | 67 -- .../rack-3.1.8/lib/rack/common_logger.rb | 88 -- .../rack-3.1.8/lib/rack/conditional_get.rb | 86 -- .../vendored/rack-3.1.8/lib/rack/config.rb | 22 - .../vendored/rack-3.1.8/lib/rack/constants.rb | 67 -- .../rack-3.1.8/lib/rack/content_length.rb | 34 - .../rack-3.1.8/lib/rack/content_type.rb | 33 - .../vendored/rack-3.1.8/lib/rack/deflater.rb | 158 --- .../vendored/rack-3.1.8/lib/rack/directory.rb | 205 ---- .../vendored/rack-3.1.8/lib/rack/etag.rb | 68 -- .../vendored/rack-3.1.8/lib/rack/events.rb | 157 --- .../vendored/rack-3.1.8/lib/rack/files.rb | 216 ---- .../vendored/rack-3.1.8/lib/rack/head.rb | 26 - .../vendored/rack-3.1.8/lib/rack/headers.rb | 238 ----- .../vendored/rack-3.1.8/lib/rack/lint.rb | 991 ----------------- .../vendored/rack-3.1.8/lib/rack/lock.rb | 29 - .../vendored/rack-3.1.8/lib/rack/logger.rb | 23 - .../rack-3.1.8/lib/rack/media_type.rb | 48 - .../rack-3.1.8/lib/rack/method_override.rb | 56 - .../vendored/rack-3.1.8/lib/rack/mime.rb | 694 ------------ .../vendored/rack-3.1.8/lib/rack/mock.rb | 3 - .../rack-3.1.8/lib/rack/mock_request.rb | 161 --- .../rack-3.1.8/lib/rack/mock_response.rb | 124 --- .../vendored/rack-3.1.8/lib/rack/multipart.rb | 77 -- .../lib/rack/multipart/generator.rb | 99 -- .../rack-3.1.8/lib/rack/multipart/parser.rb | 502 --------- .../lib/rack/multipart/uploaded_file.rb | 45 - .../rack-3.1.8/lib/rack/null_logger.rb | 48 - .../rack-3.1.8/lib/rack/query_parser.rb | 200 ---- .../vendored/rack-3.1.8/lib/rack/recursive.rb | 66 -- .../vendored/rack-3.1.8/lib/rack/reloader.rb | 112 -- .../vendored/rack-3.1.8/lib/rack/request.rb | 796 -------------- .../vendored/rack-3.1.8/lib/rack/response.rb | 403 ------- .../rack-3.1.8/lib/rack/rewindable_input.rb | 113 -- .../vendored/rack-3.1.8/lib/rack/runtime.rb | 35 - .../vendored/rack-3.1.8/lib/rack/sendfile.rb | 167 --- .../rack-3.1.8/lib/rack/show_exceptions.rb | 407 ------- .../rack-3.1.8/lib/rack/show_status.rb | 123 --- .../vendored/rack-3.1.8/lib/rack/static.rb | 187 ---- .../rack-3.1.8/lib/rack/tempfile_reaper.rb | 33 - .../vendored/rack-3.1.8/lib/rack/urlmap.rb | 99 -- .../vendored/rack-3.1.8/lib/rack/utils.rb | 631 ----------- .../vendored/rack-3.1.8/lib/rack/version.rb | 21 - .../before/vendored/rack-3.1.8/rack.gemspec | 31 - spikes/pdm/README.md | 83 -- .../six-1.16.0-py2.py3-none-any.whl | Bin 38806 -> 0 bytes spikes/pdm/direct-path-wheel/after/pdm.lock | 22 - .../direct-path-wheel/after/pyproject.toml | 15 - spikes/pdm/direct-path-wheel/before/pdm.lock | 22 - .../direct-path-wheel/before/pyproject.toml | 15 - spikes/pdm/direct-registry/after/pdm.lock | 22 - .../pdm/direct-registry/after/pyproject.toml | 15 - .../pdm/direct-registry/before/pyproject.toml | 15 - .../six-1.16.0-py2.py3-none-any.whl | Bin 38806 -> 0 bytes spikes/pdm/transitive-path/after/pdm.lock | 36 - .../pdm/transitive-path/after/pyproject.toml | 18 - spikes/pdm/transitive-path/before/pdm.lock | 36 - .../pdm/transitive-path/before/pyproject.toml | 15 - spikes/pdm/transitive-registry/after/pdm.lock | 36 - .../transitive-registry/after/pyproject.toml | 15 - .../transitive-registry/before/pyproject.toml | 15 - spikes/pipenv/README.md | 123 --- spikes/pipenv/artifacts/SHA256SUMS | 2 - .../original-six-1.16.0-py2.py3-none-any.whl | Bin 11053 -> 0 bytes .../patched-six-1.16.0-py2.py3-none-any.whl | Bin 38859 -> 0 bytes .../six-1.16.0-py2.py3-none-any.whl | Bin 38859 -> 0 bytes spikes/pipenv/direct-file/Pipfile | 10 - spikes/pipenv/direct-file/Pipfile.lock | 29 - .../direct-file/Pipfile.lock.lock-only-edit | 28 - spikes/pipenv/direct-registry/Pipfile | 10 - spikes/pipenv/direct-registry/Pipfile.lock | 30 - .../six-1.16.0-py2.py3-none-any.whl | Bin 38859 -> 0 bytes spikes/pipenv/transitive-file/Pipfile | 11 - spikes/pipenv/transitive-file/Pipfile.lock | 38 - .../Pipfile.lock.lock-only-edit | 37 - spikes/pipenv/transitive-registry/Pipfile | 10 - .../pipenv/transitive-registry/Pipfile.lock | 38 - spikes/pnpm/README.md | 76 -- spikes/pnpm/edit_lock.py | 51 - .../p1-multi-dep/after/consumer/package.json | 7 - spikes/pnpm/p1-multi-dep/after/package.json | 15 - spikes/pnpm/p1-multi-dep/after/pnpm-lock.yaml | 45 - .../p1-multi-dep/before/consumer/package.json | 7 - spikes/pnpm/p1-multi-dep/before/package.json | 10 - .../pnpm/p1-multi-dep/before/pnpm-lock.yaml | 42 - .../p4-single-dep-offline/after/package.json | 13 - .../after/pnpm-lock.yaml | 26 - .../p4-single-dep-offline/before/package.json | 8 - .../before/pnpm-lock.yaml | 23 - spikes/pnpm/p7-workspace/after/package.json | 10 - .../after/packages/app/package.json | 7 - spikes/pnpm/p7-workspace/after/pnpm-lock.yaml | 28 - .../p7-workspace/after/pnpm-workspace.yaml | 2 - spikes/pnpm/p7-workspace/before/package.json | 5 - .../before/packages/app/package.json | 7 - .../pnpm/p7-workspace/before/pnpm-lock.yaml | 25 - .../p7-workspace/before/pnpm-workspace.yaml | 2 - spikes/poetry/README.md | 95 -- .../six-1.16.0-py2.py3-none-any.whl | Bin 11196 -> 0 bytes .../lock-2.0-direct/poetry.lock | 20 - .../lock-2.0-direct/pyproject.toml | 10 - .../six-1.16.0-py2.py3-none-any.whl | Bin 11196 -> 0 bytes .../lock-2.0-transitive/poetry.lock | 34 - .../lock-2.0-transitive/pyproject.toml | 10 - .../six-1.16.0-py2.py3-none-any.whl | Bin 11196 -> 0 bytes .../lock-2.1-direct/poetry.lock | 21 - .../lock-2.1-direct/pyproject.toml | 10 - .../six-1.16.0-py2.py3-none-any.whl | Bin 11196 -> 0 bytes .../lock-2.1-transitive/poetry.lock | 36 - .../lock-2.1-transitive/pyproject.toml | 10 - .../six-1.16.0-py2.py3-none-any.whl | Bin 11196 -> 0 bytes .../lock-2.0/direct-path-wheel/poetry.lock | 20 - .../lock-2.0/direct-path-wheel/pyproject.toml | 10 - .../lock-2.0/direct-registry/poetry.lock | 17 - .../lock-2.0/direct-registry/pyproject.toml | 10 - .../six-1.16.0-py2.py3-none-any.whl | Bin 11196 -> 0 bytes .../lock-2.0/transitive-path/poetry.lock | 34 - .../lock-2.0/transitive-path/pyproject.toml | 11 - .../lock-2.0/transitive-registry/poetry.lock | 31 - .../transitive-registry/pyproject.toml | 10 - .../six-1.16.0-py2.py3-none-any.whl | Bin 11196 -> 0 bytes .../lock-2.1/direct-path-wheel/poetry.lock | 21 - .../lock-2.1/direct-path-wheel/pyproject.toml | 10 - .../lock-2.1/direct-registry/poetry.lock | 18 - .../lock-2.1/direct-registry/pyproject.toml | 10 - .../six-1.16.0-py2.py3-none-any.whl | Bin 11196 -> 0 bytes .../lock-2.1/transitive-path/poetry.lock | 36 - .../lock-2.1/transitive-path/pyproject.toml | 11 - .../lock-2.1/transitive-registry/poetry.lock | 33 - .../transitive-registry/pyproject.toml | 10 - .../original/six-1.16.0-py2.py3-none-any.whl | Bin 11053 -> 0 bytes .../patched/six-1.16.0-py2.py3-none-any.whl | Bin 11196 -> 0 bytes spikes/uv/README.md | 54 - spikes/uv/direct-path-wheel/README.md | 3 - spikes/uv/direct-path-wheel/pyproject.toml | 8 - spikes/uv/direct-path-wheel/uv.lock | 22 - spikes/uv/direct-registry/README.md | 2 - spikes/uv/direct-registry/pyproject.toml | 5 - spikes/uv/direct-registry/uv.lock | 23 - spikes/uv/override-transitive/README.md | 3 - spikes/uv/override-transitive/pyproject.toml | 11 - spikes/uv/override-transitive/uv.lock | 37 - spikes/uv/transitive-promoted/README.md | 3 - spikes/uv/transitive-promoted/pyproject.toml | 8 - spikes/uv/transitive-promoted/uv.lock | 38 - spikes/uv/transitive-registry/README.md | 1 - spikes/uv/transitive-registry/pyproject.toml | 5 - spikes/uv/transitive-registry/uv.lock | 35 - spikes/yarn-berry-nm/README.md | 183 ---- .../b1-omitted-checksum/after/yarn.lock | 21 - .../b1-omitted-checksum/before/.yarnrc.yml | 3 - .../b1-omitted-checksum/before/package.json | 11 - .../b1-omitted-checksum/before/yarn.lock | 20 - .../rebuilt-from-tgz.zip | Bin 11599 -> 0 bytes .../rebuilt-modeprobe.zip | Bin 1012 -> 0 bytes ...rn-cache-left-pad-file-8dfd6a0c16-10c0.zip | Bin 11599 -> 0 bytes .../yarn-cache-modeprobe-file-10c0.zip | Bin 1012 -> 0 bytes .../b3-vendored-resolutions/after/.yarnrc.yml | 3 - .../after/package.json | 11 - .../b3-vendored-resolutions/after/yarn.lock | 21 - .../before/.yarnrc.yml | 3 - .../before/package.json | 8 - .../b3-vendored-resolutions/before/yarn.lock | 21 - .../b4-compression-mixed/after/.yarnrc.yml | 4 - .../b4-compression-mixed/after/package.json | 11 - .../b4-compression-mixed/after/yarn.lock | 21 - .../b4-compression-mixed/before/.yarnrc.yml | 3 - .../b4-compression-mixed/before/package.json | 11 - .../b4-compression-mixed/before/yarn.lock | 21 - spikes/yarn-berry-nm/rebuild_zip.py | 82 -- spikes/yarn-classic/README.md | 134 --- .../y1-file-dep-ground-truth/package.json | 1 - .../y1-file-dep-ground-truth/yarn.lock | 7 - .../y2-lock-rewrite/after/package.json | 8 - .../y2-lock-rewrite/after/yarn.lock | 8 - .../y2-lock-rewrite/before/package.json | 1 - .../y2-lock-rewrite/before/yarn.lock | 8 - .../yarn-classic/y4-tamper/after/package.json | 8 - spikes/yarn-classic/y4-tamper/after/yarn.lock | 8 - .../y5-merged-alias/after/dep-a/package.json | 1 - .../y5-merged-alias/after/package.json | 10 - .../y5-merged-alias/after/yarn.lock | 18 - .../y5-merged-alias/before/dep-a/package.json | 1 - .../y5-merged-alias/before/package.json | 1 - .../y5-merged-alias/before/yarn.lock | 18 - .../yarn-classic/y8-berry-sniff/.yarnrc.yml | 1 - .../yarn-classic/y8-berry-sniff/package.json | 8 - spikes/yarn-classic/y8-berry-sniff/yarn.lock | 21 - 365 files changed, 34394 deletions(-) delete mode 100644 spikes/PHASE0-FINDINGS.txt delete mode 100644 spikes/PHASE0-V2-FINDINGS.txt delete mode 100644 spikes/bun/README.md delete mode 100644 spikes/bun/bn1-file-deps/after/bun.lock delete mode 100644 spikes/bun/bn1-file-deps/after/package.json delete mode 100644 spikes/bun/bn1-file-deps/before/package.json delete mode 100644 spikes/bun/bn1-nested/after/bun.lock delete mode 100644 spikes/bun/bn1-nested/after/package.json delete mode 100644 spikes/bun/bn1-nested/before/package.json delete mode 100644 spikes/bun/bn2-overrides/after/bun.lock delete mode 100644 spikes/bun/bn2-overrides/after/package.json delete mode 100644 spikes/bun/bn2-overrides/before/bun.lock delete mode 100644 spikes/bun/bn2-overrides/before/package.json delete mode 100644 spikes/bun/bn2-resolutions/after/bun.lock delete mode 100644 spikes/bun/bn2-resolutions/after/package.json delete mode 100644 spikes/bun/bn3-lock-only/after/bun.lock delete mode 100644 spikes/bun/bn3-lock-only/after/package.json delete mode 100644 spikes/bun/bn3-lock-only/before/bun.lock delete mode 100644 spikes/bun/bn3-lock-only/before/package.json delete mode 100644 spikes/bun/bn4-override-collapse/after/bun.lock delete mode 100644 spikes/bun/bn4-override-collapse/after/package.json delete mode 100644 spikes/bun/bn4-override-collapse/before/bun.lock delete mode 100644 spikes/bun/bn4-override-collapse/before/package.json delete mode 100644 spikes/bun/bn4b-version-key-ignored/after/bun.lock delete mode 100644 spikes/bun/bn4b-version-key-ignored/after/package.json delete mode 100644 spikes/bun/bn4c-targeted-nested/after/bun.lock delete mode 100644 spikes/bun/bn4c-targeted-nested/after/package.json delete mode 100644 spikes/bun/bn4c-targeted-nested/before/bun.lock delete mode 100644 spikes/bun/bn4c-targeted-nested/before/package.json delete mode 100644 spikes/gem-checksums/README.md delete mode 100644 spikes/gem-checksums/bare-checksum-registry-gem/after/.bundle/config delete mode 100644 spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile delete mode 100644 spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile.lock delete mode 100644 spikes/gem-checksums/bare-checksum-registry-gem/before/.bundle/config delete mode 100644 spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile delete mode 100644 spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile.lock delete mode 100644 spikes/gem-checksums/path-with-checksums/after/.bundle/config delete mode 100644 spikes/gem-checksums/path-with-checksums/after/Gemfile delete mode 100644 spikes/gem-checksums/path-with-checksums/after/Gemfile.lock delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CHANGELOG.md delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CONTRIBUTING.md delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/MIT-LICENSE delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/README.md delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/SPEC.rdoc delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/bad_request.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/builder.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/cascade.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/common_logger.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/config.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/constants.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_length.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_type.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/deflater.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/directory.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/etag.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/events.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/files.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/head.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/headers.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lint.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lock.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/logger.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/media_type.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/method_override.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mime.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_request.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_response.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/null_logger.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/query_parser.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/recursive.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/reloader.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/request.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/response.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/runtime.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/sendfile.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_status.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/static.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/urlmap.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/utils.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/version.rb delete mode 100644 spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/rack.gemspec delete mode 100644 spikes/gem-checksums/path-with-checksums/before/.bundle/config delete mode 100644 spikes/gem-checksums/path-with-checksums/before/Gemfile delete mode 100644 spikes/gem-checksums/path-with-checksums/before/Gemfile.lock delete mode 100644 spikes/gem-checksums/registry-with-checksums/after/.bundle/config delete mode 100644 spikes/gem-checksums/registry-with-checksums/after/Gemfile delete mode 100644 spikes/gem-checksums/registry-with-checksums/after/Gemfile.lock delete mode 100644 spikes/gem-checksums/registry-with-checksums/before/.bundle/config delete mode 100644 spikes/gem-checksums/registry-with-checksums/before/Gemfile delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/.bundle/config delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile.lock delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CHANGELOG.md delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CONTRIBUTING.md delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/MIT-LICENSE delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/README.md delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/SPEC.rdoc delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/bad_request.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/builder.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/cascade.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/common_logger.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/config.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/constants.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_length.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_type.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/deflater.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/directory.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/etag.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/events.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/files.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/head.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/headers.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lint.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lock.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/logger.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/media_type.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/method_override.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mime.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_request.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_response.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/null_logger.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/query_parser.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/recursive.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/reloader.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/request.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/response.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/runtime.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/sendfile.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_status.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/static.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/urlmap.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/utils.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/version.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/rack.gemspec delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/.bundle/config delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile.lock delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CHANGELOG.md delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CONTRIBUTING.md delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/MIT-LICENSE delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/README.md delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/SPEC.rdoc delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/basic.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/bad_request.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/body_proxy.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/builder.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/cascade.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/common_logger.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/conditional_get.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/config.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/constants.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_length.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_type.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/deflater.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/directory.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/etag.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/events.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/files.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/head.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/headers.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lint.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lock.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/logger.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/media_type.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/method_override.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mime.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_request.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_response.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/generator.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/parser.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/null_logger.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/query_parser.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/recursive.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/reloader.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/request.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/response.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/rewindable_input.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/runtime.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/sendfile.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_exceptions.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_status.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/static.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/urlmap.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/utils.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/version.rb delete mode 100644 spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/rack.gemspec delete mode 100644 spikes/pdm/README.md delete mode 100644 spikes/pdm/direct-path-wheel/after/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/pdm/direct-path-wheel/after/pdm.lock delete mode 100644 spikes/pdm/direct-path-wheel/after/pyproject.toml delete mode 100644 spikes/pdm/direct-path-wheel/before/pdm.lock delete mode 100644 spikes/pdm/direct-path-wheel/before/pyproject.toml delete mode 100644 spikes/pdm/direct-registry/after/pdm.lock delete mode 100644 spikes/pdm/direct-registry/after/pyproject.toml delete mode 100644 spikes/pdm/direct-registry/before/pyproject.toml delete mode 100644 spikes/pdm/transitive-path/after/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/pdm/transitive-path/after/pdm.lock delete mode 100644 spikes/pdm/transitive-path/after/pyproject.toml delete mode 100644 spikes/pdm/transitive-path/before/pdm.lock delete mode 100644 spikes/pdm/transitive-path/before/pyproject.toml delete mode 100644 spikes/pdm/transitive-registry/after/pdm.lock delete mode 100644 spikes/pdm/transitive-registry/after/pyproject.toml delete mode 100644 spikes/pdm/transitive-registry/before/pyproject.toml delete mode 100644 spikes/pipenv/README.md delete mode 100644 spikes/pipenv/artifacts/SHA256SUMS delete mode 100644 spikes/pipenv/artifacts/original-six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/pipenv/artifacts/patched-six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/pipenv/direct-file/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/pipenv/direct-file/Pipfile delete mode 100644 spikes/pipenv/direct-file/Pipfile.lock delete mode 100644 spikes/pipenv/direct-file/Pipfile.lock.lock-only-edit delete mode 100644 spikes/pipenv/direct-registry/Pipfile delete mode 100644 spikes/pipenv/direct-registry/Pipfile.lock delete mode 100644 spikes/pipenv/transitive-file/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/pipenv/transitive-file/Pipfile delete mode 100644 spikes/pipenv/transitive-file/Pipfile.lock delete mode 100644 spikes/pipenv/transitive-file/Pipfile.lock.lock-only-edit delete mode 100644 spikes/pipenv/transitive-registry/Pipfile delete mode 100644 spikes/pipenv/transitive-registry/Pipfile.lock delete mode 100644 spikes/pnpm/README.md delete mode 100644 spikes/pnpm/edit_lock.py delete mode 100644 spikes/pnpm/p1-multi-dep/after/consumer/package.json delete mode 100644 spikes/pnpm/p1-multi-dep/after/package.json delete mode 100644 spikes/pnpm/p1-multi-dep/after/pnpm-lock.yaml delete mode 100644 spikes/pnpm/p1-multi-dep/before/consumer/package.json delete mode 100644 spikes/pnpm/p1-multi-dep/before/package.json delete mode 100644 spikes/pnpm/p1-multi-dep/before/pnpm-lock.yaml delete mode 100644 spikes/pnpm/p4-single-dep-offline/after/package.json delete mode 100644 spikes/pnpm/p4-single-dep-offline/after/pnpm-lock.yaml delete mode 100644 spikes/pnpm/p4-single-dep-offline/before/package.json delete mode 100644 spikes/pnpm/p4-single-dep-offline/before/pnpm-lock.yaml delete mode 100644 spikes/pnpm/p7-workspace/after/package.json delete mode 100644 spikes/pnpm/p7-workspace/after/packages/app/package.json delete mode 100644 spikes/pnpm/p7-workspace/after/pnpm-lock.yaml delete mode 100644 spikes/pnpm/p7-workspace/after/pnpm-workspace.yaml delete mode 100644 spikes/pnpm/p7-workspace/before/package.json delete mode 100644 spikes/pnpm/p7-workspace/before/packages/app/package.json delete mode 100644 spikes/pnpm/p7-workspace/before/pnpm-lock.yaml delete mode 100644 spikes/pnpm/p7-workspace/before/pnpm-workspace.yaml delete mode 100644 spikes/poetry/README.md delete mode 100644 spikes/poetry/evidence-lockonly/lock-2.0-direct/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/poetry/evidence-lockonly/lock-2.0-direct/poetry.lock delete mode 100644 spikes/poetry/evidence-lockonly/lock-2.0-direct/pyproject.toml delete mode 100644 spikes/poetry/evidence-lockonly/lock-2.0-transitive/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/poetry/evidence-lockonly/lock-2.0-transitive/poetry.lock delete mode 100644 spikes/poetry/evidence-lockonly/lock-2.0-transitive/pyproject.toml delete mode 100644 spikes/poetry/evidence-lockonly/lock-2.1-direct/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/poetry/evidence-lockonly/lock-2.1-direct/poetry.lock delete mode 100644 spikes/poetry/evidence-lockonly/lock-2.1-direct/pyproject.toml delete mode 100644 spikes/poetry/evidence-lockonly/lock-2.1-transitive/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/poetry/evidence-lockonly/lock-2.1-transitive/poetry.lock delete mode 100644 spikes/poetry/evidence-lockonly/lock-2.1-transitive/pyproject.toml delete mode 100644 spikes/poetry/lock-2.0/direct-path-wheel/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/poetry/lock-2.0/direct-path-wheel/poetry.lock delete mode 100644 spikes/poetry/lock-2.0/direct-path-wheel/pyproject.toml delete mode 100644 spikes/poetry/lock-2.0/direct-registry/poetry.lock delete mode 100644 spikes/poetry/lock-2.0/direct-registry/pyproject.toml delete mode 100644 spikes/poetry/lock-2.0/transitive-path/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/poetry/lock-2.0/transitive-path/poetry.lock delete mode 100644 spikes/poetry/lock-2.0/transitive-path/pyproject.toml delete mode 100644 spikes/poetry/lock-2.0/transitive-registry/poetry.lock delete mode 100644 spikes/poetry/lock-2.0/transitive-registry/pyproject.toml delete mode 100644 spikes/poetry/lock-2.1/direct-path-wheel/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/poetry/lock-2.1/direct-path-wheel/poetry.lock delete mode 100644 spikes/poetry/lock-2.1/direct-path-wheel/pyproject.toml delete mode 100644 spikes/poetry/lock-2.1/direct-registry/poetry.lock delete mode 100644 spikes/poetry/lock-2.1/direct-registry/pyproject.toml delete mode 100644 spikes/poetry/lock-2.1/transitive-path/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/poetry/lock-2.1/transitive-path/poetry.lock delete mode 100644 spikes/poetry/lock-2.1/transitive-path/pyproject.toml delete mode 100644 spikes/poetry/lock-2.1/transitive-registry/poetry.lock delete mode 100644 spikes/poetry/lock-2.1/transitive-registry/pyproject.toml delete mode 100644 spikes/poetry/wheels/original/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/poetry/wheels/patched/six-1.16.0-py2.py3-none-any.whl delete mode 100644 spikes/uv/README.md delete mode 100644 spikes/uv/direct-path-wheel/README.md delete mode 100644 spikes/uv/direct-path-wheel/pyproject.toml delete mode 100644 spikes/uv/direct-path-wheel/uv.lock delete mode 100644 spikes/uv/direct-registry/README.md delete mode 100644 spikes/uv/direct-registry/pyproject.toml delete mode 100644 spikes/uv/direct-registry/uv.lock delete mode 100644 spikes/uv/override-transitive/README.md delete mode 100644 spikes/uv/override-transitive/pyproject.toml delete mode 100644 spikes/uv/override-transitive/uv.lock delete mode 100644 spikes/uv/transitive-promoted/README.md delete mode 100644 spikes/uv/transitive-promoted/pyproject.toml delete mode 100644 spikes/uv/transitive-promoted/uv.lock delete mode 100644 spikes/uv/transitive-registry/README.md delete mode 100644 spikes/uv/transitive-registry/pyproject.toml delete mode 100644 spikes/uv/transitive-registry/uv.lock delete mode 100644 spikes/yarn-berry-nm/README.md delete mode 100644 spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/after/yarn.lock delete mode 100644 spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/.yarnrc.yml delete mode 100644 spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/package.json delete mode 100644 spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/yarn.lock delete mode 100644 spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/rebuilt-from-tgz.zip delete mode 100644 spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/rebuilt-modeprobe.zip delete mode 100644 spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-left-pad-file-8dfd6a0c16-10c0.zip delete mode 100644 spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-modeprobe-file-10c0.zip delete mode 100644 spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/.yarnrc.yml delete mode 100644 spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/package.json delete mode 100644 spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/yarn.lock delete mode 100644 spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/.yarnrc.yml delete mode 100644 spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/package.json delete mode 100644 spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/yarn.lock delete mode 100644 spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/.yarnrc.yml delete mode 100644 spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/package.json delete mode 100644 spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/yarn.lock delete mode 100644 spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/.yarnrc.yml delete mode 100644 spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/package.json delete mode 100644 spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/yarn.lock delete mode 100644 spikes/yarn-berry-nm/rebuild_zip.py delete mode 100644 spikes/yarn-classic/README.md delete mode 100644 spikes/yarn-classic/y1-file-dep-ground-truth/package.json delete mode 100644 spikes/yarn-classic/y1-file-dep-ground-truth/yarn.lock delete mode 100644 spikes/yarn-classic/y2-lock-rewrite/after/package.json delete mode 100644 spikes/yarn-classic/y2-lock-rewrite/after/yarn.lock delete mode 100644 spikes/yarn-classic/y2-lock-rewrite/before/package.json delete mode 100644 spikes/yarn-classic/y2-lock-rewrite/before/yarn.lock delete mode 100644 spikes/yarn-classic/y4-tamper/after/package.json delete mode 100644 spikes/yarn-classic/y4-tamper/after/yarn.lock delete mode 100644 spikes/yarn-classic/y5-merged-alias/after/dep-a/package.json delete mode 100644 spikes/yarn-classic/y5-merged-alias/after/package.json delete mode 100644 spikes/yarn-classic/y5-merged-alias/after/yarn.lock delete mode 100644 spikes/yarn-classic/y5-merged-alias/before/dep-a/package.json delete mode 100644 spikes/yarn-classic/y5-merged-alias/before/package.json delete mode 100644 spikes/yarn-classic/y5-merged-alias/before/yarn.lock delete mode 100644 spikes/yarn-classic/y8-berry-sniff/.yarnrc.yml delete mode 100644 spikes/yarn-classic/y8-berry-sniff/package.json delete mode 100644 spikes/yarn-classic/y8-berry-sniff/yarn.lock diff --git a/spikes/PHASE0-FINDINGS.txt b/spikes/PHASE0-FINDINGS.txt deleted file mode 100644 index 8e1a7e9..0000000 --- a/spikes/PHASE0-FINDINGS.txt +++ /dev/null @@ -1,160 +0,0 @@ -=== cargo (cargo 1.93.1 (083ac5135 2025-12-15)) === - [CONFIRMED] 1. config [patch] + untouched Cargo.lock: cargo build --locked fails - Exit 101. Verbatim: 'error: cannot update the lock file /private/tmp/socket-vendor-spike.luzrbh/app/Cargo.lock because --locked was passed to prevent this' + 'help: to generate the lock file without accessing the network, remove the --locked flag and use --offline instead.' Note the error is generic (never mentions the patch), so user-facing diagnostics must explain it. - [CONFIRMED] 2. Surgical lock edit (delete source+checksum lines from cfg-if [[package]] entry only) makes cargo - After removing exactly the 'source = ...' and 'checksum = ...' lines, cargo build --locked compiled 'cfg-if v1.0.4 (...app/.socket/vendor/cargo/9f6b2c4e-.../cfg-if-1.0.4)' and the binary printed 'patched marker = 1'. Lock format: version = 4. CAVEAT: first attempt failed compile because path deps do NOT get --cap-lints allow, so cfg-if's own #![deny(missing_docs)] fired on the - [CONFIRMED] 3. Fresh checkout (Cargo.toml, Cargo.lock, .cargo/config.toml, src/, .socket/ only) + empty CARGO_HO - CARGO_HOME= cargo build --locked --offline succeeded (exit 0), compiled cfg-if from the vendored .socket path, binary printed 'patched marker = 1'. The empty CARGO_HOME stayed completely empty afterward — zero registry/network access. Committable-guarantee proven for cargo. - [REFUTED] 4. cargo rewrites Cargo.lock after a successful build - Byte-identical (cmp clean) after both 'cargo build --locked' and plain 'cargo build'. Cargo accepts the surgically edited entry (name+version, no source = path/patched package) as canonical and never re-adds source/checksum or reformats — the edited lock is stable across builds run from inside the project. - [CONFIRMED] 5. Equal-version path patch: no [[patch.unused]] in Cargo.lock and patch takes effect - grep -c 'patch.unused' Cargo.lock = 0; vendored path appears in the Compiling line and SOCKET_PATCHED resolves. With the surgical edit, the path patch becomes the lock's sole provider of cfg-if 1.0.4; cargo records nothing extra in the lock for an equal-version [patch] from config.toml. - [PARTIAL] 6. Version drift (vendored Cargo.toml bumped to 1.0.999, [patch] kept): produces [[patch.unused]] or - No [[patch.unused]] in any mode. With --locked: fails closed, exit 101, same generic 'cannot update the lock file ... because --locked was passed' error (good guardrail, bad diagnostic). WITHOUT --locked: NO error — cargo silently re-locks: 'Updating cfg-if v1.0.4 (...vendor path...) -> v1.0.999', rewrites the lock entry to version = "1.0.999", and builds the vendored 1.0.999 ( - [CONFIRMED] 7. Relative path in .cargo/config.toml [patch] resolves against project root, verified building from - cd app/src && cargo build --locked succeeded, compiling cfg-if from /private/tmp/.../app/.socket/... ; cargo metadata run from src/ shows cfg-if manifest_path under the app root. Cargo resolves config-relative paths against the directory containing the .cargo/ dir of the config file, not the cwd. IMPORTANT LIMIT (probed): config discovery walks UP from cwd, so 'cd /tmp && cargo - [CONFIRMED] 8. Fresh cargo 1.93 lock has version = 4 and dependencies arrays reference cfg-if as plain name - Pristine lock generated by cargo 1.93.1: 'version = 4' on line 3; app's dependencies array is exactly ["cfg-if"] — no version or source suffix (v4 locks only add suffixes when multiple versions/sources need disambiguation). Consequence: the surgical edit needs to touch ONLY the [[package]] entry's source/checksum lines; no dependencies-array rewriting needed in the single-versi - SURPRISES: - - cargo 1.93's ~/.cargo/registry/src//-/ dirs contain NO .cargo-checksum.json (only .cargo-ok and .cargo_vcs_info.json) — the planned 'delete .cargo-checksum.json' step was a no-op; don't assume that file exists when sourcing from registry/src. - - Path dependencies are built WITHOUT --cap-lints allow (registry deps get it), so the crate's own #![deny(...)] lints fire on patched code — the un-doc'd appended const broke the build with 'error: missing documentation for a constant'. Vendored patches must be lint-clean against each crate's own deny lints, or the buil - - Silent-unpatch vector: invoking cargo from OUTSIDE the project (cwd above the .cargo/ dir, e.g. --manifest-path from elsewhere) skips config discovery entirely; an unlocked build then silently re-adds source+checksum to Cargo.lock and compiles the unpatched registry crate with no warning. - - Unlocked builds silently absorb vendored-version drift (re-lock 1.0.4 -> 1.0.999, no [[patch.unused]], no warning) as long as the semver requirement still matches; only --locked catches it, and with a generic error that never mentions the patch. - - The surgically edited lock is fully stable: cargo treats the source-less entry as the path-patched package and never rewrites/canonicalizes it on later builds (locked or unlocked, from inside the project). - REC: The design is viable for cargo: vendored copy under .socket/vendor/cargo//-/ + [patch.crates-io] in .cargo/config.toml + a surgical Cargo.lock edit (delete only the source and checksum lines of the patched [[package]] entry) yields a committable, byte-stable lock and a proven fresh-checkout offline-from-Socket build (empty CARGO_HOME untouched). Implement with these guards: (1) keep the vendored Cargo.toml version byte-identical to the locked version — any drift is silently re-locked on unlocked builds; (2) ensure patch hunks - FIXTURES: /tmp/socket-vendor-spike.luzrbh (primary app + Cargo.lock.pristine/.edited snapshots); /tmp/socket-vendor-fresh.LcVAth (fresh-checkout copy, vendored version drifted to 1.0.999); /tmp/socket-vendor-cargohome.0MFucL (empty CARGO_HOME used for offline proof) - -=== golang (go (go1.26.3 darwin/arm64); GOPROXY default https://proxy.go) === - [CONFIRMED] 1. go build succeeds with the patched func; uuid path level causes no issues - replace github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 => ./.socket/vendor/golang/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/github.com/google/shlex@v0.0.0-20191202100458-e7afc7fbc510 in /tmp/socket-vendor-spike.DB8QiH/go.mod. go build exit 0; binary ran and printed '[a b c d] true' (true = shlex.SocketPatched(), the func added only to the vendored copy). Uuid segment and - [CONFIRMED] 2. Fresh-offline: only go.mod+go.sum+main.go+.socket/, empty GOMODCACHE, GOPROXY=off builds - Copied exactly those 4 paths to /tmp/socket-vendor-fresh.1DlNsA; GOMODCACHE= GOPROXY=off go build → exit 0, ran patched output. Stronger than claimed: after the build the empty GOMODCACHE contained NOTHING (find showed only the bare dir) — directory-replaced modules bypass the module cache and sumdb entirely; no download, no go.sum check for the replaced module. - [CONFIRMED] 3. go.sum still contains the original module lines — harmless? - Both original lines (h1: and /go.mod) remained; offline build exit 0 and 'go mod verify' → 'all modules verified' exit 0 with them present. Also proved the entry is unnecessary: deleted go.sum entirely → offline build still exit 0 and go.sum was NOT recreated (only dep is directory-replaced). Stale lines are inert; safe to leave committed. - [CONFIRMED] 4. go mod tidy with GOPROXY=off in fresh dir: keeps replace? prunes go.sum? builds after? - GOMODCACHE= GOPROXY=off go mod tidy → exit 0 (no network needed). Replace line kept verbatim in go.mod; go.sum TRUNCATED to a 0-byte file (kept, not deleted) — the original lines are pruned because the replaced module needs no sum. Subsequent offline build exit 0, patched output. So users running tidy won't break the vendoring, but an empty go.sum file will sit in the re - [PARTIAL] 5. A second replace for the SAME module in go.mod is rejected by go - Only same-LHS + DIFFERENT-RHS is rejected, and only at build (not parse): 'go: conflicting replacements for github.com/google/shlex@v0.0.0-20191202100458-e7afc7fbc510:' then both paths listed, exit 1. Two SURPRISE acceptances: (a) byte-identical duplicate replace lines → exit 0, both lines stay in go.mod (go never dedupes/rejects; a vendor tool must self-dedupe before appending - [CONFIRMED] 6. The replace path needs './' prefix - Without './' go rejects at go.mod PARSE time, exit 1, but the message depends on the path. With the proposed layout (dir name ends in '@'): 'go: errors parsing go.mod:\ngo.mod:7: replacement module must match format ''path version'', not ''path@version''' — go treats the RHS as a module path and chokes on the @, a confusing error. With an @-free dir name the canonical - SURPRISES: - - go silently accepts byte-identical duplicate replace lines (exit 0, both kept) — the vendor tool cannot rely on go to catch double-application; it must parse and dedupe existing replace directives itself. Conflicting-RHS duplicates fail only at build/load time, not when editing go.mod. - - A pre-existing user versionless 'replace mod => path' is silently shadowed by the tool's versioned replace (versioned LHS wins, no warning) — vendor add/remove must detect and surface existing replaces for the same module. - - go mod tidy truncates go.sum to a 0-byte file (does not delete it) when all deps are directory-replaced; tidy is fully offline-safe here. - - Directory-replaced modules write nothing to GOMODCACHE at all — the offline guarantee is total for the replaced module (no download, no sumdb, no cache). - - The '@' suffix in the vendored dir name changes the missing-'./' failure mode to a misleading parse error ('must match format path version, not path@version') instead of the canonical directory-path error; with './' prefix the @ is harmless. - - Untested caveat: go mod init wrote 'go 1.26.3' into go.mod; a fresh checkout on an older toolchain with GOPROXY=off may fail trying to fetch a newer toolchain (GOTOOLCHAIN) — orthogonal to vendoring but affects the 'offline fresh checkout' guarantee. - REC: The Go design is viable as specified: a versioned directory replace pointing at ./.socket/vendor/golang//@/ gives a fully offline, cache-independent, go.sum-free patched build that survives go mod tidy. Implementation requirements surfaced by the spike: (1) always emit the RHS with a literal './' prefix (a bare '.socket/...' path fails at parse, with an extra-confusing error because of the '@' in the dir name); (2) the tool must own replace-directive idempotency — parse go.mod and update-or-skip rather than append, since - FIXTURES: /tmp/socket-vendor-spike.DB8QiH (primary app; fresh-checkout copy at /tmp/socket-vendor-fresh.1DlNsA, tidy/dup/precedence/no-prefix variants at /tmp/socket-vendor-{tidy,dup,dup2,dup3,prec,nopfx,nopfx2}.*) - -=== uv (uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin), CPyt) === - [CONFIRMED] 1. [tool.uv.sources] path-wheel entry produces a stable, capturable lock shape - uv lock exit 0. six [[package]] VERBATIM: name="six" / version="1.16.0" / source = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } / wheels = [ { filename = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d..." } ]. Wheels element keys are ONLY filename+hash (no url/path/size/upload-time); sdist line dropped; versio - [CONFIRMED] 2. Surgical text edit of the registry lock is viable (byte-match + accepted by uv) - Text surgery on direct-registry uv.lock (replace requires-dist line + six source/sdist/wheels block) reproduced the uv-generated path-wheel lock BYTE-IDENTICALLY (sha256 860d8fd3... both). With sources-bearing pyproject: uv lock --check exit 0; uv sync --locked exit 0 and installed '+ six==1.16.0 (from file:///...whl)'; plain uv sync left the lock byte-identical (same sha256 be - [CONFIRMED] 3. Fresh checkout + empty cache + uv sync --frozen --offline installs the patched vendored wheel - Patched wheel (marker prepended to six.py, RECORD line rewritten with urlsafe-b64 sha256 + new size, rezipped). Copy containing ONLY pyproject/uv.lock/.socket, UV_CACHE_DIR=fresh mktemp: uv sync --frozen --offline exit 0, 'Installed 1 package ... + six==1.16.0 (from file:///...9f6b.../six-1.16.0-py2.py3-none-any.whl)'; .venv python imports six 1.16.0; site-packages six.py line - [CONFIRMED] 4. Tampered vendored wheel is detected by uv sync --frozen - Two modes. (a) Valid-zip content tamper (lock hash stale): exit 1, 'Hash mismatch for `six @ file:///...whl` Expected: sha256:5ddd5223... Computed: sha256:7aec6932...'. (b) Raw byte-flip at offset 100: fails BEFORE hash check with 'Failed to extract archive: six-1.16.0-py2.py3-none-any.whl ... deflate decompression error: invalid distances set', exit 1. Either way install fai - [CONFIRMED] 5. Transitive promotion (add six==1.16.0 to project.dependencies + sources) works and is stable - uv lock: 'Updated six v1.17.0 -> v1.16.0'. Root dependencies VERBATIM: [ { name = "python-dateutil" }, { name = "six" } ]. requires-dist VERBATIM: [ { name = "python-dateutil", specifier = "==2.8.2" }, { name = "six", path = ".socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl" } ]. python-dateutil's own dependencies = [{ name = "six" }] is satisfied by the path source. - [CONFIRMED] 6. Lock-only transitive edit (hand-edit six [[package]] to path source, pyproject untouched) - PASSES everything that doesn't re-resolve six: uv lock --check exit 0, uv sync --locked exit 0 (installs from vendored wheel), uv sync --frozen exit 0, plain uv sync byte-stable, even plain uv lock preserves the path source. BUT uv lock --upgrade and --upgrade-package six silently revert ('Updated six v1.16.0 -> v1.17.0', registry source) with exit 0. So lock-only vendoring of - [REFUTED] 7. [tool.uv.sources] entry for an undeclared package: warning or hard error? - NEITHER — silently ignored, exit 0, zero diagnostics. Tested both: (a) six transitive-only (via python-dateutil): lock keeps six at registry 1.17.0, sources path entry has NO effect; (b) six absent from graph entirely (dependencies = []): lock contains only proj, no error. Implementation implication: a sources entry alone never vendors anything and uv won't tell you; must pair - [CONFIRMED] 8. tool.uv.sources applies to [tool.uv] override-dependencies - YES — this is the no-promotion path for transitive deps. override-dependencies = ["six==1.16.0"] + sources path entry, six NOT in project.dependencies: lock gains [manifest] section VERBATIM: overrides = [{ name = "six", path = ".socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl" }] (path replaces specifier there too); six [[package]] gets the same path source + filenam - [CONFIRMED] 9. Silent-revert risk: registry pyproject + path-source lock, plain uv sync - Risk is REAL. pyproject (no sources) + path lock: plain uv sync exit 0, NO warning, re-resolves and REWRITES the lock back to registry source (sha 860d8fd3 -> c7844e44, source = { registry = ... }), installs the REGISTRY wheel ('+ six==1.16.0' without 'from file://'). Patched code silently replaced by upstream. Mitigation exists: uv lock --check on that combo exits 1 with 'The - [CONFIRMED] 10. [manifest]/members shape of a single-project lock - Single-project locks (virtual AND packaged/hatchling) contain NO [manifest] section and no members key at all. [manifest] materializes only to persist resolver inputs, e.g. overrides (claim 8). Root package source: { virtual = "." } without build-system, { editable = "." } with one. Implementation should not expect/require [manifest] in single-project locks. - SURPRISES: - - Plain `uv lock` does NOT re-hash a path wheel whose bytes changed at an unchanged path — it kept the stale registry hash after we patched the wheel (lock validation never re-reads the file). Must run `uv lock --upgrade-package `, delete+regenerate, or surgically write the new sha256 (verified equivalent since --c - - Lock-only transitive vendoring (claim 6) is far more durable than expected: plain `uv lock` AND plain `uv sync` both preserve a hand-edited path source with zero pyproject support — only --upgrade/--upgrade-package silently destroys it. - - An unmatched [tool.uv.sources] entry is 100% silent (no warning) in uv 0.11.19 — easy to ship a vendor edit that does nothing if the package isn't directly declared or overridden. - - override-dependencies + sources is a clean transitive-vendoring channel that avoids polluting [project] dependencies and records itself in [manifest].overrides — arguably the best fit for socket-patch's transitive case, but note overrides apply tree-wide (force six==1.16.0 everywhere). - - requires-dist/overrides DROP the version specifier when a path source is applied (path replaces specifier); reverting a vendored dep must restore '==X.Y.Z' from socket-patch's own records, not from the lock. - - A raw byte-flip tamper fails as a zip 'deflate decompression error', not a hash mismatch — tooling that greps for 'Hash mismatch' to classify tamper will miss corrupt-archive cases. - REC: GO for uv. The committable guarantee holds: fresh checkout (pyproject + uv.lock + .socket only) with empty UV_CACHE_DIR installs the patched wheel via `uv sync --frozen --offline` for the vendored dep while other deps come from the registry, and the lock hash fails closed on tamper. Implementation design pinned by this spike: (1) edits must be PAIRED — write the [tool.uv.sources] path entry into pyproject.toml AND the lock entry, because a path lock without the pyproject entry is silently reverted by plain `uv sync` (claim 9), and a sources ent - FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/uv - -=== pip (python3.14 + pip 26.0 + uv 0.11.19 (wheel-rebuild vendor spi) === - [CONFIRMED] 1. pip install of rebuilt wheel into fresh venv; pip check; marker; clean uninstall - Rebuilt six-1.16.0-py2.py3-none-any.whl (members: LICENSE, METADATA, WHEEL, top_level.txt, six.py + regenerated RECORD; excluded __pycache__/six.cpython-314.pyc, INSTALLER, RECORD, REQUESTED; direct_url.json absent for registry installs). pip install --no-index exit 0 'Successfully installed six-1.16.0'; pip check 'No broken requirements found.'; installed six.py ends with '# S - [CONFIRMED] 2. uv pip install accepts the rebuilt wheel (strict RECORD validation) - uv venv + uv pip install --no-index : exit 0, 'Installed 1 package ... + six==1.16.0 (from file:///...9f6b2c4e.../six-1.16.0-py2.py3-none-any.whl)', ZERO warnings, marker present, import ok. Caveat from negative controls: uv 0.11.19's 'strict' RECORD check = RECORD must EXIST and parse (absent RECORD fails: 'failed to open file .../six-1.16.0.dist-info/RECORD: No such fi - [PARTIAL] 3. Bare relative path line in requirements.txt: works from project root; resolution base from anothe - ANSWER: both pip and uv resolve bare relative paths against the CURRENT WORKING DIRECTORY, NOT the requirements-file directory. From project root: pip exit 0, uv exit 0, both install from the vendor wheel. From a different cwd with -r /abs/path/requirements.txt: pip exit 1 — 'WARNING: Requirement ./.socket/...whl looks like a filename, but the file does not exist' then 'ERROR: - [CONFIRMED] 4. Path line + --hash=sha256:: accepted outside hash-mode; installs under --require-hashes; wro - Outside hash-mode (no --require-hashes): pip exit 0, uv exit 0 — accepted. With --require-hashes + correct hash: pip exit 0, uv exit 0. Wrong hash + --require-hashes: pip exit 1 'ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE ... Expected sha256 0000... Got f75f...'; uv exit 1 'Hash mismatch for six @ file:///... Expected: sha256:0000... Computed: sha2 - [CONFIRMED] 5. Trailing comment '...--hash=... # socket-patch vendor: six==1.16.0' accepted by both - Line './.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl --hash=sha256:f75f... # socket-patch vendor: six==1.16.0' installed under --require-hashes: pip exit 0, uv exit 0. Comment (whitespace + '#') stripped by both parsers; hash still enforced. - [CONFIRMED] 6. Mixed requirements (python-dateutil==2.8.2 + bare six wheel path): six from vendor wheel, dateuti - pip: downloaded only python_dateutil-2.8.2 from PyPI, six 'Processing ./.socket/...whl' (local); dateutil's six>=1.5 dep satisfied by the vendored wheel (no PyPI six download); pip check clean; marker True; dateutil.parser works. uv: 'Resolved 2 packages', '+ six==1.16.0 (from file:///...9f6b2c4e.../six-1.16.0-py2.py3-none-any.whl)'; marker True; dateutil works. Local path pin - [CONFIRMED] 7. pycowsay console-script regeneration from rebuilt wheel - Installed pycowsay 0.0.0.2 RECORD confirmed to contain '../../../bin/pycowsay' (console script, matched+excluded via entry_points.txt [console_scripts]). Rebuilt wheel (no bin entry, entry_points.txt retained) installed into fresh venvs: pip regenerated venv/bin/pycowsay (-rwxr-xr-x, 209B shebang wrapper) and it runs (exit 0); uv likewise regenerated bin/pycowsay and it runs; p - [REFUTED] 8. A bare PATH line cannot carry '; marker' (parse error justifies refusal) - NO parse error from either tool. './.socket/.../six-1.16.0-py2.py3-none-any.whl ; python_version >= "3.8"' installed: pip 26.0 exit 0, uv 0.11.19 exit 0. The marker is genuinely EVALUATED, not swallowed: with false marker '; python_version < "3"' pip prints 'Ignoring six: markers python_version < "3" don't match your environment' (exit 0, six NOT installed) and uv resolves 0 pa - SURPRISES: - - LOAD-BEARING: bare relative paths in requirements files resolve against the invoking process's CWD in BOTH pip 26.0 and uv 0.11.19 — never against the requirements-file directory. 'pip install -r /abs/req.txt' from outside the project breaks vendoring (verbatim errors in claim 3). The committable guarantee holds only f - - Claim 8 is the opposite of expected: '; marker' on a bare path line parses AND evaluates correctly in both tools (false marker skips the install). The planned refusal cannot be justified by a parse error on current toolchains. - - Neither pip 26.0 nor uv 0.11.19 verifies RECORD per-file sha256 (or completeness) at install time — a wheel with tampered six.py and stale RECORD installed cleanly in both. RECORD must merely exist (absent RECORD hard-fails both). So the regenerated RECORD's correctness matters for uninstall bookkeeping and auditabilit - - pycowsay's RECORD carries a second '../' entry that is NOT a console script: '../../../share/man/man6/pycowsay.6' (wheel .data/data payload). Console-scripts exclusion alone is insufficient; in this spike it was dropped only because splitext('pycowsay.6') accidentally collided with the script name 'pycowsay'. Rebuild l - - pip enables hash-checking implicitly whenever any requirement carries --hash (wrong hash fails even without --require-hashes); uv behaves the same. Adding --hash to the vendor line therefore hardens every install, not just --require-hashes workflows. - - The wheel rebuild is byte-for-byte deterministic across runs (identical sha256), so the emitted --hash pin is stable — regenerating the wheel from the same patched tree never churns requirements.txt. - - direct_url.json did not exist for a normal registry install (only INSTALLER/REQUESTED/RECORD/__pycache__ needed excluding); keep it in the exclude list anyway for path/URL-installed origins. - REC: The PyPI vendor design is viable with three adjustments. (1) Write the requirements line exactly as tested — './.socket/vendor/pypi// --hash=sha256: # socket-patch vendor: ==' — the trailing comment and hash are safe in both tools and the deterministic rebuild keeps the hash stable; but document/enforce that installs must run from the project root, since both pip and uv resolve the path against CWD, not the requirements file (claim 3 is the redesign-grade constraint). (2) In the rebuilder, handle ALL out-of-tree (' - FIXTURES: /tmp/socket-vendor-spike.WburO5 - -=== composer (docker run --rm -v :/app -w /app composer:2 (Composer 2) === - [CONFIRMED] 1. rm -rf vendor; composer install from lock alone installs the PATCHED copy - Exit 0. Output: 'Installing psr/log (3.0.2): Mirroring from .socket/vendor/composer/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/psr/log@3.0.2'. Marker '// SOCKET-PATCH-MARKER 9f6b2c4e' present at line 2 of vendor/psr/log/src/LoggerInterface.php. composer.json untouched (still requires psr/log 3.0.2 from packagist); only the lock packages[] entry was rewritten to dist {type:path,url: 3.0.2 f16e1d5)', rewrites the lock entry back to zip dist + git so - [CONFIRMED] 8. Lock records for mixed-case package names - Two fixtures. (a) require 'Psr/Log' in composer.json: composer 2 hard-errors before resolution — 'require.Psr/Log is invalid, it should not contain uppercase characters. Please use psr/log instead.' — so modern locks generated by composer always carry lowercase names for the require side. (b) Hand-written lock with name 'Psr/Log' + lowercase require: install succeeds but emits - SURPRISES: - - composer status reports 'No local changes' for the patched path-mirrored package — it will never surface the patch as drift, but also gives no integrity signal that the vendored copy is intact. - - --prefer-source silently falls back to the path dist with zero warning when source is absent; no flag combination forced a failure. - - Offline install emits a new non-fatal Composer 2.10 notice ('Filter list data could not be fetched ... ignored per policy.ignore-unreachable') — feature output filtering/tests should tolerate it. - - installed.json drops a null dist.reference key entirely but preserves a dummy string verbatim — null/omitted are the cleanest choices. - - Mixed-case lock names install to a case-preserved vendor path (vendor/Psr/Log) and trip the lock-freshness warning against a lowercase require — lock-name matching in our tool must be case-insensitive while preserving the original casing. - - composer update psr/log reports the revert as 'Upgrading psr/log (3.0.2 => 3.0.2 f16e1d5)' — same version, reference-only change — and silently discards the patch; this is the main UX hazard of the design. - REC: Design is viable for Composer; ship it. Lock-only surgery (dist→{type:path,url:relative .socket path,reference:null}, del source, add transport-options{symlink:false}) gives a committable, offline-reproducible, real-copy install with no composer.json edits and no content-hash invalidation; composer 2.10.1 confirmed every claim. Required implementation details: (1) always set transport-options.symlink:false or you may get a symlink instead of a copy; (2) use reference:null or omit it; (3) match lock package names case-insensitively but preserve - FIXTURES: /tmp/socket-vendor-spike.QhIIEk (main; lock backup at /tmp/socket-vendor-lock-backup.json), /tmp/socket-vendor-fresh.uoqZ0T (offline fresh-checkout proof), /tmp/socket-vendor-case.ZhhAFa + /tmp/socket-vendor-case2.eMmzdF (mixed-case fixtures) - -=== bundler (docker ruby:3.3 (bundler 2.5.22, ruby 3.3.11, aarch64-linux)) === - [CONFIRMED] 1. Pair edit + fresh container: bundle install succeeds and Rack::SOCKET_PATCHED prints true - Gemfile `gem "rack", "3.2.6", path: ".socket/vendor/gem/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/rack-3.2.6"` + hand-written lock: `bundle install` exit 0 ('Bundle complete! 1 Gemfile dependency, 2 gems now installed'), `bundle exec ruby -e 'require "rack"; puts Rack::SOCKET_PATCHED'` prints `true`. No registry fetch occurred (no 'Fetching gem metadata' line). Vendored dir = copy o - [CONFIRMED] 2. Post-install Gemfile.lock byte-identical to hand-written lock - cmp: BYTE-IDENTICAL. Stronger: deleted the lock and ran `bundle lock` from the pair-edited Gemfile alone — regenerated lock also BYTE-IDENTICAL. Canonical form the implementation must emit: (1) `PATH\n remote: .socket/vendor/gem//rack-3.2.6\n specs:\n rack (3.2.6)` — relative path, no trailing slash, no leading ./ — placed BEFORE GEM; (2) GEM section retained with `r - [CONFIRMED] 3. BUNDLE_FROZEN=true bundle install with the pair edit succeeds - docker -e BUNDLE_FROZEN=true: install exit 0, oracle prints `true`. Frozen mode accepts the hand-written PATH lock because Gemfile and lock agree, so the pair edit is CI/deploy-safe. - [CONFIRMED] 4. Lock-only edit: normal install silently unpatches; frozen install errors - Original Gemfile (`gem "rack", "~> 3.1"`) + PATH-edited lock. NORMAL: exit 0, re-resolves ('Fetching gem metadata... Fetching rack 3.2.6 / Installing rack 3.2.6'), lockfile rewritten back to pure GEM form (PATH section gone, DEPENDENCIES back to `rack (~> 3.1)`), runtime oracle prints UNPATCHED — silent unpatch confirmed; editing the Gemfile is mandatory. FROZEN: exit 16, lock - [CONFIRMED] 5. Stub gemspec from specifications/ works as the path-source gemspec - rack-3.2.6.gemspec stub renamed to rack.gemspec inside the vendored dir works. Warnings: NONE — bundle install stderr empty, and `bundle exec ruby -w -e 'require "rack"'` empty stderr too. Notes: stub's `s.files` lists only rdoc files (CHANGELOG/CONTRIBUTING/README) — irrelevant for path sources since the load path comes from `s.require_paths`; stub carries `s.installed_by_vers - [PARTIAL] 6. Native-ext (json C ext) behavior with a path-vendored copy - Bundler NEVER attempts to build native extensions for path-sourced gems — even though the stub gemspec declares `s.extensions = ["ext/json/ext/generator/extconf.rb", "ext/json/ext/parser/extconf.rb"]` (and even with a second explicit s.extensions added). (a) With prebuilt lib/json/ext/{generator,parser}.so copied along from the registry install: install exit 0 (no 'with native - [CONFIRMED] 7. Offline: --network none + only committed files (Gemfile, Gemfile.lock, .socket/, .bundle/config) - Fresh dir containing exactly Gemfile, Gemfile.lock, .socket/, .bundle/config (BUNDLE_PATH: vendor/bundle); docker --network none: install exit 0, oracle prints true, lock byte-untouched. No registry contact attempted (GEM specs empty + complete lock → bundler skips the metadata fetch entirely). Caveat: this Gemfile has no other registry deps; projects with other gems will still - SURPRISES: - - The official ruby docker image sets BUNDLE_APP_CONFIG=/usr/local/bundle, so `bundle config set --local path vendor/bundle` writes config INSIDE the container, not the project — the spike writes .bundle/config by hand and runs containers with -e BUNDLE_APP_CONFIG=/app/.bundle. Any docker-based test harness (and users ru - - Bundler skips native-extension builds entirely for path: sources even when the gemspec declares s.extensions — and a missing .so does NOT fail bundle install; it fails at first require, and on ruby 3.3 the bundled default `json` gem masks it as a confusing NameError (constant from a newer json API missing in the old st - - The hand-written lock was byte-identical even when regenerated from scratch via `bundle lock`, including the empty `GEM specs:` stanza, `(= 3.2.6)!` dependency form, and 3-space BUNDLED WITH indent — emission can be deterministic string surgery, no bundler invocation needed. - - Registry installs leave build litter inside the gem dir itself (ext/**/Makefile) which a naive copy inherits into the committed vendor tree; compiled-ext bookkeeping (gem.build_complete, mkmf.log) lives separately under vendor/bundle/.../extensions// and is NOT needed by the path source. - - Fully-offline install needed zero registry contact: with all gems path-sourced and the lock complete, bundler never fetches the rubygems index despite `source "https://rubygems.org"` in the Gemfile. - - Tooling self-correction: macOS BSD grep does not support \| alternation in BRE — an early grep 'proved' the json stub gemspec had no s.extensions; it does. Conclusions above were re-verified after fixing this. - REC: The Gemfile+Gemfile.lock pair edit is a sound design for bundler vendoring; no redesign risk found for pure-ruby gems. Implementation requirements proven by the spike: (1) ALWAYS edit both files — a lock-only edit is a silent unpatch on the next plain `bundle install` (frozen/CI catches it with exit 16, dev machines do not); (2) emit exactly bundler's canonical lock form captured in claim 2 (PATH before GEM, relative remote path, exact-pin `name (= ver)!` dependency, preserve PLATFORMS/BUNDLED WITH); since hand-written and bundler-regenerated l - FIXTURES: /tmp/socket-vendor-gem.Q9hupt (main pair-edit fixture; also /tmp/socket-vendor-gem-fresh.u27hcX offline, /tmp/socket-vendor-gem-lockonly.Yu4LLQ claim 4, /tmp/socket-vendor-gem-native.bEKz7m json C-ext, /tmp/socket-vendor-gem-relock.* lock regen) - diff --git a/spikes/PHASE0-V2-FINDINGS.txt b/spikes/PHASE0-V2-FINDINGS.txt deleted file mode 100644 index 0c39ff4..0000000 --- a/spikes/PHASE0-V2-FINDINGS.txt +++ /dev/null @@ -1,198 +0,0 @@ -=== yarnClassic (yarn classic 1.22.22 (via corepack 0.34.5, node v24.12.0; yarn 4.12.0 for the berry sniff)) === - [CONFIRMED] Y1 ground truth: yarn.lock entry shape for a file: tarball dep - yarn install on {"lp": "file:./lp.tgz"} produced verbatim: '"lp@file:./lp.tgz":\n version "1.3.0"\n resolved "file:./lp.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6"'. Key is quoted "@file:./"; resolved keeps the file: prefix and relative path; #fragment is the SHA1 of the tgz bytes (matches shasum -a 1); NO integrity line is emitted for native file: deps. Fixture: y1-file-dep-ground-truth/. - [CONFIRMED] Y2 lock-only edit: rewrite registry left-pad block to vendored tarball, frozen install passes - Rewrote resolved to 'file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6' and integrity to sha512-AhUdVqx1...RmPw== of our tgz. 'corepack yarn install --frozen-lockfile' exit 0, marker present in node_modules/left-pad/index.js, yarn.lock byte-unchanged (sha256-verified). Spellings: 'file:./#sha1' WORKS; bare './#sha1' WORKS; path with no ./ prefix FAILS — treated as registry-relative: 'error Error: https://registry.yarnpkg.com/.socket/vendor/npm/.../left-pad-1.3.0.tgz: Request failed "404 Not Found"'. Bonus oracle: forcing yarn to re-serialize the lock (yarn add isarray@2.0.5 + yarn remove isarray) reproduced the rewritten block byte-for-byte, so the committed after/yarn.lock is yarn-emitted, not hand-written. - [CONFIRMED] Y3 warm-cache poisoning: registry-primed cache does not shadow the vendored tarball - Primed YARN_CACHE_FOLDER via a registry-lock install (cache dir v6/npm-left-pad-1.3.0-5b8a3a7765dfe001261dde915589e782f8c94d1e-integrity), then ran the vendored-lock frozen install against the same cache: exit 0, patched marker present. Cache keys embed the sha1 (npm----integrity), so the vendored tgz got its own slot (…-fa4cc6e3…) alongside the registry one. No poisoning in either direction. - [CONFIRMED] Y4 tamper: modified tgz must fail frozen install - Two variants, both exit 1. (a) raw byte-flip at offset 100: 'error "invalid distance too far back". Mirror tarball appears to be corrupt. You can resolve this by running:\n\n rm -rf undefined\n yarn install' (gzip-level failure, note the cosmetic 'rm -rf undefined' bug). (b) the sharper test — substituting the valid-but-unpatched registry tarball (hash mismatch only): 'error Integrity check failed for "left-pad" (computed integrity doesn't match our records, got "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== sha1-W4o6d2Xf4AEmHd6RVYnngvjJTR4=")'. Same failure occurs with the integrity line removed, proving the #sha1 fragment alone is also enforced. Fixture: y4-tamper/ (replayed from the committed fixture). - [PARTIAL] Y5 merged keys + alias: merged block and npm: alias both install patched - Confirmed for the real-world shape, with one substitution: left-pad@^1.3.2 is unsatisfiable (1.3.0 is the last left-pad ever published), so the merged block was generated by yarn itself as 'left-pad@^1.3.0, left-pad@~1.3.0:' (root dep ^1.3.0 + folder dep dep-a requiring ~1.3.0), plus a separate '"alias@npm:left-pad@^1.3.0":' block from dep 'alias: npm:left-pad@^1.3.0'. After rewriting both blocks' resolved+integrity to the vendored tarball: frozen install exit 0, marker present in BOTH node_modules/left-pad/index.js and node_modules/alias/index.js (alias dir contains left-pad@1.3.0), lock byte-unchanged, and yarn's serializer round-tripped the lock byte-identically. Takeaway: one rewrite of the merged block covers all requesters; alias blocks are separate keys and need their own rewrite. Fixture: y5-merged-alias/. - [CONFIRMED] Y6 offline fresh checkout: committed files only + cold cache installs patched - Fresh dir with ONLY package.json + yarn.lock + .socket/, empty YARN_CACHE_FOLDER: 'corepack yarn install --offline --frozen-lockfile' exit 0, marker present — --offline works fine for the file: dep (no offline-mirror config needed). Re-verified with HTTP_PROXY/HTTPS_PROXY pointed at a dead port (127.0.0.1:1): still exit 0 and patched, proving zero registry traffic. - [CONFIRMED] Y7 resolution base: relative resolved resolves against the lockfile/project dir, not process cwd - Ran 'corepack yarn --cwd install --frozen-lockfile' from an unrelated directory that contained a DECOY unpatched tarball at the same relative .socket/... path. The decoy was ignored: install succeeded with the patched marker (a process-cwd resolution would have hit the decoy and failed integrity with the registry sha1). No node_modules created in the invoking dir. Also ran from a nested subdir of the project without --cwd (yarn walks up to package.json): same correct result. - [CONFIRMED] Y8 berry sniff: yarn@4 lock has __metadata: and no '# yarn lockfile v1' header - yarn 4.12.0 with nodeLinker: node-modules generated a yarn.lock starting with '# This file is generated by running "yarn install" inside your project.' + '__metadata:\n version: 8\n cacheKey: 10c0'; grep counts: __metadata:=1, '# yarn lockfile v1'=0. Entries use 'resolution:'/'checksum: 10c0/...' instead of resolved/integrity. Reliable sniff: '__metadata:' => berry; '# yarn lockfile v1' => classic. Fixture: y8-berry-sniff/. - SURPRISES: - - A resolved path WITHOUT a ./ or file: prefix is interpreted as registry-relative — yarn requested https://registry.yarnpkg.com/.socket/... and 404'd. The rewrite must emit 'file:./...' (or './...'); never a bare path. - - Native file: deps get NO integrity line from yarn (Y1), but if the rewrite adds one, yarn verifies it; and even without it the #sha1 fragment alone is enforced (substitution fails 'Integrity check failed'). So the vendored entry is double-verified when we write both. - - yarn classic's lock serializer round-trips the hand-rewritten vendored entry byte-for-byte (verified via forced re-save with yarn add/remove), meaning later legitimate yarn operations won't churn or revert the vendored block — and the fixtures' after-locks are genuinely tool-emitted. - - left-pad@^1.3.2 (from the claim text) is unsatisfiable — left-pad's last release is 1.3.0 — so the merged-key fixture uses ^1.3.0 + ~1.3.0, generated by yarn itself via a folder dep. - - The byte-flip tamper failure surfaces as a gzip error with a buggy remediation message ('rm -rf undefined') rather than an integrity error; only a valid-but-wrong tarball exercises the actual hash check — fixture y4 uses the latter as the regression oracle. - - Cache entries are keyed npm----integrity, so registry and vendored artifacts of the same name@version coexist in one cache; warm-cache poisoning is structurally impossible in v6 cache layout. - REC: Vendor v2 is fully viable for yarn classic with a lock-only rewrite and no package.json changes. Recipe: for every lock block whose resolved points at the target name@version (including merged multi-key blocks and separate \"alias@npm:...\" blocks), set resolved to \"file:./.socket/vendor/npm//-.tgz#\" and integrity to the sha512 SRI of the tgz; always use the file:./ spelling (bare ./ also works, no-prefix breaks). This is checksum-verified on every install (sha1 fragment + sha512 integrity, both enforced even under --frozen-lockfile and warm caches), survives yarn's own re-serialization byte-for-byte, installs offline from a fresh checkout with cold caches, and resolves relative to the project dir regardless of invocation cwd. Gate the rewriter on the '# yarn lockfile v1' header and bail to a different strategy when '__metadata:' (berry) is present. One residual to verify in the next spike: workspaces-root locks (single lock at the workspace root) should behave identically since resolution is lockfile-dir-based, but it was not explicitly tested here. - FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/yarn-classic - -=== yarnBerry (yarn berry 4.x (corepack; 4.12.0 + 4.6.0), nodeLinker: node-modules, node v24.12.0, Python 3.14.3 rebuild script, macOS Darwin 25.5.0) === - [REFUTED] B1: lock entry with checksum field removed makes 'yarn install --immutable' / '--immutable --check-cache' fail (YN0028/YN0018) - Both pass with EXIT=0 on a cold cache (yarn 4.12.0). Installed bytes ARE the patched ones (marker line present in node_modules/left-pad/index.js). Surprise: the --immutable run silently REWROTE yarn.lock, re-adding the checksum line (resulting lock byte-identical to the original tool-generated one) — missing checksum is TOFU+self-heal, not an immutability violation. Tamper guards still hold: tampered tgz + intact lock -> resolution recomputes hash=b4fd84 (vs 39ea9b) -> 'YN0028: The lockfile would have been modified by this install, which is explicitly forbidden.' EXIT=1; present-but-wrong checksum -> 'YN0018: ...The remote archive doesn't match the expected checksum' EXIT=1. - [CONFIRMED] B2 (DECISIVE): berry's lock checksum is reproducible offline from our tgz - Lock 'checksum: 10c0/' hex == sha512 of the cache zip file, and rebuild_zip.py (python stdlib, offline, no yarn) rebuilds that zip BYTE-IDENTICAL (cmp clean) from the tgz. Verified on yarn 4.12.0 and 4.6.0 (identical checksum 7785879d...8d7ca3), under TZ=Asia/Kathmandu (identical -> DOS times written as UTC), and on an odd-modes probe tarball. Full recipe pinned in README: package/ stripped -> node_modules// prefix; tar order with mkdirp-on-demand dir entries; all entries stored (method 0, the 'c0'); every timestamp dosdate=0x08D6 dostime=0xAE40 (SAFE_TIME 456789000 = 1984-06-22 21:50:00 UTC); modes NORMALIZED by yarn (files 0o100644, 0o100755 iff any tar exec bit; dirs always 0o40755); flags 0x0000, version-needed 10 files/20 dirs, version-made-by 0x033F; NO extra fields, NO data descriptors, NO comments, NO zip64; external_attr=mode<<16, internal_attr=0; EOCD single-disk. - [CONFIRMED] B3: resolutions-driven file: lock entry ground truth + fresh-clone --immutable passes - package.json change: add resolutions {"left-pad": "file:./.socket/vendor/npm//left-pad-1.3.0.tgz"} (dependency stays registry range 1.3.0). Verbatim entry: key 'left-pad@file:./.socket/.../left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.'; resolution '...tgz#...tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A.'; version: 1.3.0; checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3; languageName: node; linkType: hard. hash= is the first 6 hex chars of sha512(tgz bytes) (verified + tamper-flips). Lock key/resolution embed the root workspace NAME and the relative tgz path. Fresh clone of committed files with empty caches: --immutable passes (see B5). - [CONFIRMED] B4: which .yarnrc.yml knobs change the checksum - compressionLevel changes BOTH the cacheKey and the checksum: 'mixed' -> cacheKey: 10 (drops c0), files become deflate(8), checksum 10/fdd30d4a...f18fdb5d. cacheVersion is NOT settable: yarn config get cacheVersion -> 'Usage Error: Couldn't find a configuration settings named "cacheVersion"' (the 10 is internal CACHE_VERSION). cacheFolder/enableGlobalCache/cacheMigrationMode don't change fresh-install checksums. Design rule: only emit checksums for cacheKey 10c0 (compressionLevel 0 = yarn 4 default); reproducing 'mixed' would require bit-identical zlib output. - [CONFIRMED] B5: strictest fresh-checkout install of committed files installs the patched artifact, checksum-verified - Copied exactly package.json + yarn.lock + .yarnrc.yml + .socket/ to a new mktemp dir; YARN_GLOBAL_FOLDER=, YARN_ENABLE_GLOBAL_CACHE=false, and (stricter than asked) YARN_ENABLE_NETWORK=false; 'yarn install --immutable --check-cache' -> EXIT=0, marker present in node_modules/left-pad/index.js, project-local .yarn/cache/left-pad-file-8dfd6a0c16-7785879d9a.zip sha512 == lock checksum. Fully offline: file: protocol never contacts the registry when the lock is complete. - SURPRISES: - - B1 inverted: --immutable with a missing checksum PASSES and self-heals — yarn rewrites yarn.lock under --immutable to add the computed checksum back (resulting lock byte-identical to the original); only a present-but-wrong checksum (YN0018) or changed tgz bytes (YN0028 via hash= param) fail. - - The file: resolution's hash=39ea9b is simply the first 6 hex chars of sha512(tgz bytes) — an independent, lock-committed tamper guard on the tarball itself, separate from the zip checksum. - - Yarn NORMALIZES file modes during tgz->zip conversion instead of copying tar modes: files become 0644 (0755 iff any exec bit: 0600/0664/0444 all -> 0644), dirs always 0755 — confirmed with a probe tarball and still byte-identical after encoding the rule. - - Checksum is timezone-insensitive even though zip DOS timestamps are nominally local-time: yarn's wasm libzip renders SAFE_TIME as UTC (fresh install under TZ=Asia/Kathmandu produced the identical checksum). - - The zip is maximally plain — no extra fields, no data descriptors, no UTF-8 flags, no zip64 — so the first-attempt raw-struct rebuild was already byte-identical; the only versioning hazard is the internal, non-settable CACHE_VERSION ('10') which historically bumps on yarn majors. - - With enableGlobalCache: false the project-local cache file name embeds the checksum head (left-pad-file-8dfd6a0c16-7785879d9a.zip) instead of the cacheKey suffix used in the global cache (-10c0.zip). - REC: Adopt the resolutions+file: design for yarn berry 4.x — it meets the committable guarantee with full checksum verification. Implementation: (1) write the vendored tgz under .socket/vendor/npm//; (2) add the resolutions entry; (3) write the lock entry ourselves using the pinned recipe: key/resolution embed the root workspace name + relative path, hash= = first 6 hex of sha512(tgz), checksum = 10c0/ + sha512 of the deterministic zip rebuilt per rebuild_zip.py (port to Rust; trivially small: stored entries, SAFE_TIME 0x08D6/0xAE40, normalized modes, tar-order + mkdirp dirs, no extra fields) — or, acceptable fallback, omit the checksum field and let --immutable self-heal it (B1), though emitting it is strictly better and cheap. Gate checksum emission on the user's lock cacheKey == 10c0 (compressionLevel 0, the yarn 4 default); for any other cacheKey emit no checksum and rely on hash= + self-heal. Keep tgz entry modes 0644/0755 and ASCII names. Re-validate the recipe whenever yarn bumps CACHE_VERSION (lock __metadata.cacheKey changes). Untested residue: pnp linker (cache layer is linker-independent but unverified) and symlink-bearing tarballs (npm pack never emits them). - FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/yarn-berry-nm - -=== pnpm (pnpm 9.15.9 and 10.34.1 via corepack 0.34.5 (node v24.12.0, macOS Darwin 25.5.0); both `corepack pnpm@N` and packageManager-field pinning verified) === - [CONFIRMED] P1 ground truth: pnpm.overrides {left-pad@1.3.0: file:.socket/...tgz} resolves and lands in the lock on both majors - Both 9.15.9 and 10.34.1 emit byte-identical lockfileVersion: '9.0' locks (diff IDENTICAL). Verbatim: `overrides:\n left-pad@1.3.0: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz`. Importer dep: specifier is NOT unchanged — both specifier AND version are rewritten to the file: URL (`specifier: file:.socket/...tgz` / `version: file:.socket/...tgz`; package.json keeps "1.3.0"). packages: key = `left-pad@file:.socket/.../left-pad-1.3.0.tgz`; resolution has BOTH keys: `{integrity: sha512-VR8nCbFx... (patched tarball sha512), tarball: file:.socket/...tgz}` plus a new top-level `version: 1.3.0` line, and the registry entry's `deprecated:` line is dropped. snapshots: key identical to packages key; dependents reference it as bare `left-pad: file:.socket/...tgz` (no name@ prefix) in their dependencies map. Zero shape diffs between 9 and 10. - [CONFIRMED] P2 surgical reproduction: hand-edited pristine lock + package.json passes --frozen-lockfile and stays byte-stable - edit_lock.py (4 text edits + package.json pnpm.overrides; integrity computed sha512-base64 from the vendored tarball) produced locks BYTE-IDENTICAL to pnpm's own generated locks for both majors before any install. rm -rf node_modules + fresh store: `pnpm install --frozen-lockfile` exit 0, output 'Lockfile is up to date, resolution step is skipped'; marker `/* SOCKET_PATCHED 9f6b2c4e-... */` installed for direct dep AND transitive (consumer). Subsequent plain `pnpm install` left lock byte-identical (sha256 a7c36d374de4c705bdb43d7aee42d944656a3b0d9c5d2c08c5b41664d23ee156 before and after, both majors). - [CONFIRMED] P3 lock-only control: lock edit without package.json override fails frozen / reverts on plain install - Frozen (both majors): exit 1 with ` ERR_PNPM_LOCKFILE_CONFIG_MISMATCH Cannot proceed with the frozen installation. The current "overrides" configuration doesn't match the value found in the lockfile` — NOT ERR_PNPM_OUTDATED_LOCKFILE. Plain install: exit 0, silently re-resolves, REWRITES the lock (overrides section removed entirely) and installs pristine registry bytes (no marker). No warning that a patch was dropped. - [CONFIRMED] P4 fresh-offline: committed files only + empty store + --offline --frozen-lockfile installs patched bytes - Single-dep project; copied ONLY package.json + pnpm-lock.yaml + .socket/ into an empty dir; brand-new --store-dir and XDG cache/data/state. `pnpm install --frozen-lockfile --offline`: exit 0 on both majors, 'Lockfile is up to date, resolution step is skipped', node_modules/left-pad/index.js first line = SOCKET_PATCHED marker. No network needed for file: tarball resolution. - [CONFIRMED] P5 tamper + warm-store: byte-flip fails with integrity error; registry-primed store does not shadow the patch - One byte XORed mid-tarball -> frozen install exit 1, left-pad NOT installed: ` ERR_PNPM_TARBALL_INTEGRITY Got unexpected checksum for ".../.socket/vendor/npm//left-pad-1.3.0.tgz". Wanted "sha512-VR8nCbFx...". Got "sha512-FaR7sFah..."` (both majors; pnpm 10's message additionally suggests `pnpm install --update-checksums` — a footgun to warn about, since it would bless tampered bytes). Warm-store: store primed by installing registry left-pad@1.3.0 (no override), then patched project on the SAME store -> exit 0, patched marker bytes win (store is content-addressed per resolution; file: tarball has its own key). - [CONFIRMED] P6 scoping: versioned selector left-pad@1.3.0 moves only that version; left-pad@1.2.0 stays registry - Lock (both majors): `left-pad@1.2.0:` keeps registry resolution `{integrity: sha512-OQadpCyF...}` with no tarball key; importer left-pad-old still `version: left-pad@1.2.0`. node_modules: left-pad-old version 1.2.0 with 0 marker hits; left-pad and consumer's transitive left-pad each have exactly 1 marker hit. - [CONFIRMED] P7 workspace: root-only pnpm.overrides covers a workspace sub-package's dependency - pnpm-workspace.yaml (packages/*), override ONLY in root package.json. Sub-importer fragment (verbatim, both majors identical): `packages/app:\n dependencies:\n left-pad:\n specifier: file:../../.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz\n version: file:.socket/vendor/npm/9f6b2c4e-.../left-pad-1.3.0.tgz` — specifier is re-relativized PER IMPORTER (../../) while version and the packages/snapshots keys stay lockfile-root-relative. packages/app/package.json is NOT rewritten (still ^1.3.0); frozen install passes after rm -rf node_modules; app gets patched bytes. - SURPRISES: - - The lock importer `specifier` field is REWRITTEN to the override target (not kept as the package.json range), and in workspaces it is re-relativized per importer (file:../../.socket/... for packages/app) while `version` and the packages/snapshots keys stay root-relative — a lock-editing tool must compute per-importer relative paths or frozen installs will fail. - - pnpm 9.15.9 and 10.34.1 produced byte-identical lockfiles in every scenario (same lockfileVersion '9.0', zero shape diffs) — one edit shape serves both majors. - - P3's frozen failure is ERR_PNPM_LOCKFILE_CONFIG_MISMATCH (overrides config vs lock), not ERR_PNPM_OUTDATED_LOCKFILE as expected; and a plain `pnpm install` silently strips the overrides section and reverts to registry bytes with no warning at all. - - The file: tarball packages entry gains a top-level `version: 1.3.0` line and LOSES the registry entry's `deprecated:` line — edits that forget either produce a lock that plain install rewrites (byte-stability check would fail). - - Integrity (sha512 of the tarball bytes) IS enforced for local file: tarballs even offline — strong tamper story — but pnpm 10's ERR_PNPM_TARBALL_INTEGRITY message advertises `pnpm install --update-checksums`, which would launder a tampered vendored artifact if a user follows it blindly. - - snapshots dependents reference the overridden package as bare `left-pad: file:.tgz` (no name@ prefix), unlike registry refs (`left-pad: 1.3.0`). - REC: Adopt pnpm.overrides with the versioned selector + file: tarball for vendor v2 on pnpm 9 and 10 — every claim confirmed, including the committable guarantee (fresh checkout + cold store + --offline --frozen-lockfile installs checksum-verified patched bytes). The tool MUST edit both package.json (pnpm.overrides) and pnpm-lock.yaml together; the lock edit is fully mechanical (4 text edits, reference implementation in spikes/pnpm/edit_lock.py, verified byte-identical to pnpm's own output on both majors): insert overrides: section, rewrite the importer specifier+version (re-relativizing specifier per importer in workspaces), rekey the packages entry with resolution {integrity: sha512-base64(tarball), tarball: file:} + version: X.Y.Z and drop any deprecated: line, rekey snapshots and rewrite dependents' bare refs. One lock shape covers both majors. Edge cases to handle in the design: lock-without-package.json desync fails closed on CI (frozen) but silently reverts on dev plain install — patch-removal/verify tooling should detect a missing overrides pair; document that --update-checksums must never be run to 'fix' a vendored-tarball integrity failure. - FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/pnpm - -=== bun (bun 1.3.14 (0d9b296a), text lockfile bun.lock (default in 1.3.x; bun.lockb never produced); `bun ci` available as alias of `bun install --frozen-lockfile`) === - [CONFIRMED] BN1 ground truth: lock shape for "lp": "./lp.tgz" and "lp2": "file:./lp2.tgz", incl. tuple arity, integrity presence, nested key grammar - bun 1.3.14 writes text bun.lock by default (no flag needed). Local-tarball packages entry is a 3-tuple keyed by ALIAS: `"lp": ["left-pad@./lp.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="]` — element 0 = @, element 1 = deps object, element 2 = integrity which IS present and equals the sha512 of the raw tarball bytes (verified with openssl). No registry element. Bare `./lp.tgz` and `file:./lp2.tgz` produce identical entry shapes. Registry entries are 4-tuples `["left-pad@1.2.0", "", {}, "sha512-..."]` (element 1 = registry, "" = default). Two-level fixture (root left-pad@1.2.0 + local haspad.tgz depending on ^1.3.0) yields flat slash-keyed nested entry: `"haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVN..."]`. Verbatim locks in bn1-file-deps/ and bn1-nested/. - [CONFIRMED] BN2 overrides ground truth: root overrides -> file:.socket/vendor/npm//left-pad-1.3.0.tgz; lock shape; resolutions too - bun install exits 0, installs marker. Lock gains a top-level `"overrides": {"left-pad": "file:.socket/vendor/npm/9f6b2c4e-.../left-pad-1.3.0.tgz"}` section (spec verbatim) AND the `"left-pad"` packages entry becomes the 3-tuple tarball shape `["left-pad@.socket/vendor/npm//left-pad-1.3.0.tgz", {}, "sha512-BeCz4t..."]`; dependency spec stays ^1.3.0. `"resolutions"` works identically — bun normalizes it into the SAME `"overrides"` lock section, byte-identical packages entry. Fixtures bn2-overrides/, bn2-resolutions/. - [CONFIRMED] BN3 lock-only edit (decisive): hand-rewrite left-pad packages entry to local-tarball shape, package.json untouched, frozen install passes + marker + byte-stable - PASS on all three criteria. Registry project (package.json keeps `"left-pad": "1.3.0"`), only the packages entry rewritten from the registry 4-tuple to `["left-pad@.socket/vendor/npm//left-pad-1.3.0.tgz", {}, ""]`. rm -rf node_modules; `bun install --frozen-lockfile` -> exit 0, prints `+ left-pad@.socket/vendor/npm/.../left-pad-1.3.0.tgz`, marker `/* SOCKET-PATCHED left-pad@1.3.0 marker:9f6b2c4e */` present. Plain `bun install` and `bun ci` leave the lock BYTE-IDENTICAL (sha256 cdb14119c5e6... before and after, cmp-verified, re-verified on a replay of the committed fixture). The workspaces section's registry spec string vs tarball packages entry mismatch is accepted. Bonus: the edit survives `bun add is-odd` (entry + marker intact); only `bun update left-pad` reverts the entry to the registry tuple (and leaves stale patched bytes in node_modules until the next clean install). Fixture bn3-lock-only/. - [CONFIRMED] BN4 override semantics: two versions of left-pad in tree + name-keyed override — what moves? - Everything moves. Before: root left-pad@1.2.0 + nested haspad/left-pad@1.3.0. Adding `"overrides": {"left-pad": "file:..."}` collapses BOTH to the single patched root copy; the `"haspad/left-pad"` key disappears from the lock entirely and require() from haspad resolves the patched 1.3.0; the 1.2.0 consumer is force-upgraded. Trap found: a version-scoped key `"left-pad@1.3.0": "file:..."` is SILENTLY IGNORED — copied verbatim into the lock's overrides section but the nested 1.3.0 stays vanilla (fail-open no-op); bun overrides are name-only. However, a lock-only edit of just the `"haspad/left-pad"` entry gives exact per-instance targeting: root stays vanilla 1.2.0, nested patched, frozen install passes, byte-stable (fixture bn4c-targeted-nested/). - [CONFIRMED] BN5 integrity: file: entries carry integrity; tamper -> frozen install fails - file: entries DO carry integrity (sha512 of raw tarball bytes). Byte-flip at offset 100 of the vendored tarball, cold cache: `bun install --frozen-lockfile` exits 1 with verbatim errors `error: Integrity check failed for tarball: left-pad` and `error: IntegrityCheckFailed extracting tarball from left-pad`; node_modules/left-pad not created. Plain (non-frozen) `bun install` ALSO fails with the same error and does NOT rewrite the lock hash — fail-closed in both modes, no auto-heal. Checksum-verified guarantee holds for bun. - [CONFIRMED] BN6 warm cache: registry version primed in BUN_INSTALL_CACHE_DIR -> patched bytes still win - Primed isolated cache with registry left-pad@1.3.0 (cache entry `left-pad@1.3.0@@@1` present), then frozen-installed the lock-edited project against the SAME cache: patched marker installed — local tarball is not shadowed by the name@version cache entry. Reverse direction also clean: after the patched install, a fresh registry project using the same cache still gets vanilla bytes (no cache poisoning either way). - [CONFIRMED] BN7 fresh-checkout proof: committed files only + cold cache; frozen install; marker present - git init/commit/clone of exactly {package.json (untouched registry spec), edited bun.lock, .socket/vendor/npm//left-pad-1.3.0.tgz}; BUN_INSTALL_CACHE_DIR pointed at a nonexistent dir (verified cold). `bun install --frozen-lockfile` exit 0, marker present, left-pad('x',3) works. Repeated with HTTPS_PROXY/HTTP_PROXY=http://127.0.0.1:9 (dead proxy): still exit 0 — the install is fully offline. `bun ci` on the same checkout: exit 0, marker present, lock unchanged. - SURPRISES: - - bun enforces integrity on LOCAL file: tarballs (sha512 of raw tarball bytes in the lock 3-tuple) and fails closed even on plain non-frozen install without auto-healing the hash — stronger than npm's typical local-tarball handling and gives the committable guarantee real teeth. - - Version-scoped override keys ("left-pad@1.3.0": ...) are a SILENT no-op: bun copies the key verbatim into the lock's overrides section but never applies it — a fail-open trap if the vendor tool ever emits npm-style scoped overrides for bun. - - The lock-only edit survives `bun add ` re-resolution untouched; only an explicit `bun update left-pad` reverts it — and that revert leaves stale patched bytes in node_modules (lock and tree silently diverge until the next clean install). - - Tuple arity is shape-significant: local-tarball entries are 3-tuples (no registry element, deps at index 1) while registry entries are 4-tuples (registry at index 1, deps at index 2) — a lock rewriter must change arity, not just substitute fields. - - A name-keyed override deletes the nested "parent/child" lock key entirely and force-collapses ALL versions (even non-matching 1.2.0) onto the patched tarball — collateral version movement that per-entry lock edits avoid (bn4c shows exact per-instance targeting works and is byte-stable). - - `resolutions` and `overrides` are the same feature in bun: both normalize to an identical top-level "overrides" lock section. - REC: Use the lock-only edit as the primary bun mechanism: rewrite the target packages entry (any key, including nested \"parent/child\") from the registry 4-tuple to the 3-tuple `["@", {deps}, "sha512-"]`, drop the vendored .tgz under .socket/vendor/npm//, and leave package.json untouched. It passed every gate: bun ci / --frozen-lockfile exit 0, byte-stable under plain install, integrity-enforced (tamper = hard fail, no auto-heal), immune to warm-cache shadowing, fully offline on fresh checkout, survives `bun add`. It is also strictly more capable than overrides (per-instance/version targeting). Keep the overrides edit (BN2 shape: package.json overrides + lock overrides section + tarball entry) as a documented fallback only — it moves every version of the name and bun ignores version-scoped keys silently. Implementation cautions: preserve the alias-keyed `@` element-0 grammar and the arity change; compute integrity as sha512 of the raw tarball bytes; document `bun update ` as the operation that reverts the patch (consider re-applying after update); note minimum surface is bun 1.2+ text lockfile (this spike pins bun 1.3.14 behavior). - FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/bun - -=== poetry (poetry (Poetry 2.4.1 = lock-version 2.1; Poetry 1.8.5 = lock-version 2.0; Python 3.14.3, pip 26.0)) === - [CONFIRMED] P1 lock shape from 'poetry add ./.socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl' - Both majors produce: [[package]] name="six" version="1.16.0" ... files = [{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}] followed by [package.source] type = "file", url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl". URL is RELATIVE on both majors (even when add was given an absolute path); files[] is a single entry whose hash IS our patched wheel sha256; the source table sits AFTER files=[...], as the LAST subtable of the [[package]] entry (after [package.dependencies] would come too, before next [[package]]/[metadata]). Poetry 2 adds groups=["main"]; 1.8 omits it. Caveat: Poetry 2's add writes an ABSOLUTE file:/// URL into PEP 621 [project].dependencies (pyproject not committable from add); 1.8 writes relative {path=...} under [tool.poetry.dependencies]. - [CONFIRMED] P2 lock-only direct: registry pyproject + hand-spliced six lock entry installs the patched wheel - PASS on Poetry 2.4.1 with [tool.poetry.dependencies] six="1.16.0", PASS with PEP 621 dependencies=["six==1.16.0"], PASS on Poetry 1.8.5. In all three: 'poetry install' exit 0 installing 'six (1.16.0 /...../.socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl)', import six shows '# SOCKET-PATCHED', poetry.lock sha256 byte-unchanged after install. 'poetry sync' (2.4.1) and 'poetry install --sync' (1.8.5) also exit 0, 'No dependencies to install or update', lock unchanged, marker still present. content-hash was left untouched and never complained about. - [CONFIRMED] P3 lock-only transitive: python-dateutil==2.8.2 direct, splice only six's entry - PASS both majors: exit 0, installs python-dateutil 2.8.2 from registry + six 1.16.0 from the vendored file, marker present, lock byte-unchanged, dateutil imports and works. Notably the tool-generated baseline had resolved six to 1.17.0; the spliced 1.16.0 file entry installed anyway (constraint six>=1.5 satisfied) — install is purely lock-driven, no re-resolution check. - [CONFIRMED] P4 hash fail-closed: tampered wheel + stale lock hash + empty POETRY_CACHE_DIR - Both majors exit 1 with: 'RuntimeError: Hash for six (1.16.0 /.../six-1.16.0-py2.py3-none-any.whl) from archive six-1.16.0-py2.py3-none-any.whl not found in known hashes (was: sha256:2597d578238182ae0214bcf91dcf7b1bd3583f8b54ecb4aba6197751da7e4f65)' raised at poetry/installation/executor.py _validate_archive_hash (line 809 in 2.4.1, 812 in 1.8.5), 'Cannot install six.' The tampered wheel was a valid zip (extra line appended inside six.py). - [CONFIRMED] P5 silent-unpatch surfaces on the P2 spliced state - PRESERVE file source: Poetry 2.4.1 plain 'poetry lock' (left lock byte-identical, exit 0); Poetry 1.8.5 'poetry lock --no-update'; 'poetry add packaging' on BOTH majors (full re-resolve keeps locked file source for untouched packages, console even prints 'Updating six (... .whl -> ... .whl)'). REWRITE to registry (silent, exit 0): 'poetry update six' on BOTH majors — six version stays 1.16.0 (pyproject pin) but files[] reverts to the two registry hashes, i.e. next install is silently unpatched; Poetry 2.4.1 'poetry lock --regenerate'; Poetry 1.8.5 plain 'poetry lock' (1.x plain lock is a full re-resolve). Note: 'poetry add requests' originally failed resolution because requests 2.34.2 now requires Python >=3.10 vs the project's >=3.9 (lock untouched on failure); 'packaging' used instead. - [CONFIRMED] P6 'poetry check --lock' on the P2 spliced state exits 0 - Exit 0 on Poetry 2.4.1 [tool.poetry] style (prints unrelated [tool.poetry]->[project] deprecation warnings), exit 0 'All set!' on the PEP 621 variant, exit 0 'All set!' on Poetry 1.8.5. The lock freshness check is content-hash-of-pyproject only and the splice never touches pyproject. - [CONFIRMED] P7 fresh-checkout proof: pyproject + poetry.lock + .socket only, empty cache - Clean dir with exactly {pyproject.toml, poetry.lock, .socket/}, brand-new empty POETRY_CACHE_DIR, fresh in-project venv: install exit 0 and '# SOCKET-PATCHED' marker present on Poetry 2.4.1 and 1.8.5 (direct case) and on the 2.4.1 transitive case. The committable guarantee holds; the relative file url resolves against the project root. - [CONFIRMED] P8 name normalization: pyproject 'PyYAML = "6.0.1"' - Both majors record the PEP 503 canonical lowercase name in the lock: name = "pyyaml". The files[] entries keep the original artifact filename casing (PyYAML-6.0.1-*.whl). So lock-entry matching must canonicalize (lowercase, and presumably -/_/. folding) rather than reuse the pyproject spelling. - SURPRISES: - - Poetry 2.4.1 'poetry add ' rewrites PEP 621 [project].dependencies with an ABSOLUTE 'six @ file:///private/tmp/...' URL — the pyproject from 'add' is not committable, even though the lock it writes uses a relative url. The [tool.poetry.dependencies] {path = "..."} form stays relative on both majors; lock-only splicing avoids the problem entirely. - - 'poetry add ' PRESERVES the spliced file source on BOTH majors despite re-resolving and rewriting the lock — only targeted 'poetry update ' and full re-locks (2.x 'lock --regenerate', 1.x plain 'lock') revert it. 2.x plain 'poetry lock' leaves the spliced lock byte-identical. - - 'poetry update six' un-patches with zero signal: exit 0, version printed as '1.16.0 -> 1.16.0' (pyproject pins it); only files[] hashes and the dropped [package.source] reveal it. Drift detection must diff the lock, not versions or exit codes. - - The transitive splice installed six 1.16.0 from file even though the tool's own resolution in that same lock generation had picked 1.17.0 — poetry install performs no lock-vs-resolver consistency check beyond the pyproject content-hash. - - metadata.content-hash never has to be recomputed: it hashes pyproject only, so lock-only edits pass 'poetry check --lock' and install freshness on both majors. - - A lock where six is BOTH transitive-only and file-sourced is not tool-generatable (six must also be a direct path dep, as in the transitive-path fixture); that exact state is only reachable by splicing — which P3 proved both majors accept. - - Environment gotcha: requests 2.34.2 now requires Python >=3.10, so 'poetry add requests' fails to resolve under the project's >=3.9 range (lock untouched on failure); the add experiment used 'packaging' instead. Poetry 1.8.5 itself runs fine on Python 3.14.3. - REC: Lock-only splicing is the right vendor-v2 mechanism for poetry and works identically on both supported majors: leave pyproject.toml and metadata.content-hash untouched, and rewrite only the target [[package]] entry — replace files[] with a single {file = \"\", hash = \"sha256:\"} and append a [package.source] table (type = \"file\", url = relative \".socket/vendor/pypi//\") as the last subtable of the entry, keeping groups=[\"main\"] when lock-version is 2.1 and omitting it for 2.0. This passes install/sync/check --lock byte-stably, covers direct and transitive deps (even at a version the resolver would not pick), is hash-fail-closed against tampering, and survives fresh checkout with cold caches. Match lock entries by PEP 503 canonical name (pyyaml, not PyYAML). Two follow-ups are mandatory: (1) a drift check, since 'poetry update ', 2.x 'lock --regenerate', and 1.x plain 'poetry lock' silently revert the entry with exit 0 — the cheapest oracle is the patched-wheel sha256 in files[]; (2) docs/setup should steer 1.x users to 'poetry lock --no-update' and never write pyproject path deps via 'poetry add' on 2.x (it embeds absolute file:/// URLs). - FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/poetry - -=== pdm (pdm 2.27.0 (installed via pip into /tmp/pdmv venv; Python 3.14.3; lockfile lock_version 4.5.0)) === - [CONFIRMED] D1 shape: pdm add ./.socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl on pdm init -n scratch project - Lock entry verbatim: [[package]] name="six" version="1.16.0" requires_python=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" path="./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" summary=... groups=["default"] files=[{file="six-1.16.0-py2.py3-none-any.whl", hash="sha256:7015f5a42a0f83fd1b7d3ca0ba10d8777a207c19b6ffebb39e2e1c03af6a281b"}] — i.e. a RELATIVE `path` key (no ${PROJECT_ROOT} in the lock) plus a single files[] entry carrying OUR patched sha256. [metadata]: groups=["default"], strategy=["inherit_metadata"], lock_version="4.5.0", content_hash="sha256:8929...", plus [[metadata.targets]] requires_python="==3.14.*". pyproject gets `file:///${PROJECT_ROOT}/.socket/...whl` appended to dependencies (and KEEPS any prior `six==1.16.0` specifier alongside). Marker installed. - [CONFIRMED] D2 lock-only direct: registry pyproject (six==1.16.0) + hand-edited pdm.lock per D1 shape, content_hash untouched - PASS. Fresh venv + cold PDM_CACHE_DIR: `pdm sync` exit 0 (installs from path, hash-verified), `pdm install --check` exit 0, `pdm install --frozen-lockfile` exit 0 (flag confirmed in 2.27.0 help, alias --no-lock). Marker present in .venv six.py; pdm.lock byte-identical before/after (sha256 91003c30...). pdm never cross-checks that the lock candidate's path shape matches the pyproject registry specifier — only name/version vs content_hash. Negative control: editing pyproject makes `pdm install --check` exit 1 with 'Lockfile hash doesn't match pyproject.toml', so the check is not vacuous. - [CONFIRMED] D3 lock-only transitive: python-dateutil direct / six transitive - PASS, with one nuance: registry resolution locks six 1.17.0 (latest matching six>=1.5), so the splice also downgrades version to 1.16.0 — accepted without complaint. sync/--check/--frozen-lockfile all exit 0, marker present, `import dateutil.parser` works against patched six 1.16.0, lock byte-identical (sha 10e16bfa...). - [CONFIRMED] D4 tamper fail-closed: tampered wheel + cold cache + pdm sync - Exit 1, package NOT installed. Verbatim: `unearth.errors.HashMismatchError: Hash mismatch for file:///...wheel: Expected(sha256): 7015f5a4... Actual(sha256): 33e9994c...` then `[InstallationError]: Some package operations failed.` pdm does NOT skip hash checks on local paths — unearth validates the file:// link against files[] before unpack. Reproduced twice (second run with clean .pdm-python/venv/cache). - [CONFIRMED] D5 silent unpatch from the D2 state - `pdm lock`: SILENTLY UNPATCHES the lock — six entry rewritten back to registry shape (PyPI hashes, no path), exit 0, zero warning (installed env untouched). `pdm update six`: unpatches lock AND reinstalls registry six (marker gone from .venv). Plain `pdm install`: PRESERVES — resolves from lockfile since content_hash matches; lock byte-identical and marker intact. Counter-measure found: a `[tool.pdm.resolution.overrides] six = "file:///${PROJECT_ROOT}/..."` entry in pyproject makes `pdm lock` and `pdm update six` keep the path entry byte-identically. - [PARTIAL] D6 strategy matrix: --static-urls relock + D2 edit; --no-hashes lock - static_urls: `pdm lock --static-urls` works (deprecated alias of -S static_urls); shape: strategy=["inherit_metadata","static_urls"], files entries become {url="https://files.pythonhosted.org/...", hash="sha256:..."}; content_hash IDENTICAL to the default-strategy lock (covers requirements only). Splicing the same D1 path shape (path key + {file=...,hash=...} entry) into the static_urls lock works: sync/--check/--frozen-lockfile all exit 0, marker present, lock byte-stable — a file= entry is accepted inside a static_urls lock. no_hashes: NOT AVAILABLE in pdm 2.27.0 — `pdm lock --no-hashes` is an argparse usage error and `-S no_hashes` gives `[PdmUsageError]: Invalid strategy flag: hashes, supported: cross_platform, static_urls, direct_minimal_versions, inherit_metadata`; hash-less locks could not be tested (partial only for this sub-leg). - [CONFIRMED] D7 freshness: pdm install does not re-lock when content_hash matches - On the D2 edited state: `pdm lock --check` exit 0; two consecutive `pdm install` runs print 'Resolving packages from lockfile... All packages are synced to date' with no Lock step; pdm.lock sha256 AND mtime unchanged (91003c30..., mtime still the edit time). Lock is byte-stable under install. - SURPRISES: - - [tool.pdm.resolution.overrides] six = "file:///${PROJECT_ROOT}/.socket/vendor/pypi//" is the killer mechanism: pdm expands ${PROJECT_ROOT}, generates the exact same relative `path = "./..."` lock shape, works for TRANSITIVE deps without touching the dependent's requirement, and survives `pdm lock` and `pdm update six` byte-identically — closing the D5 silent-unpatch hole. It does change content_hash (overrides feed resolution), so it must be added tool-side followed by a pdm-run lock. - - pdm add does NOT replace the existing registry specifier: pyproject ends up with BOTH "six==1.16.0" and the file:///${PROJECT_ROOT} URL in dependencies; pdm resolves the pair to the path candidate. - - content_hash covers requirements only — identical between default and static_urls locks of the same pyproject — so per-package lock splices can never trip `pdm install --check`/`pdm lock --check` freshness. - - Transitive resolution locks six 1.17.0, not 1.16.0; a vendored 1.16.0 splice is a version downgrade inside the lock and pdm accepts it silently as long as the dependent's range (six>=1.5) is satisfied — no range validation error to rely on, but also none to fight. - - pdm lock --no-hashes no longer exists in 2.27.0 (PdmUsageError lists only cross_platform, static_urls, direct_minimal_versions, inherit_metadata); hashes are effectively always present in 4.5.0 locks, which strengthens the checksum-verified guarantee. - - Test-hygiene gotcha: .pdm-python stores an absolute venv path; cp -R'ing a project carries it and pdm will happily operate on the ORIGINAL project's venv (first D4 run said '1 to update' against the copied pointer). Any harness must delete .pdm-python (it is gitignored in real checkouts, so fresh clones are safe). - - A {file=..., hash=...} entry is accepted inside a strategy=[...,"static_urls"] lock — pdm does not enforce entry-shape consistency with the recorded strategy. - REC: pdm is a strong vendor-v2 target; prefer the pyproject-override route over lock-only splicing. Mechanism: (1) drop the patched wheel at .socket/vendor/pypi//; (2) add `[tool.pdm.resolution.overrides] = \"file:///${PROJECT_ROOT}/.socket/vendor/pypi//\"` to pyproject.toml; (3) run `pdm lock` (the tool generates the lock — relative `path` key + our sha256 in files[]). This covers direct AND transitive deps with one uniform edit, is fully committable/relative, is checksum fail-closed on tamper (unearth HashMismatchError, exit 1), and — unlike a lock-only splice — survives `pdm lock` and `pdm update ` byte-identically. Lock-only splicing (D1 shape: add `path`, replace files[] with single patched-wheel hash, leave content_hash) is a workable fallback on pdm 2.27.0 (sync/--check/--frozen-lockfile all pass, lock byte-stable, fail-closed hashes) but any `pdm lock`/`pdm update` silently reverts it, so it should only be used where pyproject must stay pristine, paired with drift detection. Strictest committable-guarantee install to document for users/CI: `pdm install --check --frozen-lockfile` (or `pdm sync`) on a fresh checkout — verified green with cold caches, fresh venv, marker present. - FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/pdm - -=== pipenv (pipenv 2026.6.2 (pip 26.0 host / pip 26.1.1 seeded in project venvs), Python 3.14.3, macOS arm64) === - [CONFIRMED] V1 lock shape for Pipfile file ref - Pipfile `six = {file = "./.socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl"}` -> `pipenv lock` exit 0. Verbatim default entry: {"file": "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", "hashes": ["sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'"}. Key is `file` (not `path`); `./` prefix KEPT; NO `version` key; NO `index` key; hashes are NOT computed from the local wheel — they are the PyPI REGISTRY hashes for six 1.16.0 (sdist 1e61c374 + original wheel 8abb2f1d; our patched wheel is 573ecfcc and absent). pipenv parses name/version from the wheel filename and fetches index hashes. Despite the mismatched hashes, `pipenv sync` on this tool-generated lock exits 0 and installs the PATCHED wheel (marker imports). - [CONFIRMED] V2 lock-only edit (decisive): registry Pipfile + hand-edited six lock entry survives strictest install - Registry Pipfile `six = "==1.16.0"`; only default.six in Pipfile.lock replaced with V1 shape (file ref, hashes -> [sha256:573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0], index+version dropped, markers kept, _meta untouched). Fresh state (rm .venv, cold PIPENV_CACHE_DIR/PIP_CACHE_DIR, PIPENV_VENV_IN_PROJECT=1): `pipenv sync` exit 0, `pipenv install --deploy` exit 0, `pipenv verify` exit 0 ("Pipfile.lock is up-to-date."), `.venv` import six -> SOCKET_PATCH_MARKER=9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f, lock sha256 byte-unchanged (e122334497c65539e702b817526334bb58888ec8558e56795fca36c83de61565 before and after). CAVEAT: the hash we wrote is decorative — see V4. verify/--deploy only compare _meta.hash (derived from Pipfile) so default-only edits stay green. - [CONFIRMED] V3 lock-only transitive: python-dateutil in Pipfile, six flat in lock, edit six only - Pipfile `python-dateutil = "==2.8.2"` -> lock has six FLAT in default (resolved ==1.17.0; transitive entries carry hashes+markers+version but NO `index` key). Replaced only default.six with the vendored 1.16.0 file ref. Fresh sync/install --deploy/verify all exit 0; venv has dateutil 2.8.2 + patched six 1.16.0 (marker present, dateutil.parser works); lock byte-unchanged (4ab20803...). Note the edit also downgrades 1.17.0->1.16.0 silently — pipenv never cross-checks installed version vs dependents at sync time (per-entry --no-deps installs). - [REFUTED] V4 tamper: tampered wheel -> pipenv sync nonzero with pip hash error - Tampered wheel (sha256 7c7da793670a7c386de23bd1760255ce3d23116352dce6b5369f03f9acb51418, lock demanded 573ecfcc...) at the vendored path: `pipenv sync` exit 0, `pipenv install --deploy` exit 0, `pipenv verify` exit 0, and `import six` shows the tampered payload (SOCKET_PATCH_MARKER='TAMPERED-EVIL'). Root cause (from `pipenv sync --verbose`): file-ref entries are installed in a separate 'Install Phase: Editable Requirements' — pipenv writes the requirement line as `./.socket/...whl ; ` with NO --hash, and invokes `pip install -i https://pypi.org/simple --no-input --upgrade --no-deps -r ` with NO --require-hashes. The `hashes` array on a `file` entry is never enforced. Raw pip 26 CAN fail closed for local wheels (`pip install --require-hashes` with ` --hash=sha256:` -> exit 1, 'THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE ... Expected sha256 8abb2f1d... Got 573ecfcc...'); the gap is purely pipenv's installer path. - [CONFIRMED] V5 CWD: relative file ref resolves against Pipfile dir or CWD? - Resolves against the PIPFILE's directory, not CWD — the pip-inherited hazard does NOT manifest through pipenv. (a) `pipenv sync` from /subdir (no .socket there): exit 0, patched marker installed into /.venv. (b) From an unrelated dir with PIPENV_PIPFILE=/abs/path/Pipfile AND a decoy six wheel (marker DECOY-CWD) planted at $CWD/.socket/vendor/pypi//: exit 0, venv created at the project, installed marker = 9f6b2c4e... (project wheel), decoy never touched. Mechanically pipenv runs its vendored pip from the project root even though the temp requirements file lives under $TMPDIR. - [CONFIRMED] V6 silent unpatch: which operations regenerate the lock to registry - From the V2 edited state: `pipenv lock` exit 0 -> lock REGENERATED, six reverts to registry entry (==1.16.0, registry hashes, index:pypi); vendored ref erased silently. `pipenv update six` exit 0 -> WORSE: rewrites the Pipfile pin `six = "==1.16.0"` to `six = "*"`, relocks to registry six ==1.17.0 and installs it (marker gone). Bare `pipenv install` exit 0 -> lock byte-UNCHANGED (its _meta hash still matches the untouched Pipfile, so no relock) and the vendored patched wheel is installed (marker present) — bare install is safe. - [CONFIRMED] V7 serializer / byte-stability - pipenv's lock formatting is byte-identical to Python `json.dumps(obj, indent=4, sort_keys=True) + "\n"`: 4-space indent, ALL keys sorted alphabetically at every nesting level (_meta/default/develop at top; file/hashes/index/markers/version within entries), default separators, exactly one trailing newline (file ends `}\n`, verified via xxd) — proven by re-rendering a pipenv-written lock and comparing bytes (True). After V2's pipenv sync + install --deploy + verify, lock sha256 unchanged: e122334497c65539e702b817526334bb58888ec8558e56795fca36c83de61565 before and after (same for V3: 4ab20803...). ensure_ascii behavior untested (all fixture content is ASCII). - SURPRISES: - - pipenv lock fills `hashes` for a local file ref with the PyPI REGISTRY hashes (looked up via name/version parsed from the wheel filename), not the local file's sha256 — so even the tool-generated lock for a vendored wheel contains hashes that don't match the artifact, and sync still succeeds. - - The `hashes` array on `file` lock entries is completely decorative: pipenv installs file refs in a separate 'Editable Requirements' pip phase with --no-deps and no --hash/--require-hashes. Tampered artifact installs with exit 0 across sync/--deploy/verify (V4 refuted). Raw pip 26 would have failed closed. - - `pipenv update six` doesn't just relock — it REWRITES the user's Pipfile pin from `==1.16.0` to `*` and upgrades to 1.17.0, a double silent-unpatch (Pipfile + lock). - - Bare `pipenv install` does NOT relock when the Pipfile is unchanged (only _meta.hash is compared), so the patched lock state survives it — only `lock`/`update`(/`upgrade`) clobber. - - Transitive lock entries omit the `index` key; direct registry entries carry it. The lock-only edit must drop `index` and `version` to match the tool's own file-ref shape exactly. - - Relative `file` refs resolve against the Pipfile directory even via PIPENV_PIPFILE from an unrelated CWD with a decoy planted at CWD — pipenv does not inherit pip's requirements-file-relative-path-vs-CWD hazard. - REC: Pipenv vendor v2 is VIABLE via the lock-only edit (V2/V3 shape): replace only the default. entry with {file: \"./.socket/vendor/pypi//\", hashes: [sha256:], markers: } (drop index+version), leave _meta untouched, serialize with json.dumps(indent=4, sort_keys=True)+\"\\n\" for guaranteed byte-stability. This survives pipenv sync, install --deploy, verify, and bare pipenv install on a fresh checkout with cold caches, and path resolution is safely Pipfile-dir-relative. HOWEVER, the committable guarantee's 'checksum-verified where the format supports it' clause must be classified as NOT SUPPORTED for pipenv: pipenv never enforces hashes on file entries (tamper installs silently, V4), so Socket tooling must provide tamper-evidence itself — verify the vendored wheel's sha256 against the lock entry in `socket patch verify`/CI (the hash we write into the lock is the right self-documenting place, and becomes enforced for free if pipenv ever fixes its file-ref install phase). Treat `pipenv lock` and `pipenv update/upgrade` as unpatch events: detect drift by checking the default. entry still carries the .socket/vendor file ref (cheap string probe) and re-apply; `pipenv update ` additionally reverts any Pipfile pin to `*`, so re-application must not assume the Pipfile is intact. Do NOT rely on the Pipfile file-ref form (direct-file/transitive-file pairs) as the primary mechanism — it works but mutates the user's Pipfile and still gets registry hashes in the lock; prefer lock-only. - FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/pipenv - -=== gemChecksums (bundler 2.7.2 (ruby 3.3.11, RubyGems 3.5.22, ruby:3.3 docker image, aarch64-linux)) === - [CONFIRMED] G1 enable + capture: lockfile_checksums config before first lock, and bundle lock --add-checksums on an existing lock, both produce a CHECKSUMS section; capture registry line grammar verbatim - Both routes produce the byte-identical section. Verbatim (2-space indent, single space before token, 64 lowercase hex): 'CHECKSUMS\n rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1'. 'bundle config set --local lockfile_checksums true' then 'bundle lock' exits 0; separately, a lock created WITHOUT the config has no CHECKSUMS section and 'bundle lock --add-checksums' (exit 0, 'Writing lockfile to /app/Gemfile.lock') adds the identical line. - [CONFIRMED] G2 path-gem CHECKSUMS form (decisive): what does CHECKSUMS contain for a path-sourced gem? - NOT omitted, NOT sha256'd: the path gem gets a BARE entry ' rack (3.1.8)' (name + version, no token). Full tool-generated lock: PATH/remote: vendored/rack-3.1.8 (Bundler strips the Gemfile's leading './'), empty 'GEM remote: https://rubygems.org/ specs:' block retained, DEPENDENCIES entry becomes 'rack (= 3.1.8)!' (trailing bang), CHECKSUMS ' rack (3.1.8)'. bundle install exits 0, leaves the lock byte-identical, uses the vendored dir IN PLACE (never copies into vendor/bundle): $LOADED_FEATURES shows /app/vendored/rack-3.1.8/lib/rack.rb whose line 1 is the patch marker. Gem dir was the installed gems/rack-3.1.8 copy + specifications/rack-3.1.8.gemspec copied to vendored/rack-3.1.8/rack.gemspec. - [CONFIRMED] G3 byte-stability: hand-written lock in the captured pair-edited form survives bundle install, BUNDLE_FROZEN=true bundle install, and bundle lock byte-identically with all exit 0 - Hand-written lock (PATH section + DEPENDENCIES '!' pin + bare CHECKSUMS entry) was diff-identical to Bundler's own G2 output before any run. From a fresh dir containing only Gemfile, Gemfile.lock, .bundle/config, vendored/ (committed files only), each step in a fresh container with vendor/ removed between (cold caches): bundle install exit 0, BUNDLE_FROZEN=true bundle install exit 0, bundle lock exit 0 ('Writing lockfile'); lock sha256 3086e757...60a2 unchanged across all three ('ALL BYTE-IDENTICAL'). Committable guarantee validated again directly on the committed fixture tree, including patched-file load check. - [REFUTED] G4 stale checksum failure mode: registry sha256 left on the path gem's CHECKSUMS line breaks install/frozen-install/lock - Nothing breaks and nothing changes on Bundler 2.7.2: bundle install exit 0, BUNDLE_FROZEN=true bundle install exit 0, bundle lock exit 0, and all three leave the stale token byte-for-byte in place — Bundler never verifies checksums for PATH sources and preserves existing CHECKSUMS entries on rewrite. The v1 bug is LATENT, not loud: only a from-scratch regen (delete lock, bundle lock) emits the bare form, i.e. permanent diff churn vs anything Bundler would write. Negative control proves enforcement is live for registry gems: corrupting the sha256 of registry-sourced rack fails cold install with exit 37, 'Bundler found mismatched checksums. This is a potential security risk. ... from the lockfile CHECKSUMS at Gemfile.lock:14:16 ... from the API at https://rubygems.org/' (caught at metadata time, pre-download). - [CONFIRMED] G5 platform entries: CHECKSUMS lines are not platform-suffixed for normal linux installs of a pure-ruby gem - Pure-ruby rack gets exactly one un-suffixed line ' rack (3.1.8) sha256=...' even though PLATFORMS lists both aarch64-linux and ruby. The suffix grammar DOES exist for native gems: locking ffi 1.17.2 yields one CHECKSUMS line per platform spec mirroring the GEM specs entries, e.g. ' ffi (1.17.2-aarch64-linux-gnu) sha256=c910bd3c...' alongside bare ' ffi (1.17.2) sha256=...'. So the emitter must match (version[-platform]) tokens against specs entries, but for the pure-ruby vendored case there is a single bare-version line. - SURPRISES: - - G4 refuted the expected loud failure: a stale registry sha256 on a path gem's CHECKSUMS line is silently preserved by install, frozen install, AND bundle lock on Bundler 2.7.2 — checksum verification is skipped entirely for PATH sources, so the v1 bug manifests only as permanent lock-vs-regen divergence, not an error. - - Reverse direction is the loud one (pins the ROLLBACK emitter): a bare token-less CHECKSUMS entry on a REGISTRY-sourced gem fails BUNDLE_FROZEN=true bundle install with exit 16: 'Your lockfile has an empty CHECKSUMS entry for "rack", but can't be updated because frozen mode is set'; plain bundle install succeeds but REWRITES the lock to fill the sha back in (not byte-stable). Rollback must restore the registry sha256 token verbatim. - - Registry checksum mismatches are caught against the rubygems.org compact-index API at metadata-fetch time (exit 37), before any gem download — so a patched .gem placed under a registry source would fail even with warm caches. - - Path gems are used in place, never copied into vendor/bundle, and never checksum-verified — the gem format's committable guarantee for vendored artifacts rests on git content alone, with no Bundler-side integrity check available. - - Bundler normalizes Gemfile path: './vendored/rack-3.1.8' to lock 'remote: vendored/rack-3.1.8' (leading './' stripped) — an emitter writing the './' form would not be byte-stable under bundle lock. - - The installed gemspec from vendor/bundle/ruby/3.3.0/specifications/ works as-is when copied to the root of the vendored gem dir (stub format, s.files only lists docs — harmless since path gems resolve load paths from require_paths). - REC: Adopt the PATH-source conversion for gem vendoring. Emitter spec, pinned by tool-generated fixtures: (1) move the gem from GEM specs to a PATH section with 'remote: ', keep the (possibly empty) GEM block; (2) append '!' to the gem's DEPENDENCIES line; (3) replace its CHECKSUMS line with the bare ' ()' form — strip the sha256 token (and any platform-suffixed sibling lines for that name/version, since PATH specs collapse to one bare-version entry); (4) vendored dir = installed gem dir + specifications/-.gemspec copied to /.gemspec. This form is byte-stable under bundle install, frozen install, and bundle lock (Bundler 2.7.2). Rollback must restore the original registry 'sha256=' token exactly — a bare entry on a registry gem hard-fails frozen installs (exit 16) and causes lock churn on plain installs. Caveats to document: Bundler performs no checksum verification for path gems (integrity rides on git), and leaving the stale registry sha (v1 bug) errors nowhere on 2.7.x — detect/repair it proactively rather than relying on Bundler to complain. Note all locks here were generated on aarch64-linux; PLATFORMS content is host-dependent, so the emitter must never touch PLATFORMS. - FIXTURES: /Users/mikolalysenko/Projects/socket-patch/.claude/worktrees/vendor/spikes/gem-checksums - diff --git a/spikes/bun/README.md b/spikes/bun/README.md deleted file mode 100644 index 2b24ed6..0000000 --- a/spikes/bun/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# bun vendor-v2 spike fixtures - -Tool: **bun 1.3.14 (0d9b296a)**, macOS (Darwin 25.5.0, arm64). Node v24.12.0 used only as a -require() oracle. Captured 2026-06-10. All installs ran with an isolated `BUN_INSTALL_CACHE_DIR`. - -bun 1.3.x writes the **text lockfile `bun.lock` by default** (no `--save-text-lockfile` needed; -`bun.lockb` is never produced). `bun ci` exists and is an alias of -`bun install --frozen-lockfile`. - -Patched artifact: `artifacts/left-pad-1.3.0-patched.tgz` — registry left-pad-1.3.0.tgz -(sha1 5b8a3a7765dfe001261dde915589e782f8c94d1e) unpacked, marker comment -`/* SOCKET-PATCHED left-pad@1.3.0 marker:9f6b2c4e */` prepended to `package/index.js`, -repacked with `tar czf` keeping the `package/` prefix. -Its `sha512` (base64) is `BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ==` -— this exact string appears as the integrity element of every tarball lock entry below -(**integrity = sha512 of the raw tarball bytes**). -`artifacts/haspad-1.0.0.tgz` is a shell-built one-file package depending on `left-pad: ^1.3.0`, -used to force a nested lock key. - -Every `after/bun.lock` was generated by `bun install` itself, with one deliberate exception: -the two lock-only-edit pairs (bn3, bn4c), where the *edit* is the thing under test. There the -edited entry copies bun's own bn1/bn2-generated shape verbatim, and bun then *blessed* the file: -`bun install --frozen-lockfile` and `bun ci` exit 0 and a subsequent plain `bun install` leaves -the lock **byte-identical** (cmp-verified, including on a replay of the committed fixture). - -## Pairs - -- `bn1-file-deps/` — ground truth for `"lp": "./lp.tgz"` and `"lp2": "file:./lp2.tgz"`. - Local-tarball packages entry is a **3-tuple**: - `"lp": ["left-pad@./lp.tgz", {deps-object}, ""]` — - key = the *alias*, element 0 = `@`, **no registry element**, - integrity **present**. Bare `./lp.tgz` and `file:./lp2.tgz` specs produce identical entry - shapes (spec string preserved only in the workspaces section). -- `bn1-nested/` — two-level key grammar. Registry entry is a **4-tuple** - `["name@version", "", {deps}, "sha512-..."]`; conflict nesting uses a - flat slash key: `"haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-..."]`. -- `bn2-overrides/` — root `"overrides": {"left-pad": "file:.socket/vendor/npm//left-pad-1.3.0.tgz"}`. - Lock gains a top-level `"overrides"` section (spec copied verbatim) and the `"left-pad"` - packages entry becomes the 3-tuple tarball shape with the `.socket/...` path. Marker installs. -- `bn2-resolutions/` — same but via `"resolutions"`. bun normalizes it into the **same - `"overrides"` lock section**; lock is otherwise identical to bn2-overrides. -- `bn3-lock-only/` — **the decisive pair.** before = pure registry project + bun-generated lock. - after = *identical package.json* (still `"left-pad": "1.3.0"`), only the packages entry - rewritten to the bn1 tarball shape + the vendored tarball added. - `rm -rf node_modules && bun install --frozen-lockfile` → exit 0, marker present; - plain `bun install` and `bun ci` keep the lock byte-identical. PASS. -- `bn4-override-collapse/` — before: two left-pad versions in the tree (root 1.2.0, nested - `haspad/left-pad` 1.3.0). after: name-keyed override → **both instances collapse to the single - patched root copy; the nested key disappears from the lock** (`require` from haspad resolves - the patched 1.3.0). Name-keyed overrides move *every* version. -- `bn4b-version-key-ignored/` — trap: `"overrides": {"left-pad@1.3.0": "file:..."}` is - **silently ignored** (copied verbatim into the lock's overrides section, but the nested 1.3.0 - stays vanilla registry — fail-open no-op). bun overrides are name-only. -- `bn4c-targeted-nested/` — lock-only edit of *just* `"haspad/left-pad"` to the tarball shape: - root stays vanilla 1.2.0, nested becomes patched 1.3.0, frozen install passes, byte-stable. - Lock edits give per-instance targeting that overrides cannot. - -## Verbatim outcomes not embodied in a pair - -- **BN5 tamper:** byte-flip the vendored tarball → `bun install --frozen-lockfile` *and* plain - `bun install` exit 1 with - `error: Integrity check failed for tarball: left-pad` / - `error: IntegrityCheckFailed extracting tarball from left-pad`; nothing installed, lock - unchanged. Fail-closed. -- **BN6 warm cache:** cache primed with registry left-pad@1.3.0 (cache entry - `left-pad@1.3.0@@@1`) → patched tarball bytes still win; and the patched extraction does - **not** poison later registry installs from the same cache. -- **BN7 fresh checkout:** `git clone` of {package.json, edited bun.lock, .socket tarball} + - cold cache + dead `HTTPS_PROXY` (network blocked) → `bun install --frozen-lockfile` and - `bun ci` exit 0, marker present, fully offline. -- **Survival:** the lock-only edit survives `bun add is-odd`. `bun update left-pad` reverts the - lock entry to the registry tuple (while stale patched bytes linger in node_modules until the - next clean install) — the one operation that undoes the patch. - -Replay any pair: `cd /after && BUN_INSTALL_CACHE_DIR=$(mktemp -d) bun ci && head -1 node_modules/left-pad/index.js` -(bn1/bn4 pairs install `lp`/`haspad` aliases; check the corresponding node_modules path). diff --git a/spikes/bun/bn1-file-deps/after/bun.lock b/spikes/bun/bn1-file-deps/after/bun.lock deleted file mode 100644 index 6990295..0000000 --- a/spikes/bun/bn1-file-deps/after/bun.lock +++ /dev/null @@ -1,18 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bn1-ground-truth", - "dependencies": { - "lp": "./lp.tgz", - "lp2": "file:./lp2.tgz", - }, - }, - }, - "packages": { - "lp": ["left-pad@./lp.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], - - "lp2": ["left-pad@./lp2.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], - } -} diff --git a/spikes/bun/bn1-file-deps/after/package.json b/spikes/bun/bn1-file-deps/after/package.json deleted file mode 100644 index 950e276..0000000 --- a/spikes/bun/bn1-file-deps/after/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "bn1-ground-truth", - "version": "1.0.0", - "dependencies": { - "lp": "./lp.tgz", - "lp2": "file:./lp2.tgz" - } -} diff --git a/spikes/bun/bn1-file-deps/before/package.json b/spikes/bun/bn1-file-deps/before/package.json deleted file mode 100644 index 950e276..0000000 --- a/spikes/bun/bn1-file-deps/before/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "bn1-ground-truth", - "version": "1.0.0", - "dependencies": { - "lp": "./lp.tgz", - "lp2": "file:./lp2.tgz" - } -} diff --git a/spikes/bun/bn1-nested/after/bun.lock b/spikes/bun/bn1-nested/after/bun.lock deleted file mode 100644 index 13377a5..0000000 --- a/spikes/bun/bn1-nested/after/bun.lock +++ /dev/null @@ -1,20 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bn1-nested", - "dependencies": { - "haspad": "file:./haspad-1.0.0.tgz", - "left-pad": "1.2.0", - }, - }, - }, - "packages": { - "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], - - "left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], - - "haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], - } -} diff --git a/spikes/bun/bn1-nested/after/package.json b/spikes/bun/bn1-nested/after/package.json deleted file mode 100644 index bb8109c..0000000 --- a/spikes/bun/bn1-nested/after/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "bn1-nested", - "version": "1.0.0", - "dependencies": { - "left-pad": "1.2.0", - "haspad": "file:./haspad-1.0.0.tgz" - } -} diff --git a/spikes/bun/bn1-nested/before/package.json b/spikes/bun/bn1-nested/before/package.json deleted file mode 100644 index bb8109c..0000000 --- a/spikes/bun/bn1-nested/before/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "bn1-nested", - "version": "1.0.0", - "dependencies": { - "left-pad": "1.2.0", - "haspad": "file:./haspad-1.0.0.tgz" - } -} diff --git a/spikes/bun/bn2-overrides/after/bun.lock b/spikes/bun/bn2-overrides/after/bun.lock deleted file mode 100644 index fa3106d..0000000 --- a/spikes/bun/bn2-overrides/after/bun.lock +++ /dev/null @@ -1,18 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bn2-overrides", - "dependencies": { - "left-pad": "^1.3.0", - }, - }, - }, - "overrides": { - "left-pad": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", - }, - "packages": { - "left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], - } -} diff --git a/spikes/bun/bn2-overrides/after/package.json b/spikes/bun/bn2-overrides/after/package.json deleted file mode 100644 index 70b6d69..0000000 --- a/spikes/bun/bn2-overrides/after/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "bn2-overrides", - "version": "1.0.0", - "dependencies": { - "left-pad": "^1.3.0" - }, - "overrides": { - "left-pad": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" - } -} diff --git a/spikes/bun/bn2-overrides/before/bun.lock b/spikes/bun/bn2-overrides/before/bun.lock deleted file mode 100644 index 64ac20e..0000000 --- a/spikes/bun/bn2-overrides/before/bun.lock +++ /dev/null @@ -1,15 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bn2-overrides", - "dependencies": { - "left-pad": "^1.3.0", - }, - }, - }, - "packages": { - "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], - } -} diff --git a/spikes/bun/bn2-overrides/before/package.json b/spikes/bun/bn2-overrides/before/package.json deleted file mode 100644 index 9734154..0000000 --- a/spikes/bun/bn2-overrides/before/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "bn2-overrides", - "version": "1.0.0", - "dependencies": { - "left-pad": "^1.3.0" - } -} diff --git a/spikes/bun/bn2-resolutions/after/bun.lock b/spikes/bun/bn2-resolutions/after/bun.lock deleted file mode 100644 index 133c148..0000000 --- a/spikes/bun/bn2-resolutions/after/bun.lock +++ /dev/null @@ -1,18 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bn2-resolutions", - "dependencies": { - "left-pad": "^1.3.0", - }, - }, - }, - "overrides": { - "left-pad": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", - }, - "packages": { - "left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], - } -} diff --git a/spikes/bun/bn2-resolutions/after/package.json b/spikes/bun/bn2-resolutions/after/package.json deleted file mode 100644 index 5b7ddaa..0000000 --- a/spikes/bun/bn2-resolutions/after/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "bn2-resolutions", - "version": "1.0.0", - "dependencies": { - "left-pad": "^1.3.0" - }, - "resolutions": { - "left-pad": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" - } -} diff --git a/spikes/bun/bn3-lock-only/after/bun.lock b/spikes/bun/bn3-lock-only/after/bun.lock deleted file mode 100644 index a241cb8..0000000 --- a/spikes/bun/bn3-lock-only/after/bun.lock +++ /dev/null @@ -1,15 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bn3-lockonly", - "dependencies": { - "left-pad": "1.3.0", - }, - }, - }, - "packages": { - "left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], - } -} diff --git a/spikes/bun/bn3-lock-only/after/package.json b/spikes/bun/bn3-lock-only/after/package.json deleted file mode 100644 index 14649ef..0000000 --- a/spikes/bun/bn3-lock-only/after/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "bn3-lockonly", - "version": "1.0.0", - "dependencies": { - "left-pad": "1.3.0" - } -} diff --git a/spikes/bun/bn3-lock-only/before/bun.lock b/spikes/bun/bn3-lock-only/before/bun.lock deleted file mode 100644 index b0c5264..0000000 --- a/spikes/bun/bn3-lock-only/before/bun.lock +++ /dev/null @@ -1,15 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bn3-lockonly", - "dependencies": { - "left-pad": "1.3.0", - }, - }, - }, - "packages": { - "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], - } -} diff --git a/spikes/bun/bn3-lock-only/before/package.json b/spikes/bun/bn3-lock-only/before/package.json deleted file mode 100644 index 14649ef..0000000 --- a/spikes/bun/bn3-lock-only/before/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "bn3-lockonly", - "version": "1.0.0", - "dependencies": { - "left-pad": "1.3.0" - } -} diff --git a/spikes/bun/bn4-override-collapse/after/bun.lock b/spikes/bun/bn4-override-collapse/after/bun.lock deleted file mode 100644 index c8e7975..0000000 --- a/spikes/bun/bn4-override-collapse/after/bun.lock +++ /dev/null @@ -1,21 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bn4-twover", - "dependencies": { - "haspad": "file:./haspad-1.0.0.tgz", - "left-pad": "1.2.0", - }, - }, - }, - "overrides": { - "left-pad": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", - }, - "packages": { - "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], - - "left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], - } -} diff --git a/spikes/bun/bn4-override-collapse/after/package.json b/spikes/bun/bn4-override-collapse/after/package.json deleted file mode 100644 index ed30676..0000000 --- a/spikes/bun/bn4-override-collapse/after/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "bn4-twover", - "version": "1.0.0", - "dependencies": { - "left-pad": "1.2.0", - "haspad": "file:./haspad-1.0.0.tgz" - }, - "overrides": { - "left-pad": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" - } -} diff --git a/spikes/bun/bn4-override-collapse/before/bun.lock b/spikes/bun/bn4-override-collapse/before/bun.lock deleted file mode 100644 index 13377a5..0000000 --- a/spikes/bun/bn4-override-collapse/before/bun.lock +++ /dev/null @@ -1,20 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bn1-nested", - "dependencies": { - "haspad": "file:./haspad-1.0.0.tgz", - "left-pad": "1.2.0", - }, - }, - }, - "packages": { - "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], - - "left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], - - "haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], - } -} diff --git a/spikes/bun/bn4-override-collapse/before/package.json b/spikes/bun/bn4-override-collapse/before/package.json deleted file mode 100644 index bb8109c..0000000 --- a/spikes/bun/bn4-override-collapse/before/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "bn1-nested", - "version": "1.0.0", - "dependencies": { - "left-pad": "1.2.0", - "haspad": "file:./haspad-1.0.0.tgz" - } -} diff --git a/spikes/bun/bn4b-version-key-ignored/after/bun.lock b/spikes/bun/bn4b-version-key-ignored/after/bun.lock deleted file mode 100644 index 74634b1..0000000 --- a/spikes/bun/bn4b-version-key-ignored/after/bun.lock +++ /dev/null @@ -1,23 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bn4b", - "dependencies": { - "haspad": "file:./haspad-1.0.0.tgz", - "left-pad": "1.2.0", - }, - }, - }, - "overrides": { - "left-pad@1.3.0": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", - }, - "packages": { - "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], - - "left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], - - "haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], - } -} diff --git a/spikes/bun/bn4b-version-key-ignored/after/package.json b/spikes/bun/bn4b-version-key-ignored/after/package.json deleted file mode 100644 index a5d3eba..0000000 --- a/spikes/bun/bn4b-version-key-ignored/after/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "bn4b", - "version": "1.0.0", - "dependencies": { - "left-pad": "1.2.0", - "haspad": "file:./haspad-1.0.0.tgz" - }, - "overrides": { - "left-pad@1.3.0": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" - } -} diff --git a/spikes/bun/bn4c-targeted-nested/after/bun.lock b/spikes/bun/bn4c-targeted-nested/after/bun.lock deleted file mode 100644 index 32634bf..0000000 --- a/spikes/bun/bn4c-targeted-nested/after/bun.lock +++ /dev/null @@ -1,20 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bn4c-targeted", - "dependencies": { - "haspad": "file:./haspad-1.0.0.tgz", - "left-pad": "1.2.0", - }, - }, - }, - "packages": { - "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], - - "left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], - - "haspad/left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], - } -} diff --git a/spikes/bun/bn4c-targeted-nested/after/package.json b/spikes/bun/bn4c-targeted-nested/after/package.json deleted file mode 100644 index 0bce809..0000000 --- a/spikes/bun/bn4c-targeted-nested/after/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "bn4c-targeted", - "version": "1.0.0", - "dependencies": { - "left-pad": "1.2.0", - "haspad": "file:./haspad-1.0.0.tgz" - } -} diff --git a/spikes/bun/bn4c-targeted-nested/before/bun.lock b/spikes/bun/bn4c-targeted-nested/before/bun.lock deleted file mode 100644 index 3505b96..0000000 --- a/spikes/bun/bn4c-targeted-nested/before/bun.lock +++ /dev/null @@ -1,20 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bn4c-targeted", - "dependencies": { - "haspad": "file:./haspad-1.0.0.tgz", - "left-pad": "1.2.0", - }, - }, - }, - "packages": { - "haspad": ["haspad@./haspad-1.0.0.tgz", { "dependencies": { "left-pad": "^1.3.0" } }, "sha512-Ct3JBgq1p/gbE4bZVj4DH8g6yueYk9gzR70Z0IXrjsI2UxcieFppUx84kdARnyO1wKM1p6dNw0hgTYnokLEtOQ=="], - - "left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], - - "haspad/left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], - } -} diff --git a/spikes/bun/bn4c-targeted-nested/before/package.json b/spikes/bun/bn4c-targeted-nested/before/package.json deleted file mode 100644 index 0bce809..0000000 --- a/spikes/bun/bn4c-targeted-nested/before/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "bn4c-targeted", - "version": "1.0.0", - "dependencies": { - "left-pad": "1.2.0", - "haspad": "file:./haspad-1.0.0.tgz" - } -} diff --git a/spikes/gem-checksums/README.md b/spikes/gem-checksums/README.md deleted file mode 100644 index a959447..0000000 --- a/spikes/gem-checksums/README.md +++ /dev/null @@ -1,111 +0,0 @@ -# Spike: Bundler >= 2.6 CHECKSUMS for vendored (path-sourced) gem patching - -Tool versions (everything ran inside docker, fresh container per step = cold caches): - -- image: `ruby:3.3` (digest base `56d789a4b8e8`), aarch64-linux -- ruby 3.3.11 (2026-03-26 revision 1f2d15125a) [aarch64-linux] -- RubyGems 3.5.22 -- **Bundler 2.7.2** (installed via `gem install bundler -v '~> 2.7' --no-document`) -- invocation pattern: `docker run --rm -v :/app -w /app -e BUNDLE_APP_CONFIG=/app/.bundle ruby:3.3 ...` - -Every `after/Gemfile.lock` was written by Bundler itself (`bundle lock` or `bundle install`), -never hand-written. Locks were generated on aarch64-linux, so `PLATFORMS` contains -`aarch64-linux` + `ruby`; regenerating on x86_64 would add `x86_64-linux`. - -Common config (committed as `.bundle/config` in each tree): - -```yaml ---- -BUNDLE_PATH: "vendor/bundle" -BUNDLE_LOCKFILE_CHECKSUMS: "true" -``` - -## Pairs - -### registry-with-checksums/ (G1) -- `before/`: Gemfile (`gem "rack", "3.1.8"` from rubygems.org) + `.bundle/config`, no lock. -- `after/`: lock produced by `bundle lock` with `lockfile_checksums true` set before first lock. - Verbatim registry CHECKSUMS grammar (2-space indent, single space before token, 64 lowercase hex): - - ``` - CHECKSUMS - rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 - ``` - - `bundle lock --add-checksums` on a lock created *without* the config produces the - byte-identical CHECKSUMS section (verified). - -### path-with-checksums/ (G2 — pins the emitter) -- `before/`: the registry-locked project (what socket-patch sees pre-patch). -- `after/`: Gemfile switched to `gem "rack", "3.1.8", path: "./vendored/rack-3.1.8"`; - `vendored/rack-3.1.8/` = installed gem dir copied from `vendor/bundle/ruby/3.3.0/gems/` - + gemspec copied from `specifications/rack-3.1.8.gemspec` to `vendored/rack-3.1.8/rack.gemspec` - + patch marker `# socket-patch: patched rack-3.1.8 (spike marker)` as line 1 of `lib/rack.rb`. - Lock written by `bundle lock`. The path gem is **not omitted** from CHECKSUMS — it gets a - **bare entry with no sha256 token**: - - ``` - PATH - remote: vendored/rack-3.1.8 - specs: - rack (3.1.8) - - GEM - remote: https://rubygems.org/ - specs: - - ... - DEPENDENCIES - rack (= 3.1.8)! - - CHECKSUMS - rack (3.1.8) - ``` - - Notes for the emitter: Bundler strips the Gemfile's leading `./` in `remote:`; - the DEPENDENCIES entry gains a trailing `!`; the empty `GEM ... specs:` block stays. -- Byte-stability (G3): a hand-written lock in exactly this form was diff-identical to - Bundler's own output, and from a fresh checkout with cold caches all of - `bundle install`, `BUNDLE_FROZEN=true bundle install`, `bundle lock` exited 0 and left the - lock byte-identical (sha256 `3086e757...` unchanged across all three). -- Committable guarantee: cold `BUNDLE_FROZEN=true bundle install` on `after/` (committed files - only) exits 0 and `require "rack"` loads `/app/vendored/rack-3.1.8/lib/rack.rb` whose first - line is the patch marker. Path gems are used **in place** (never copied to vendor/bundle) and - are **never checksum-verified** — the format has no artifact to hash. - -### stale-checksum-v1-bug/ (G4) -- `before/`: same project as path-with-checksums/after but the lock (hand-edited, simulating the - v1 emitter) keeps the REGISTRY sha256 token on the path gem's CHECKSUMS line. -- `after/`: what `bundle lock` writes given that lock — **byte-identical**; the stale token is - silently preserved. -- Findings: `bundle install`, `BUNDLE_FROZEN=true bundle install`, and `bundle lock` all exit 0 - and never touch or verify the stale token (Bundler skips checksum verification for PATH - sources entirely). The v1 lock is therefore *latently* divergent: deleting the lock and - re-running `bundle lock` produces the bare ` rack (3.1.8)` form, i.e. permanent diff churn - vs. anything Bundler would emit, but no loud failure on Bundler 2.7.2. -- Negative control proving CHECKSUMS enforcement is live for registry gems: corrupting the - sha256 of registry-sourced rack fails cold `bundle install` with exit 37: - `Bundler found mismatched checksums. This is a potential security risk.` (caught against the - rubygems.org API at metadata time, before download). - -### bare-checksum-registry-gem/ (reverse probe — pins the rollback emitter) -- `before/`: registry-sourced lock whose CHECKSUMS entry was stripped to the bare - ` rack (3.1.8)` form (no token). -- `after/`: lock as rewritten by plain `bundle install` — Bundler fills the sha256 back in - (byte-identical to registry-with-checksums/after/Gemfile.lock). -- `BUNDLE_FROZEN=true bundle install` on `before/` **fails, exit 16**: - - ``` - Your lockfile has an empty CHECKSUMS entry for "rack", but can't be updated - because frozen mode is set - ``` - - So: bare entry is *required* for path gems but *breaks frozen installs* for registry gems — - rollback must restore the registry sha256 token, and the patch emitter must strip it. - -## G5: platform suffixes -Pure-ruby rack gets exactly one CHECKSUMS line with **no platform suffix** even though -PLATFORMS lists `aarch64-linux` + `ruby`. The suffix *does* exist for native gems: locking -`ffi 1.17.2` yields one line per platform spec, e.g. -` ffi (1.17.2-aarch64-linux-gnu) sha256=...` alongside the bare ` ffi (1.17.2) sha256=...` -— the `(version-platform)` token mirrors the GEM specs entries exactly. diff --git a/spikes/gem-checksums/bare-checksum-registry-gem/after/.bundle/config b/spikes/gem-checksums/bare-checksum-registry-gem/after/.bundle/config deleted file mode 100644 index 6eb400d..0000000 --- a/spikes/gem-checksums/bare-checksum-registry-gem/after/.bundle/config +++ /dev/null @@ -1,3 +0,0 @@ ---- -BUNDLE_PATH: "vendor/bundle" -BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile b/spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile deleted file mode 100644 index 864c947..0000000 --- a/spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem "rack", "3.1.8" diff --git a/spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile.lock b/spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile.lock deleted file mode 100644 index 7898b2f..0000000 --- a/spikes/gem-checksums/bare-checksum-registry-gem/after/Gemfile.lock +++ /dev/null @@ -1,17 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - rack (3.1.8) - -PLATFORMS - aarch64-linux - ruby - -DEPENDENCIES - rack (= 3.1.8) - -CHECKSUMS - rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 - -BUNDLED WITH - 2.7.2 diff --git a/spikes/gem-checksums/bare-checksum-registry-gem/before/.bundle/config b/spikes/gem-checksums/bare-checksum-registry-gem/before/.bundle/config deleted file mode 100644 index 6eb400d..0000000 --- a/spikes/gem-checksums/bare-checksum-registry-gem/before/.bundle/config +++ /dev/null @@ -1,3 +0,0 @@ ---- -BUNDLE_PATH: "vendor/bundle" -BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile b/spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile deleted file mode 100644 index 864c947..0000000 --- a/spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem "rack", "3.1.8" diff --git a/spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile.lock b/spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile.lock deleted file mode 100644 index 9a599c6..0000000 --- a/spikes/gem-checksums/bare-checksum-registry-gem/before/Gemfile.lock +++ /dev/null @@ -1,17 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - rack (3.1.8) - -PLATFORMS - aarch64-linux - ruby - -DEPENDENCIES - rack (= 3.1.8) - -CHECKSUMS - rack (3.1.8) - -BUNDLED WITH - 2.7.2 diff --git a/spikes/gem-checksums/path-with-checksums/after/.bundle/config b/spikes/gem-checksums/path-with-checksums/after/.bundle/config deleted file mode 100644 index 6eb400d..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/.bundle/config +++ /dev/null @@ -1,3 +0,0 @@ ---- -BUNDLE_PATH: "vendor/bundle" -BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/path-with-checksums/after/Gemfile b/spikes/gem-checksums/path-with-checksums/after/Gemfile deleted file mode 100644 index 6d26ec6..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem "rack", "3.1.8", path: "./vendored/rack-3.1.8" diff --git a/spikes/gem-checksums/path-with-checksums/after/Gemfile.lock b/spikes/gem-checksums/path-with-checksums/after/Gemfile.lock deleted file mode 100644 index 98b0124..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/Gemfile.lock +++ /dev/null @@ -1,21 +0,0 @@ -PATH - remote: vendored/rack-3.1.8 - specs: - rack (3.1.8) - -GEM - remote: https://rubygems.org/ - specs: - -PLATFORMS - aarch64-linux - ruby - -DEPENDENCIES - rack (= 3.1.8)! - -CHECKSUMS - rack (3.1.8) - -BUNDLED WITH - 2.7.2 diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CHANGELOG.md b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CHANGELOG.md deleted file mode 100644 index 18069d3..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CHANGELOG.md +++ /dev/null @@ -1,998 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. For info on how to format all future additions to this file please reference [Keep A Changelog](https://keepachangelog.com/en/1.0.0/). - -## [3.1.8] - 2024-10-14 - -- Resolve deprecation warnings about uri `DEFAULT_PARSER`. ([#2249](https://github.com/rack/rack/pull/2249), [@earlopain]) - -## [3.1.7] - 2024-07-11 - -### Fixed - -- Do not remove escaped opening/closing quotes for content-disposition filenames. ([#2229](https://github.com/rack/rack/pull/2229), [@jeremyevans]) -- Fix encoding setting for non-binary IO-like objects in MockRequest#env_for. ([#2227](https://github.com/rack/rack/pull/2227), [@jeremyevans]) -- `Rack::Response` should not generate invalid `content-length` header. ([#2219](https://github.com/rack/rack/pull/2219), [@ioquatix]) -- Allow empty PATH_INFO. ([#2214](https://github.com/rack/rack/pull/2214), [@ioquatix]) - -## [3.1.6] - 2024-07-03 - -### Fixed - -- Fix several edge cases in `Rack::Request#parse_http_accept_header`'s implementation. ([#2226](https://github.com/rack/rack/pull/2226), [@ioquatix]) - -## [3.1.5] - 2024-07-02 - -### Security - -- Fix potential ReDoS attack in `Rack::Request#parse_http_accept_header`. ([GHSA-cj83-2ww7-mvq7](https://github.com/rack/rack/security/advisories/GHSA-cj83-2ww7-mvq7), [@dwisiswant0](https://github.com/dwisiswant0)) - -## [3.1.4] - 2024-06-22 - -### Fixed - -- Fix `Rack::Lint` matching some paths incorrectly as authority form. ([#2220](https://github.com/rack/rack/pull/2220), [@ioquatix]) - -## [3.1.3] - 2024-06-12 - -### Fixed - -- Fix passing non-strings to `Rack::Utils.escape_html`. ([#2202](https://github.com/rack/rack/pull/2202), [@earlopain]) -- `Rack::MockResponse` gracefully handles empty cookies ([#2203](https://github.com/rack/rack/pull/2203) [@wynksaiddestroy]) - -## [3.1.2] - 2024-06-11 - -- `Rack::Response` will take in to consideration chunked encoding responses ([#2204](https://github.com/rack/rack/pull/2204), [@tenderlove]) - -## [3.1.1] - 2024-06-11 - -- Oops! I shouldn't have shipped that - -## [3.1.0] - 2024-06-11 - -:warning: **This release includes several breaking changes.** Refer to the **Removed** section below for the list of deprecated methods that have been removed in this release. - -Rack v3.1 is primarily a maintenance release that removes features deprecated in Rack v3.0. Alongside these removals, there are several improvements to the Rack SPEC, mainly focused on enhancing input and output handling. These changes aim to make Rack more efficient and align better with the requirements of server implementations and relevant HTTP specifications. - -### SPEC Changes - -- `rack.input` is now optional. ([#1997](https://github.com/rack/rack/pull/1997), [#2018](https://github.com/rack/rack/pull/2018), [@ioquatix]) -- `PATH_INFO` is now validated according to the HTTP/1.1 specification. ([#2117](https://github.com/rack/rack/pull/2117), [#2181](https://github.com/rack/rack/pull/2181), [@ioquatix]) - - `OPTIONS *` is now accepted. ([#2114](https://github.com/rack/rack/pull/2114), [@doriantaylor](https://github.com/doriantaylor)) -- Introduce optional `rack.protocol` request and response header for handling connection upgrades. ([#1954](https://github.com/rack/rack/pull/1954), [@ioquatix]) - -### Added - -- Introduce `Rack::Multipart::MissingInputError` for improved handling of missing input in `#parse_multipart`. ([#2018](https://github.com/rack/rack/pull/2018), [@ioquatix]) -- Introduce `module Rack::BadRequest` which is included in multipart and query parser errors. ([#2019](https://github.com/rack/rack/pull/2019), [@ioquatix]) -- Add `.mjs` MIME type ([#2057](https://github.com/rack/rack/pull/2057), [@axilleas](https://github.com/axilleas)) -- `set_cookie_header` utility now supports the `partitioned` cookie attribute. This is required by Chrome in some embedded contexts. ([#2131](https://github.com/rack/rack/pull/2131), [@flavio-b](https://github.com/flavio-b)) -- Introduce `rack.early_hints` for sending `103 Early Hints` informational responses. ([#1831](https://github.com/rack/rack/pull/1831), [@casperisfine](https://github.com/casperisfine), [@jeremyevans]) - -### Changed - -- MIME type for JavaScript files (`.js`) changed from `application/javascript` to `text/javascript` ([`1bd0f15`](https://github.com/rack/rack/commit/1bd0f1597d8f4a90d47115f3e156a8ce7870c9c8), [@ioquatix]) -- Update MIME types associated to `.ttf`, `.woff`, `.woff2` and `.otf` extensions to use mondern `font/*` types. ([#2065](https://github.com/rack/rack/pull/2065), [@davidstosik]) -- `Rack::Utils.escape_html` is now delegated to `CGI.escapeHTML`. `'` is escaped to `#39;` instead of `#x27;`. (decimal vs hexadecimal) ([#2099](https://github.com/rack/rack/pull/2099), [@JunichiIto](https://github.com/JunichiIto)) -- Clarify use of `@buffered` and only update `content-length` when `Rack::Response#finish` is invoked. ([#2149](https://github.com/rack/rack/pull/2149), [@ioquatix]) - -### Deprecated - -- Deprecate automatic cache invalidation in `Request#{GET,POST}` ([#2073](https://github.com/rack/rack/pull/2073), [@jeremyevans]) -- Only cookie keys that are not valid according to the HTTP specifications are escaped. We are planning to deprecate this behaviour, so now a deprecation message will be emitted in this case. In the future, invalid cookie keys may not be accepted. ([#2191](https://github.com/rack/rack/pull/2191), [@ioquatix]) -- `Rack::Logger` is deprecated. ([#2197](https://github.com/rack/rack/pull/2197), [@ioquatix]) -- Add fallback lookup and deprecation warning for obsolete status symbols. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn](https://github.com/wtn)) -- Deprecate `Rack::Request#values_at`, use `request.params.values_at` instead ([#2183](https://github.com/rack/rack/pull/2183), [@ioquatix]) - -### Removed - -- Remove deprecated `Rack::Auth::Digest` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::Cascade::NotFound` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::Chunked` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::File`, use `Rack::Files` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::QueryParser` `key_space_limit` parameter with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::Response#header`, use `Rack::Response#headers` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated cookie methods from `Rack::Utils`: `add_cookie_to_header`, `make_delete_cookie_header`, `add_remove_cookie_to_header`. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::Utils::HeaderHash`. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::VERSION`, `Rack::VERSION_STRING`, `Rack.version`, use `Rack.release` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove non-standard status codes 306, 509, & 510 and update descriptions for 413, 422, & 451. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn](https://github.com/wtn)) -- Remove any dependency on `transfer-encoding: chunked`. ([#2195](https://github.com/rack/rack/pull/2195), [@ioquatix]) -- Remove deprecated `Rack::Request#[]`, use `request.params[key]` instead ([#2183](https://github.com/rack/rack/pull/2183), [@ioquatix]) - -### Fixed - -- In `Rack::Files`, ignore the `Range` header if served file is 0 bytes. ([#2159](https://github.com/rack/rack/pull/2159), [@zarqman]) - -## [3.0.11] - 2024-05-10 - -- Backport #2062 to 3-0-stable: Do not allow `BodyProxy` to respond to `to_str`, make `to_ary` call close . ([#2062](https://github.com/rack/rack/pull/2062), [@jeremyevans](https://github.com/jeremyevans)) - -## [3.0.10] - 2024-03-21 - -- Backport #2104 to 3-0-stable: Return empty when parsing a multi-part POST with only one end delimiter. ([#2164](https://github.com/rack/rack/pull/2164), [@JoeDupuis](https://github.com/JoeDupuis)) - -## [3.0.9.1] - 2024-02-21 - -### Security - -* [CVE-2024-26146] Fixed ReDoS in Accept header parsing -* [CVE-2024-25126] Fixed ReDoS in Content Type header parsing -* [CVE-2024-26141] Reject Range headers which are too large - -[CVE-2024-26146]: https://github.com/advisories/GHSA-54rr-7fvw-6x8f -[CVE-2024-25126]: https://github.com/advisories/GHSA-22f2-v57c-j9cx -[CVE-2024-26141]: https://github.com/advisories/GHSA-xj5v-6v4g-jfw6 - -## [3.0.9] - 2024-01-31 - -- Fix incorrect content-length header that was emitted when `Rack::Response#write` was used in some situations. ([#2150](https://github.com/rack/rack/pull/2150), [@mattbrictson](https://github.com/mattbrictson)) - -## [3.0.8] - 2023-06-14 - -- Fix some unused variable verbose warnings. ([#2084](https://github.com/rack/rack/pull/2084), [@jeremyevans], [@skipkayhil](https://github.com/skipkayhil)) - -## [3.0.7] - 2023-03-16 - -- Make query parameters without `=` have `nil` values. ([#2059](https://github.com/rack/rack/pull/2059), [@jeremyevans]) - -## [3.0.6.1] - 2023-03-13 - -### Security - -- [CVE-2023-27539] Avoid ReDoS in header parsing - -## [3.0.6] - 2023-03-13 - -- Add `QueryParser#missing_value` for handling missing values + tests. ([#2052](https://github.com/rack/rack/pull/2052), [@ioquatix]) - -## [3.0.5] - 2023-03-13 - -- Split form/query parsing into two steps. ([#2038](https://github.com/rack/rack/pull/2038), [@matthewd](https://github.com/matthewd)) - -## [3.0.4.2] - 2023-03-02 - -### Security - -- [CVE-2023-27530] Introduce multipart_total_part_limit to limit total parts - -## [3.0.4.1] - 2023-01-17 - -### Security - -- [CVE-2022-44571] Fix ReDoS vulnerability in multipart parser -- [CVE-2022-44570] Fix ReDoS in Rack::Utils.get_byte_ranges -- [CVE-2022-44572] Forbid control characters in attributes (also ReDoS) - -## [3.0.4] - 2023-01-17 - -- `Rack::Request#POST` should consistently raise errors. Cache errors that occur when invoking `Rack::Request#POST` so they can be raised again later. ([#2010](https://github.com/rack/rack/pull/2010), [@ioquatix]) -- Fix `Rack::Lint` error message for `HTTP_CONTENT_TYPE` and `HTTP_CONTENT_LENGTH`. ([#2007](https://github.com/rack/rack/pull/2007), [@byroot](https://github.com/byroot)) -- Extend `Rack::MethodOverride` to handle `QueryParser::ParamsTooDeepError` error. ([#2006](https://github.com/rack/rack/pull/2006), [@byroot](https://github.com/byroot)) - -## [3.0.3] - 2022-12-27 - -### Fixed - -- `Rack::URLMap` uses non-deprecated form of `Regexp.new`. ([#1998](https://github.com/rack/rack/pull/1998), [@weizheheng](https://github.com/weizheheng)) - -## [3.0.2] - 2022-12-05 - -### Fixed - -- `Utils.build_nested_query` URL-encodes nested field names including the square brackets. -- Allow `Rack::Response` to pass through streaming bodies. ([#1993](https://github.com/rack/rack/pull/1993), [@ioquatix]) - -## [3.0.1] - 2022-11-18 - -### Fixed - -- `MethodOverride` does not look for an override if a request does not include form/parseable data. -- `Rack::Lint::Wrapper` correctly handles `respond_to?` with `to_ary`, `each`, `call` and `to_path`, forwarding to the body. ([#1981](https://github.com/rack/rack/pull/1981), [@ioquatix]) - -## [3.0.0] - 2022-09-06 - -- No changes - -## [3.0.0.rc1] - 2022-09-04 - -### SPEC Changes - -- Stream argument must implement `<<` https://github.com/rack/rack/pull/1959 -- `close` may be called on `rack.input` https://github.com/rack/rack/pull/1956 -- `rack.response_finished` may be used for executing code after the response has been finished https://github.com/rack/rack/pull/1952 - -## [3.0.0.beta1] - 2022-08-08 - -### Security - -- Do not use semicolon as GET parameter separator. ([#1733](https://github.com/rack/rack/pull/1733), [@jeremyevans]) - -### SPEC Changes - -- Response array must now be non-frozen. -- Response `status` must now be an integer greater than or equal to 100. -- Response `headers` must now be an unfrozen hash. -- Response header keys can no longer include uppercase characters. -- Response header values can be an `Array` to handle multiple values (and no longer supports `\n` encoded headers). -- Response body can now respond to `#call` (streaming body) instead of `#each` (enumerable body), for the equivalent of response hijacking in previous versions. -- Middleware must no longer call `#each` on the body, but they can call `#to_ary` on the body if it responds to `#to_ary`. -- `rack.input` is no longer required to be rewindable. -- `rack.multithread`/`rack.multiprocess`/`rack.run_once`/`rack.version` are no longer required environment keys. -- `SERVER_PROTOCOL` is now a required environment key, matching the HTTP protocol used in the request. -- `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. -- `rack.hijack_io` has been removed completely. -- `rack.response_finished` is an optional environment key which contains an array of callable objects that must accept `#call(env, status, headers, error)` and are invoked after the response is finished (either successfully or unsuccessfully). -- It is okay to call `#close` on `rack.input` to indicate that you no longer need or care about the input. -- The stream argument supplied to the streaming body and hijack must support `#<<` for writing output. - -### Removed - -- Remove `rack.multithread`/`rack.multiprocess`/`rack.run_once`. These variables generally come too late to be useful. ([#1720](https://github.com/rack/rack/pull/1720), [@ioquatix], [@jeremyevans])) -- Remove deprecated Rack::Request::SCHEME_WHITELIST. ([@jeremyevans]) -- Remove internal cookie deletion using pattern matching, there are very few practical cases where it would be useful and browsers handle it correctly without us doing anything special. ([#1844](https://github.com/rack/rack/pull/1844), [@ioquatix]) -- Remove `rack.version` as it comes too late to be useful. ([#1938](https://github.com/rack/rack/pull/1938), [@ioquatix]) -- Extract `rackup` command, `Rack::Server`, `Rack::Handler`, `Rack::Lobster` and related code into a separate gem. ([#1937](https://github.com/rack/rack/pull/1937), [@ioquatix]) - -### Added - -- `Rack::Headers` added to support lower-case header keys. ([@jeremyevans]) -- `Rack::Utils#set_cookie_header` now supports `escape_key: false` to avoid key escaping. ([@jeremyevans]) -- `Rack::RewindableInput` supports size. ([@ahorek](https://github.com/ahorek)) -- `Rack::RewindableInput::Middleware` added for making `rack.input` rewindable. ([@jeremyevans]) -- The RFC 7239 Forwarded header is now supported and considered by default when looking for information on forwarding, falling back to the X-Forwarded-* headers. `Rack::Request.forwarded_priority` accessor has been added for configuring the priority of which header to check. ([#1423](https://github.com/rack/rack/issues/1423), [@jeremyevans]) -- Allow response headers to contain array of values. ([#1598](https://github.com/rack/rack/issues/1598), [@ioquatix]) -- Support callable body for explicit streaming support and clarify streaming response body behaviour. ([#1745](https://github.com/rack/rack/pull/1745), [@ioquatix], [#1748](https://github.com/rack/rack/pull/1748), [@wjordan]) -- Allow `Rack::Builder#run` to take a block instead of an argument. ([#1942](https://github.com/rack/rack/pull/1942), [@ioquatix]) -- Add `rack.response_finished` to `Rack::Lint`. ([#1802](https://github.com/rack/rack/pull/1802), [@BlakeWilliams], [#1952](https://github.com/rack/rack/pull/1952), [@ioquatix]) -- The stream argument must implement `#<<`. ([#1959](https://github.com/rack/rack/pull/1959), [@ioquatix]) - -### Changed - -- BREAKING CHANGE: Require `status` to be an Integer. ([#1662](https://github.com/rack/rack/pull/1662), [@olleolleolle](https://github.com/olleolleolle)) -- BREAKING CHANGE: Query parsing now treats parameters without `=` as having the empty string value instead of nil value, to conform to the URL spec. ([#1696](https://github.com/rack/rack/issues/1696), [@jeremyevans]) -- Relax validations around `Rack::Request#host` and `Rack::Request#hostname`. ([#1606](https://github.com/rack/rack/issues/1606), [@pvande](https://github.com/pvande)) -- Removed antiquated handlers: FCGI, LSWS, SCGI, Thin. ([#1658](https://github.com/rack/rack/pull/1658), [@ioquatix]) -- Removed options from `Rack::Builder.parse_file` and `Rack::Builder.load_file`. ([#1663](https://github.com/rack/rack/pull/1663), [@ioquatix]) -- `Rack::HTTP_VERSION` has been removed and the `HTTP_VERSION` env setting is no longer set in the CGI and Webrick handlers. ([#970](https://github.com/rack/rack/issues/970), [@jeremyevans]) -- `Rack::Request#[]` and `#[]=` now warn even in non-verbose mode. ([#1277](https://github.com/rack/rack/issues/1277), [@jeremyevans]) -- Decrease default allowed parameter recursion level from 100 to 32. ([#1640](https://github.com/rack/rack/issues/1640), [@jeremyevans]) -- Attempting to parse a multipart response with an empty body now raises Rack::Multipart::EmptyContentError. ([#1603](https://github.com/rack/rack/issues/1603), [@jeremyevans]) -- `Rack::Utils.secure_compare` uses OpenSSL's faster implementation if available. ([#1711](https://github.com/rack/rack/pull/1711), [@bdewater](https://github.com/bdewater)) -- `Rack::Request#POST` now caches an empty hash if input content type is not parseable. ([#749](https://github.com/rack/rack/pull/749), [@jeremyevans]) -- BREAKING CHANGE: Updated `trusted_proxy?` to match full 127.0.0.0/8 network. ([#1781](https://github.com/rack/rack/pull/1781), [@snbloch](https://github.com/snbloch)) -- Explicitly deprecate `Rack::File` which was an alias for `Rack::Files`. ([#1811](https://github.com/rack/rack/pull/1720), [@ioquatix]). -- Moved `Rack::Session` into [separate gem](https://github.com/rack/rack-session). ([#1805](https://github.com/rack/rack/pull/1805), [@ioquatix]) -- `rackup -D` option to daemonizes no longer changes the working directory to the root. ([#1813](https://github.com/rack/rack/pull/1813), [@jeremyevans]) -- The `x-forwarded-proto` header is now considered before the `x-forwarded-scheme` header for determining the forwarded protocol. `Rack::Request.x_forwarded_proto_priority` accessor has been added for configuring the priority of which header to check. ([#1809](https://github.com/rack/rack/issues/1809), [@jeremyevans]) -- `Rack::Request.forwarded_authority` (and methods that call it, such as `host`) now returns the last authority in the forwarded header, instead of the first, as earlier forwarded authorities can be forged by clients. This restores the Rack 2.1 behavior. ([#1829](https://github.com/rack/rack/issues/1809), [@jeremyevans]) -- Use lower case cookie attributes when creating cookies, and fold cookie attributes to lower case when reading cookies (specifically impacting `secure` and `httponly` attributes). ([#1849](https://github.com/rack/rack/pull/1849), [@ioquatix]) -- The response array must now be mutable (non-frozen) so middleware can modify it without allocating a new Array,therefore reducing object allocations. ([#1887](https://github.com/rack/rack/pull/1887), [#1927](https://github.com/rack/rack/pull/1927), [@amatsuda], [@ioquatix]) -- `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. `rack.hijack_io` is no longer required/specified. ([#1939](https://github.com/rack/rack/pull/1939), [@ioquatix]) -- Allow calling close on `rack.input`. ([#1956](https://github.com/rack/rack/pull/1956), [@ioquatix]) - -### Fixed - -- Make Rack::MockResponse handle non-hash headers. ([#1629](https://github.com/rack/rack/issues/1629), [@jeremyevans]) -- TempfileReaper now deletes temp files if application raises an exception. ([#1679](https://github.com/rack/rack/issues/1679), [@jeremyevans]) -- Handle cookies with values that end in '=' ([#1645](https://github.com/rack/rack/pull/1645), [@lukaso](https://github.com/lukaso)) -- Make `Rack::NullLogger` respond to `#fatal!` [@jeremyevans]) -- Fix multipart filename generation for filenames that contain spaces. Encode spaces as "%20" instead of "+" which will be decoded properly by the multipart parser. ([#1736](https://github.com/rack/rack/pull/1645), [@muirdm](https://github.com/muirdm)) -- `Rack::Request#scheme` returns `ws` or `wss` when one of the `X-Forwarded-Scheme` / `X-Forwarded-Proto` headers is set to `ws` or `wss`, respectively. ([#1730](https://github.com/rack/rack/issues/1730), [@erwanst](https://github.com/erwanst)) - -## [2.2.4] - 2022-06-30 - -- Better support for lower case headers in `Rack::ETag` middleware. ([#1919](https://github.com/rack/rack/pull/1919), [@ioquatix](https://github.com/ioquatix)) -- Use custom exception on params too deep error. ([#1838](https://github.com/rack/rack/pull/1838), [@simi](https://github.com/simi)) - -## [2.2.3.1] - 2022-05-27 - -### Security - -- [CVE-2022-30123] Fix shell escaping issue in Common Logger -- [CVE-2022-30122] Restrict parsing of broken MIME attachments - -## [2.2.3] - 2020-06-15 - -### Security - -- [[CVE-2020-8184](https://nvd.nist.gov/vuln/detail/CVE-2020-8184)] Do not allow percent-encoded cookie name to override existing cookie names. BREAKING CHANGE: Accessing cookie names that require URL encoding with decoded name no longer works. ([@fletchto99](https://github.com/fletchto99)) - -## [2.2.2] - 2020-02-11 - -### Fixed - -- Fix incorrect `Rack::Request#host` value. ([#1591](https://github.com/rack/rack/pull/1591), [@ioquatix]) -- Revert `Rack::Handler::Thin` implementation. ([#1583](https://github.com/rack/rack/pull/1583), [@jeremyevans]) -- Double assignment is still needed to prevent an "unused variable" warning. ([#1589](https://github.com/rack/rack/pull/1589), [@kamipo](https://github.com/kamipo)) -- Fix to handle same_site option for session pool. ([#1587](https://github.com/rack/rack/pull/1587), [@kamipo](https://github.com/kamipo)) - -## [2.2.1] - 2020-02-09 - -### Fixed - -- Rework `Rack::Request#ip` to handle empty `forwarded_for`. ([#1577](https://github.com/rack/rack/pull/1577), [@ioquatix]) - -## [2.2.0] - 2020-02-08 - -### SPEC Changes - -- `rack.session` request environment entry must respond to `to_hash` and return unfrozen Hash. ([@jeremyevans]) -- Request environment cannot be frozen. ([@jeremyevans]) -- CGI values in the request environment with non-ASCII characters must use ASCII-8BIT encoding. ([@jeremyevans]) -- Improve SPEC/lint relating to SERVER_NAME, SERVER_PORT and HTTP_HOST. ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix]) - -### Added - -- `rackup` supports multiple `-r` options and will require all arguments. ([@jeremyevans]) -- `Server` supports an array of paths to require for the `:require` option. ([@khotta](https://github.com/khotta)) -- `Files` supports multipart range requests. ([@fatkodima](https://github.com/fatkodima)) -- `Multipart::UploadedFile` supports an IO-like object instead of using the filesystem, using `:filename` and `:io` options. ([@jeremyevans]) -- `Multipart::UploadedFile` supports keyword arguments `:path`, `:content_type`, and `:binary` in addition to positional arguments. ([@jeremyevans]) -- `Static` supports a `:cascade` option for calling the app if there is no matching file. ([@jeremyevans]) -- `Session::Abstract::SessionHash#dig`. ([@jeremyevans]) -- `Response.[]` and `MockResponse.[]` for creating instances using status, headers, and body. ([@ioquatix]) -- Convenient cache and content type methods for `Rack::Response`. ([#1555](https://github.com/rack/rack/pull/1555), [@ioquatix]) - -### Changed - -- `Request#params` no longer rescues EOFError. ([@jeremyevans]) -- `Directory` uses a streaming approach, significantly improving time to first byte for large directories. ([@jeremyevans]) -- `Directory` no longer includes a Parent directory link in the root directory index. ([@jeremyevans]) -- `QueryParser#parse_nested_query` uses original backtrace when reraising exception with new class. ([@jeremyevans]) -- `ConditionalGet` follows RFC 7232 precedence if both If-None-Match and If-Modified-Since headers are provided. ([@jeremyevans]) -- `.ru` files supports the `frozen-string-literal` magic comment. ([@eregon](https://github.com/eregon)) -- Rely on autoload to load constants instead of requiring internal files, make sure to require 'rack' and not just 'rack/...'. ([@jeremyevans]) -- BREAKING CHANGE: `Etag` will continue sending ETag even if the response should not be cached. Streaming no longer works without a workaround, see [#1619](https://github.com/rack/rack/issues/1619#issuecomment-848460528). ([@henm](https://github.com/henm)) -- `Request#host_with_port` no longer includes a colon for a missing or empty port. ([@AlexWayfer](https://github.com/AlexWayfer)) -- All handlers uses keywords arguments instead of an options hash argument. ([@ioquatix]) -- `Files` handling of range requests no longer return a body that supports `to_path`, to ensure range requests are handled correctly. ([@jeremyevans]) -- `Multipart::Generator` only includes `Content-Length` for files with paths, and `Content-Disposition` `filename` if the `UploadedFile` instance has one. ([@jeremyevans]) -- `Request#ssl?` is true for the `wss` scheme (secure websockets). ([@jeremyevans]) -- `Rack::HeaderHash` is memoized by default. ([#1549](https://github.com/rack/rack/pull/1549), [@ioquatix]) -- `Rack::Directory` allow directory traversal inside root directory. ([#1417](https://github.com/rack/rack/pull/1417), [@ThomasSevestre](https://github.com/ThomasSevestre)) -- Sort encodings by server preference. ([#1184](https://github.com/rack/rack/pull/1184), [@ioquatix], [@wjordan](https://github.com/wjordan)) -- Rework host/hostname/authority implementation in `Rack::Request`. `#host` and `#host_with_port` have been changed to correctly return IPv6 addresses formatted with square brackets, as defined by [RFC3986](https://tools.ietf.org/html/rfc3986#section-3.2.2). ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix]) -- `Rack::Builder` parsing options on first `#\` line is deprecated. ([#1574](https://github.com/rack/rack/pull/1574), [@ioquatix]) - -### Removed - -- `Directory#path` as it was not used and always returned nil. ([@jeremyevans]) -- `BodyProxy#each` as it was only needed to work around a bug in Ruby <1.9.3. ([@jeremyevans]) -- `URLMap::INFINITY` and `URLMap::NEGATIVE_INFINITY`, in favor of `Float::INFINITY`. ([@ch1c0t](https://github.com/ch1c0t)) -- Deprecation of `Rack::File`. It will be deprecated again in rack 2.2 or 3.0. ([@rafaelfranca](https://github.com/rafaelfranca)) -- Support for Ruby 2.2 as it is well past EOL. ([@ioquatix]) -- Remove `Rack::Files#response_body` as the implementation was broken. ([#1153](https://github.com/rack/rack/pull/1153), [@ioquatix]) -- Remove `SERVER_ADDR` which was never part of the original SPEC. ([#1573](https://github.com/rack/rack/pull/1573), [@ioquatix]) - -### Fixed - -- `Directory` correctly handles root paths containing glob metacharacters. ([@jeremyevans]) -- `Cascade` uses a new response object for each call if initialized with no apps. ([@jeremyevans]) -- `BodyProxy` correctly delegates keyword arguments to the body object on Ruby 2.7+. ([@jeremyevans]) -- `BodyProxy#method` correctly handles methods delegated to the body object. ([@jeremyevans]) -- `Request#host` and `Request#host_with_port` handle IPv6 addresses correctly. ([@AlexWayfer](https://github.com/AlexWayfer)) -- `Lint` checks when response hijacking that `rack.hijack` is called with a valid object. ([@jeremyevans]) -- `Response#write` correctly updates `Content-Length` if initialized with a body. ([@jeremyevans]) -- `CommonLogger` includes `SCRIPT_NAME` when logging. ([@Erol](https://github.com/Erol)) -- `Utils.parse_nested_query` correctly handles empty queries, using an empty instance of the params class instead of a hash. ([@jeremyevans]) -- `Directory` correctly escapes paths in links. ([@yous](https://github.com/yous)) -- `Request#delete_cookie` and related `Utils` methods handle `:domain` and `:path` options in same call. ([@jeremyevans]) -- `Request#delete_cookie` and related `Utils` methods do an exact match on `:domain` and `:path` options. ([@jeremyevans]) -- `Static` no longer adds headers when a gzipped file request has a 304 response. ([@chooh](https://github.com/chooh)) -- `ContentLength` sets `Content-Length` response header even for bodies not responding to `to_ary`. ([@jeremyevans]) -- Thin handler supports options passed directly to `Thin::Controllers::Controller`. ([@jeremyevans]) -- WEBrick handler no longer ignores `:BindAddress` option. ([@jeremyevans]) -- `ShowExceptions` handles invalid POST data. ([@jeremyevans]) -- Basic authentication requires a password, even if the password is empty. ([@jeremyevans]) -- `Lint` checks response is array with 3 elements, per SPEC. ([@jeremyevans]) -- Support for using `:SSLEnable` option when using WEBrick handler. (Gregor Melhorn) -- Close response body after buffering it when buffering. ([@ioquatix]) -- Only accept `;` as delimiter when parsing cookies. ([@mrageh](https://github.com/mrageh)) -- `Utils::HeaderHash#clear` clears the name mapping as well. ([@raxoft](https://github.com/raxoft)) -- Support for passing `nil` `Rack::Files.new`, which notably fixes Rails' current `ActiveStorage::FileServer` implementation. ([@ioquatix]) - -### Documentation - -- CHANGELOG updates. ([@aupajo](https://github.com/aupajo)) -- Added [CONTRIBUTING](CONTRIBUTING.md). ([@dblock](https://github.com/dblock)) - -## [2.0.9] - 2020-02-08 - -- Handle case where session id key is requested but missing ([@jeremyevans]) -- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans]) -- Add support for `SameSite=None` cookie value. ([@hennikul](https://github.com/hennikul)) - -## [2.1.2] - 2020-01-27 - -- Fix multipart parser for some files to prevent denial of service ([@aiomaster](https://github.com/aiomaster)) -- Fix `Rack::Builder#use` with keyword arguments ([@kamipo](https://github.com/kamipo)) -- Skip deflating in Rack::Deflater if Content-Length is 0 ([@jeremyevans]) -- Remove `SessionHash#transform_keys`, no longer needed ([@pavel](https://github.com/pavel)) -- Add to_hash to wrap Hash and Session classes ([@oleh-demyanyuk](https://github.com/oleh-demyanyuk)) -- Handle case where session id key is requested but missing ([@jeremyevans]) - -## [2.1.1] - 2020-01-12 - -- Remove `Rack::Chunked` from `Rack::Server` default middleware. ([#1475](https://github.com/rack/rack/pull/1475), [@ioquatix]) -- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans]) - -## [2.1.0] - 2020-01-10 - -### Added - -- Add support for `SameSite=None` cookie value. ([@hennikul](https://github.com/hennikul)) -- Add trailer headers. ([@eileencodes](https://github.com/eileencodes)) -- Add MIME Types for video streaming. ([@styd](https://github.com/styd)) -- Add MIME Type for WASM. ([@buildrtech](https://github.com/buildrtech)) -- Add `Early Hints(103)` to status codes. ([@egtra](https://github.com/egtra)) -- Add `Too Early(425)` to status codes. ([@y-yagi]((https://github.com/y-yagi))) -- Add `Bandwidth Limit Exceeded(509)` to status codes. ([@CJKinni](https://github.com/CJKinni)) -- Add method for custom `ip_filter`. ([@svcastaneda](https://github.com/svcastaneda)) -- Add boot-time profiling capabilities to `rackup`. ([@tenderlove](https://github.com/tenderlove)) -- Add multi mapping support for `X-Accel-Mappings` header. ([@yoshuki](https://github.com/yoshuki)) -- Add `sync: false` option to `Rack::Deflater`. (Eric Wong) -- Add `Builder#freeze_app` to freeze application and all middleware instances. ([@jeremyevans]) -- Add API to extract cookies from `Rack::MockResponse`. ([@petercline](https://github.com/petercline)) - -### Changed - -- Don't propagate nil values from middleware. ([@ioquatix]) -- Lazily initialize the response body and only buffer it if required. ([@ioquatix]) -- Fix deflater zlib buffer errors on empty body part. ([@felixbuenemann](https://github.com/felixbuenemann)) -- Set `X-Accel-Redirect` to percent-encoded path. ([@diskkid](https://github.com/diskkid)) -- Remove unnecessary buffer growing when parsing multipart. ([@tainoe](https://github.com/tainoe)) -- Expand the root path in `Rack::Static` upon initialization. ([@rosenfeld](https://github.com/rosenfeld)) -- Make `ShowExceptions` work with binary data. ([@axyjo](https://github.com/axyjo)) -- Use buffer string when parsing multipart requests. ([@janko-m](https://github.com/janko-m)) -- Support optional UTF-8 Byte Order Mark (BOM) in config.ru. ([@mikegee](https://github.com/mikegee)) -- Handle `X-Forwarded-For` with optional port. ([@dpritchett](https://github.com/dpritchett)) -- Use `Time#httpdate` format for Expires, as proposed by RFC 7231. ([@nanaya](https://github.com/nanaya)) -- Make `Utils.status_code` raise an error when the status symbol is invalid instead of `500`. ([@adambutler](https://github.com/adambutler)) -- Rename `Request::SCHEME_WHITELIST` to `Request::ALLOWED_SCHEMES`. -- Make `Multipart::Parser.get_filename` accept files with `+` in their name. ([@lucaskanashiro](https://github.com/lucaskanashiro)) -- Add Falcon to the default handler fallbacks. ([@ioquatix]) -- Update codebase to avoid string mutations in preparation for `frozen_string_literals`. ([@pat](https://github.com/pat)) -- Change `MockRequest#env_for` to rely on the input optionally responding to `#size` instead of `#length`. ([@janko](https://github.com/janko)) -- Rename `Rack::File` -> `Rack::Files` and add deprecation notice. ([@postmodern](https://github.com/postmodern)) -- Prefer Base64 “strict encoding” for Base64 cookies. ([@ioquatix]) - -### Removed - -- BREAKING CHANGE: Remove `to_ary` from Response ([@tenderlove](https://github.com/tenderlove)) -- Deprecate `Rack::Session::Memcache` in favor of `Rack::Session::Dalli` from dalli gem ([@fatkodima](https://github.com/fatkodima)) - -### Fixed - -- Eliminate warnings for Ruby 2.7. ([@osamtimizer](https://github.com/osamtimizer])) - -### Documentation - -- Update broken example in `Session::Abstract::ID` documentation. ([tonytonyjan](https://github.com/tonytonyjan)) -- Add Padrino to the list of frameworks implementing Rack. ([@wikimatze](https://github.com/wikimatze)) -- Remove Mongrel from the suggested server options in the help output. ([@tricknotes](https://github.com/tricknotes)) -- Replace `HISTORY.md` and `NEWS.md` with `CHANGELOG.md`. ([@twitnithegirl](https://github.com/twitnithegirl)) -- CHANGELOG updates. ([@drenmi](https://github.com/Drenmi), [@p8](https://github.com/p8)) - -## [2.0.8] - 2019-12-08 - -### Security - -- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) - -## [1.6.12] - 2019-12-08 - -### Security - -- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) - -## [2.0.7] - 2019-04-02 - -### Fixed - -- Remove calls to `#eof?` on Rack input in `Multipart::Parser`, as this breaks the specification. ([@matthewd](https://github.com/matthewd)) -- Preserve forwarded IP addresses for trusted proxy chains. ([@SamSaffron](https://github.com/SamSaffron)) - -## [2.0.6] - 2018-11-05 - -### Fixed - -- [[CVE-2018-16470](https://nvd.nist.gov/vuln/detail/CVE-2018-16470)] Reduce buffer size of `Multipart::Parser` to avoid pathological parsing. ([@tenderlove](https://github.com/tenderlove)) -- Fix a call to a non-existing method `#accepts_html` in the `ShowExceptions` middleware. ([@tomelm](https://github.com/tomelm)) -- [[CVE-2018-16471](https://nvd.nist.gov/vuln/detail/CVE-2018-16471)] Whitelist HTTP and HTTPS schemes in `Request#scheme` to prevent a possible XSS attack. ([@PatrickTulskie](https://github.com/PatrickTulskie)) - -## [2.0.5] - 2018-04-23 - -### Fixed - -- Record errors originating from invalid UTF8 in `MethodOverride` middleware instead of breaking. ([@mclark](https://github.com/mclark)) - -## [2.0.4] - 2018-01-31 - -### Changed - -- Ensure the `Lock` middleware passes the original `env` object. ([@lugray](https://github.com/lugray)) -- Improve performance of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) -- Increase buffer size in `Multipart::Parser` for better performance. ([@jkowens](https://github.com/jkowens)) -- Reduce memory usage of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) -- Replace ConcurrentRuby dependency with native `Queue`. ([@devmchakan](https://github.com/devmchakan)) - -### Fixed - -- Require the correct digest algorithm in the `ETag` middleware. ([@matthewd](https://github.com/matthewd)) - -### Documentation - -- Update homepage links to use SSL. ([@hugoabonizio](https://github.com/hugoabonizio)) - -## [2.0.3] - 2017-05-15 - -### Changed - -- Ensure `env` values are ASCII 8-bit encoded. ([@eileencodes](https://github.com/eileencodes)) - -### Fixed - -- Prevent exceptions when a class with mixins inherits from `Session::Abstract::ID`. ([@jnraine](https://github.com/jnraine)) - -## [2.0.2] - 2017-05-08 - -### Added - -- Allow `Session::Abstract::SessionHash#fetch` to accept a block with a default value. ([@yannvanhalewyn](https://github.com/yannvanhalewyn)) -- Add `Builder#freeze_app` to freeze application and all middleware. ([@jeremyevans]) - -### Changed - -- Freeze default session options to avoid accidental mutation. ([@kirs](https://github.com/kirs)) -- Detect partial hijack without hash headers. ([@devmchakan](https://github.com/devmchakan)) -- Update tests to use MiniTest 6 matchers. ([@tonytonyjan](https://github.com/tonytonyjan)) -- Allow 205 Reset Content responses to set a Content-Length, as RFC 7231 proposes setting this to 0. ([@devmchakan](https://github.com/devmchakan)) - -### Fixed - -- Handle `NULL` bytes in multipart filenames. ([@casperisfine](https://github.com/casperisfine)) -- Remove warnings due to miscapitalized global. ([@ioquatix]) -- Prevent exceptions caused by a race condition on multi-threaded servers. ([@sophiedeziel](https://github.com/sophiedeziel)) -- Add RDoc as an explicit dependency for `doc` group. ([@tonytonyjan](https://github.com/tonytonyjan)) -- Record errors originating from `Multipart::Parser` in the `MethodOverride` middleware instead of letting them bubble up. ([@carlzulauf](https://github.com/carlzulauf)) -- Remove remaining use of removed `Utils#bytesize` method from the `File` middleware. ([@brauliomartinezlm](https://github.com/brauliomartinezlm)) - -### Removed - -- Remove `deflate` encoding support to reduce caching overhead. ([@devmchakan](https://github.com/devmchakan)) - -### Documentation - -- Update broken example in `Deflater` documentation. ([@mwpastore](https://github.com/mwpastore)) - -## [2.0.1] - 2016-06-30 - -### Changed - -- Remove JSON as an explicit dependency. ([@mperham](https://github.com/mperham)) - - -# History/News Archive -Items below this line are from the previously maintained HISTORY.md and NEWS.md files. - -## [2.0.0.rc1] 2016-05-06 -- Rack::Session::Abstract::ID is deprecated. Please change to use Rack::Session::Abstract::Persisted - -## [2.0.0.alpha] 2015-12-04 -- First-party "SameSite" cookies. Browsers omit SameSite cookies from third-party requests, closing the door on many CSRF attacks. -- Pass `same_site: true` (or `:strict`) to enable: response.set_cookie 'foo', value: 'bar', same_site: true or `same_site: :lax` to use Lax enforcement: response.set_cookie 'foo', value: 'bar', same_site: :lax -- Based on version 7 of the Same-site Cookies internet draft: - https://tools.ietf.org/html/draft-west-first-party-cookies-07 -- Thanks to Ben Toews (@mastahyeti) and Bob Long (@bobjflong) for updating to drafts 5 and 7. -- Add `Rack::Events` middleware for adding event based middleware: middleware that does not care about the response body, but only cares about doing work at particular points in the request / response lifecycle. -- Add `Rack::Request#authority` to calculate the authority under which the response is being made (this will be handy for h2 pushes). -- Add `Rack::Response::Helpers#cache_control` and `cache_control=`. Use this for setting cache control headers on your response objects. -- Add `Rack::Response::Helpers#etag` and `etag=`. Use this for setting etag values on the response. -- Introduce `Rack::Response::Helpers#add_header` to add a value to a multi-valued response header. Implemented in terms of other `Response#*_header` methods, so it's available to any response-like class that includes the `Helpers` module. -- Add `Rack::Request#add_header` to match. -- `Rack::Session::Abstract::ID` IS DEPRECATED. Please switch to `Rack::Session::Abstract::Persisted`. `Rack::Session::Abstract::Persisted` uses a request object rather than the `env` hash. -- Pull `ENV` access inside the request object in to a module. This will help with legacy Request objects that are ENV based but don't want to inherit from Rack::Request -- Move most methods on the `Rack::Request` to a module `Rack::Request::Helpers` and use public API to get values from the request object. This enables users to mix `Rack::Request::Helpers` in to their own objects so they can implement `(get|set|fetch|each)_header` as they see fit (for example a proxy object). -- Files and directories with + in the name are served correctly. Rather than unescaping paths like a form, we unescape with a URI parser using `Rack::Utils.unescape_path`. Fixes #265 -- Tempfiles are automatically closed in the case that there were too - many posted. -- Added methods for manipulating response headers that don't assume - they're stored as a Hash. Response-like classes may include the - Rack::Response::Helpers module if they define these methods: - - Rack::Response#has_header? - - Rack::Response#get_header - - Rack::Response#set_header - - Rack::Response#delete_header -- Introduce Util.get_byte_ranges that will parse the value of the HTTP_RANGE string passed to it without depending on the `env` hash. `byte_ranges` is deprecated in favor of this method. -- Change Session internals to use Request objects for looking up session information. This allows us to only allocate one request object when dealing with session objects (rather than doing it every time we need to manipulate cookies, etc). -- Add `Rack::Request#initialize_copy` so that the env is duped when the request gets duped. -- Added methods for manipulating request specific data. This includes - data set as CGI parameters, and just any arbitrary data the user wants - to associate with a particular request. New methods: - - Rack::Request#has_header? - - Rack::Request#get_header - - Rack::Request#fetch_header - - Rack::Request#each_header - - Rack::Request#set_header - - Rack::Request#delete_header -- lib/rack/utils.rb: add a method for constructing "delete" cookie - headers. This allows us to construct cookie headers without depending - on the side effects of mutating a hash. -- Prevent extremely deep parameters from being parsed. CVE-2015-3225 - -## [1.6.1] 2015-05-06 - - Fix CVE-2014-9490, denial of service attack in OkJson - - Use a monotonic time for Rack::Runtime, if available - - RACK_MULTIPART_LIMIT changed to RACK_MULTIPART_PART_LIMIT (RACK_MULTIPART_LIMIT is deprecated and will be removed in 1.7.0) - -## [1.5.3] 2015-05-06 - - Fix CVE-2014-9490, denial of service attack in OkJson - - Backport bug fixes to 1.5 series - -## [1.6.0] 2014-01-18 - - Response#unauthorized? helper - - Deflater now accepts an options hash to control compression on a per-request level - - Builder#warmup method for app preloading - - Request#accept_language method to extract HTTP_ACCEPT_LANGUAGE - - Add quiet mode of rack server, rackup --quiet - - Update HTTP Status Codes to RFC 7231 - - Less strict header name validation according to RFC 2616 - - SPEC updated to specify headers conform to RFC7230 specification - - Etag correctly marks etags as weak - - Request#port supports multiple x-http-forwarded-proto values - - Utils#multipart_part_limit configures the maximum number of parts a request can contain - - Default host to localhost when in development mode - - Various bugfixes and performance improvements - -## [1.5.2] 2013-02-07 - - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie - - Fix CVE-2013-0262, symlink path traversal in Rack::File - - Add various methods to Session for enhanced Rails compatibility - - Request#trusted_proxy? now only matches whole strings - - Add JSON cookie coder, to be default in Rack 1.6+ due to security concerns - - URLMap host matching in environments that don't set the Host header fixed - - Fix a race condition that could result in overwritten pidfiles - - Various documentation additions - -## [1.4.5] 2013-02-07 - - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie - - Fix CVE-2013-0262, symlink path traversal in Rack::File - -## [1.1.6, 1.2.8, 1.3.10] 2013-02-07 - - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie - -## [1.5.1] 2013-01-28 - - Rack::Lint check_hijack now conforms to other parts of SPEC - - Added hash-like methods to Abstract::ID::SessionHash for compatibility - - Various documentation corrections - -## [1.5.0] 2013-01-21 - - Introduced hijack SPEC, for before-response and after-response hijacking - - SessionHash is no longer a Hash subclass - - Rack::File cache_control parameter is removed, in place of headers options - - Rack::Auth::AbstractRequest#scheme now yields strings, not symbols - - Rack::Utils cookie functions now format expires in RFC 2822 format - - Rack::File now has a default mime type - - rackup -b 'run Rack::Files.new(".")', option provides command line configs - - Rack::Deflater will no longer double encode bodies - - Rack::Mime#match? provides convenience for Accept header matching - - Rack::Utils#q_values provides splitting for Accept headers - - Rack::Utils#best_q_match provides a helper for Accept headers - - Rack::Handler.pick provides convenience for finding available servers - - Puma added to the list of default servers (preferred over Webrick) - - Various middleware now correctly close body when replacing it - - Rack::Request#params is no longer persistent with only GET params - - Rack::Request#update_param and #delete_param provide persistent operations - - Rack::Request#trusted_proxy? now returns true for local unix sockets - - Rack::Response no longer forces Content-Types - - Rack::Sendfile provides local mapping configuration options - - Rack::Utils#rfc2109 provides old netscape style time output - - Updated HTTP status codes - - Ruby 1.8.6 likely no longer passes tests, and is no longer fully supported - -## [1.4.4, 1.3.9, 1.2.7, 1.1.5] 2013-01-13 - - [SEC] Rack::Auth::AbstractRequest no longer symbolizes arbitrary strings - - Fixed erroneous test case in the 1.3.x series - -## [1.4.3] 2013-01-07 - - Security: Prevent unbounded reads in large multipart boundaries - -## [1.3.8] 2013-01-07 - - Security: Prevent unbounded reads in large multipart boundaries - -## [1.4.2] 2013-01-06 - - Add warnings when users do not provide a session secret - - Fix parsing performance for unquoted filenames - - Updated URI backports - - Fix URI backport version matching, and silence constant warnings - - Correct parameter parsing with empty values - - Correct rackup '-I' flag, to allow multiple uses - - Correct rackup pidfile handling - - Report rackup line numbers correctly - - Fix request loops caused by non-stale nonces with time limits - - Fix reloader on Windows - - Prevent infinite recursions from Response#to_ary - - Various middleware better conforms to the body close specification - - Updated language for the body close specification - - Additional notes regarding ECMA escape compatibility issues - - Fix the parsing of multiple ranges in range headers - - Prevent errors from empty parameter keys - - Added PATCH verb to Rack::Request - - Various documentation updates - - Fix session merge semantics (fixes rack-test) - - Rack::Static :index can now handle multiple directories - - All tests now utilize Rack::Lint (special thanks to Lars Gierth) - - Rack::File cache_control parameter is now deprecated, and removed by 1.5 - - Correct Rack::Directory script name escaping - - Rack::Static supports header rules for sophisticated configurations - - Multipart parsing now works without a Content-Length header - - New logos courtesy of Zachary Scott! - - Rack::BodyProxy now explicitly defines #each, useful for C extensions - - Cookies that are not URI escaped no longer cause exceptions - -## [1.3.7] 2013-01-06 - - Add warnings when users do not provide a session secret - - Fix parsing performance for unquoted filenames - - Updated URI backports - - Fix URI backport version matching, and silence constant warnings - - Correct parameter parsing with empty values - - Correct rackup '-I' flag, to allow multiple uses - - Correct rackup pidfile handling - - Report rackup line numbers correctly - - Fix request loops caused by non-stale nonces with time limits - - Fix reloader on Windows - - Prevent infinite recursions from Response#to_ary - - Various middleware better conforms to the body close specification - - Updated language for the body close specification - - Additional notes regarding ECMA escape compatibility issues - - Fix the parsing of multiple ranges in range headers - -## [1.2.6] 2013-01-06 - - Add warnings when users do not provide a session secret - - Fix parsing performance for unquoted filenames - -## [1.1.4] 2013-01-06 - - Add warnings when users do not provide a session secret - -## [1.4.1] 2012-01-22 - - Alter the keyspace limit calculations to reduce issues with nested params - - Add a workaround for multipart parsing where files contain unescaped "%" - - Added Rack::Response::Helpers#method_not_allowed? (code 405) - - Rack::File now returns 404 for illegal directory traversals - - Rack::File now returns 405 for illegal methods (non HEAD/GET) - - Rack::Cascade now catches 405 by default, as well as 404 - - Cookies missing '--' no longer cause an exception to be raised - - Various style changes and documentation spelling errors - - Rack::BodyProxy always ensures to execute its block - - Additional test coverage around cookies and secrets - - Rack::Session::Cookie can now be supplied either secret or old_secret - - Tests are no longer dependent on set order - - Rack::Static no longer defaults to serving index files - - Rack.release was fixed - -## [1.4.0] 2011-12-28 - - Ruby 1.8.6 support has officially been dropped. Not all tests pass. - - Raise sane error messages for broken config.ru - - Allow combining run and map in a config.ru - - Rack::ContentType will not set Content-Type for responses without a body - - Status code 205 does not send a response body - - Rack::Response::Helpers will not rely on instance variables - - Rack::Utils.build_query no longer outputs '=' for nil query values - - Various mime types added - - Rack::MockRequest now supports HEAD - - Rack::Directory now supports files that contain RFC3986 reserved chars - - Rack::File now only supports GET and HEAD requests - - Rack::Server#start now passes the block to Rack::Handler::#run - - Rack::Static now supports an index option - - Added the Teapot status code - - rackup now defaults to Thin instead of Mongrel (if installed) - - Support added for HTTP_X_FORWARDED_SCHEME - - Numerous bug fixes, including many fixes for new and alternate rubies - -## [1.1.3] 2011-12-28 - - Security fix. http://www.ocert.org/advisories/ocert-2011-003.html - Further information here: http://jruby.org/2011/12/27/jruby-1-6-5-1 - -## [1.3.5] 2011-10-17 - - Fix annoying warnings caused by the backport in 1.3.4 - -## [1.3.4] 2011-10-01 - - Backport security fix from 1.9.3, also fixes some roundtrip issues in URI - - Small documentation update - - Fix an issue where BodyProxy could cause an infinite recursion - - Add some supporting files for travis-ci - -## [1.2.4] 2011-09-16 - - Fix a bug with MRI regex engine to prevent XSS by malformed unicode - -## [1.3.3] 2011-09-16 - - Fix bug with broken query parameters in Rack::ShowExceptions - - Rack::Request#cookies no longer swallows exceptions on broken input - - Prevents XSS attacks enabled by bug in Ruby 1.8's regexp engine - - Rack::ConditionalGet handles broken If-Modified-Since helpers - -## [1.3.2] 2011-07-16 - - Fix for Rails and rack-test, Rack::Utils#escape calls to_s - -## [1.3.1] 2011-07-13 - - Fix 1.9.1 support - - Fix JRuby support - - Properly handle $KCODE in Rack::Utils.escape - - Make method_missing/respond_to behavior consistent for Rack::Lock, - Rack::Auth::Digest::Request and Rack::Multipart::UploadedFile - - Reenable passing rack.session to session middleware - - Rack::CommonLogger handles streaming responses correctly - - Rack::MockResponse calls close on the body object - - Fix a DOS vector from MRI stdlib backport - -## [1.2.3] 2011-05-22 - - Pulled in relevant bug fixes from 1.3 - - Fixed 1.8.6 support - -## [1.3.0] 2011-05-22 - - Various performance optimizations - - Various multipart fixes - - Various multipart refactors - - Infinite loop fix for multipart - - Test coverage for Rack::Server returns - - Allow files with '..', but not path components that are '..' - - rackup accepts handler-specific options on the command line - - Request#params no longer merges POST into GET (but returns the same) - - Use URI.encode_www_form_component instead. Use core methods for escaping. - - Allow multi-line comments in the config file - - Bug L#94 reported by Nikolai Lugovoi, query parameter unescaping. - - Rack::Response now deletes Content-Length when appropriate - - Rack::Deflater now supports streaming - - Improved Rack::Handler loading and searching - - Support for the PATCH verb - - env['rack.session.options'] now contains session options - - Cookies respect renew - - Session middleware uses SecureRandom.hex - -## [1.2.2, 1.1.2] 2011-03-13 - - Security fix in Rack::Auth::Digest::MD5: when authenticator - returned nil, permission was granted on empty password. - -## [1.2.1] 2010-06-15 - - Make CGI handler rewindable - - Rename spec/ to test/ to not conflict with SPEC on lesser - operating systems - -## [1.2.0] 2010-06-13 - - Removed Camping adapter: Camping 2.0 supports Rack as-is - - Removed parsing of quoted values - - Add Request.trace? and Request.options? - - Add mime-type for .webm and .htc - - Fix HTTP_X_FORWARDED_FOR - - Various multipart fixes - - Switch test suite to bacon - -## [1.1.0] 2010-01-03 - - Moved Auth::OpenID to rack-contrib. - - SPEC change that relaxes Lint slightly to allow subclasses of the - required types - - SPEC change to document rack.input binary mode in greater detail - - SPEC define optional rack.logger specification - - File servers support X-Cascade header - - Imported Config middleware - - Imported ETag middleware - - Imported Runtime middleware - - Imported Sendfile middleware - - New Logger and NullLogger middlewares - - Added mime type for .ogv and .manifest. - - Don't squeeze PATH_INFO slashes - - Use Content-Type to determine POST params parsing - - Update Rack::Utils::HTTP_STATUS_CODES hash - - Add status code lookup utility - - Response should call #to_i on the status - - Add Request#user_agent - - Request#host knows about forwarded host - - Return an empty string for Request#host if HTTP_HOST and - SERVER_NAME are both missing - - Allow MockRequest to accept hash params - - Optimizations to HeaderHash - - Refactored rackup into Rack::Server - - Added Utils.build_nested_query to complement Utils.parse_nested_query - - Added Utils::Multipart.build_multipart to complement - Utils::Multipart.parse_multipart - - Extracted set and delete cookie helpers into Utils so they can be - used outside Response - - Extract parse_query and parse_multipart in Request so subclasses - can change their behavior - - Enforce binary encoding in RewindableInput - - Set correct external_encoding for handlers that don't use RewindableInput - -## [1.0.1] 2009-10-18 - - Bump remainder of rack.versions. - - Support the pure Ruby FCGI implementation. - - Fix for form names containing "=": split first then unescape components - - Fixes the handling of the filename parameter with semicolons in names. - - Add anchor to nested params parsing regexp to prevent stack overflows - - Use more compatible gzip write api instead of "<<". - - Make sure that Reloader doesn't break when executed via ruby -e - - Make sure WEBrick respects the :Host option - - Many Ruby 1.9 fixes. - -## [1.0.0] 2009-04-25 - - SPEC change: Rack::VERSION has been pushed to [1,0]. - - SPEC change: header values must be Strings now, split on "\n". - - SPEC change: Content-Length can be missing, in this case chunked transfer - encoding is used. - - SPEC change: rack.input must be rewindable and support reading into - a buffer, wrap with Rack::RewindableInput if it isn't. - - SPEC change: rack.session is now specified. - - SPEC change: Bodies can now additionally respond to #to_path with - a filename to be served. - - NOTE: String bodies break in 1.9, use an Array consisting of a - single String instead. - - New middleware Rack::Lock. - - New middleware Rack::ContentType. - - Rack::Reloader has been rewritten. - - Major update to Rack::Auth::OpenID. - - Support for nested parameter parsing in Rack::Response. - - Support for redirects in Rack::Response. - - HttpOnly cookie support in Rack::Response. - - The Rakefile has been rewritten. - - Many bugfixes and small improvements. - -## [0.9.1] 2009-01-09 - - Fix directory traversal exploits in Rack::File and Rack::Directory. - -## [0.9] 2009-01-06 - - Rack is now managed by the Rack Core Team. - - Rack::Lint is stricter and follows the HTTP RFCs more closely. - - Added ConditionalGet middleware. - - Added ContentLength middleware. - - Added Deflater middleware. - - Added Head middleware. - - Added MethodOverride middleware. - - Rack::Mime now provides popular MIME-types and their extension. - - Mongrel Header now streams. - - Added Thin handler. - - Official support for swiftiplied Mongrel. - - Secure cookies. - - Made HeaderHash case-preserving. - - Many bugfixes and small improvements. - -## [0.4] 2008-08-21 - - New middleware, Rack::Deflater, by Christoffer Sawicki. - - OpenID authentication now needs ruby-openid 2. - - New Memcache sessions, by blink. - - Explicit EventedMongrel handler, by Joshua Peek - - Rack::Reloader is not loaded in rackup development mode. - - rackup can daemonize with -D. - - Many bugfixes, especially for pool sessions, URLMap, thread safety - and tempfile handling. - - Improved tests. - - Rack moved to Git. - -## [0.3] 2008-02-26 - - LiteSpeed handler, by Adrian Madrid. - - SCGI handler, by Jeremy Evans. - - Pool sessions, by blink. - - OpenID authentication, by blink. - - :Port and :File options for opening FastCGI sockets, by blink. - - Last-Modified HTTP header for Rack::File, by blink. - - Rack::Builder#use now accepts blocks, by Corey Jewett. - (See example/protectedlobster.ru) - - HTTP status 201 can contain a Content-Type and a body now. - - Many bugfixes, especially related to Cookie handling. - -## [0.2] 2007-05-16 - - HTTP Basic authentication. - - Cookie Sessions. - - Static file handler. - - Improved Rack::Request. - - Improved Rack::Response. - - Added Rack::ShowStatus, for better default error messages. - - Bug fixes in the Camping adapter. - - Removed Rails adapter, was too alpha. - -## [0.1] 2007-03-03 - -[@ioquatix]: https://github.com/ioquatix "Samuel Williams" -[@jeremyevans]: https://github.com/jeremyevans "Jeremy Evans" -[@amatsuda]: https://github.com/amatsuda "Akira Matsuda" -[@wjordan]: https://github.com/wjordan "Will Jordan" -[@BlakeWilliams]: https://github.com/BlakeWilliams "Blake Williams" -[@davidstosik]: https://github.com/davidstosik "David Stosik" diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CONTRIBUTING.md b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CONTRIBUTING.md deleted file mode 100644 index a95263d..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/CONTRIBUTING.md +++ /dev/null @@ -1,144 +0,0 @@ -# Contributing to Rack - -Rack is work of [hundreds of -contributors](https://github.com/rack/rack/graphs/contributors). You're -encouraged to submit [pull requests](https://github.com/rack/rack/pulls) and -[propose features and discuss issues](https://github.com/rack/rack/issues). - -## Backports - -Only security patches are ideal for backporting to non-main release versions. If -you're not sure if your bug fix is backportable, you should open a discussion to -discuss it first. - -The [Security Policy] documents which release versions will receive security -backports. - -## Fork the Project - -Fork the [project on GitHub](https://github.com/rack/rack) and check out your -copy. - -``` -git clone https://github.com/(your-github-username)/rack.git -cd rack -git remote add upstream https://github.com/rack/rack.git -``` - -## Create a Topic Branch - -Make sure your fork is up-to-date and create a topic branch for your feature or -bug fix. - -``` -git checkout main -git pull upstream main -git checkout -b my-feature-branch -``` - -## Running All Tests - -Install all dependencies. - -``` -bundle install -``` - -Run all tests. - -``` -rake test -``` - -## Write Tests - -Try to write a test that reproduces the problem you're trying to fix or -describes a feature that you want to build. - -We definitely appreciate pull requests that highlight or reproduce a problem, -even without a fix. - -## Write Code - -Implement your feature or bug fix. - -Make sure that all tests pass: - -``` -bundle exec rake test -``` - -## Write Documentation - -Document any external behavior in the [README](README.md). - -## Update Changelog - -Add a line to [CHANGELOG](CHANGELOG.md). - -## Commit Changes - -Make sure git knows your name and email address: - -``` -git config --global user.name "Your Name" -git config --global user.email "contributor@example.com" -``` - -Writing good commit logs is important. A commit log should describe what changed -and why. - -``` -git add ... -git commit -``` - -## Push - -``` -git push origin my-feature-branch -``` - -## Make a Pull Request - -Go to your fork of rack on GitHub and select your feature branch. Click the -'Pull Request' button and fill out the form. Pull requests are usually -reviewed within a few days. - -## Rebase - -If you've been working on a change for a while, rebase with upstream/main. - -``` -git fetch upstream -git rebase upstream/main -git push origin my-feature-branch -f -``` - -## Make Required Changes - -Amend your previous commit and force push the changes. - -``` -git commit --amend -git push origin my-feature-branch -f -``` - -## Check on Your Pull Request - -Go back to your pull request after a few minutes and see whether it passed -tests with GitHub Actions. Everything should look green, otherwise fix issues and -amend your commit as described above. - -## Be Patient - -It's likely that your change will not be merged and that the nitpicky -maintainers will ask you to do more, or fix seemingly benign problems. Hang in -there! - -## Thank You - -Please do know that we really appreciate and value your time and work. We love -you, really. - -[Security Policy]: SECURITY.md diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/MIT-LICENSE b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/MIT-LICENSE deleted file mode 100644 index fb33b7f..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/MIT-LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -The MIT License (MIT) - -Copyright (C) 2007-2021 Leah Neukirchen - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL -THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/README.md b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/README.md deleted file mode 100644 index 3a197b1..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/README.md +++ /dev/null @@ -1,328 +0,0 @@ -# ![Rack](contrib/logo.webp) - -Rack provides a minimal, modular, and adaptable interface for developing web -applications in Ruby. By wrapping HTTP requests and responses in the simplest -way possible, it unifies and distills the bridge between web servers, web -frameworks, and web application into a single method call. - -The exact details of this are described in the [Rack Specification], which all -Rack applications should conform to. - -## Version support - -| Version | Support | -|----------|------------------------------------| -| 3.0.x | Bug fixes and security patches. | -| 2.2.x | Security patches only. | -| <= 2.1.x | End of support. | - -Please see the [Security Policy] for more information. - -## Rack 3.0 - -This is the latest version of Rack. It contains API improvements but also some -breaking changes. Please check the [Upgrade Guide](UPGRADE-GUIDE.md) for more -details about migrating servers, middlewares and applications designed for Rack 2 -to Rack 3. For detailed information on specific changes, check the [Change Log](CHANGELOG.md). - -## Rack 2.2 - -This version of Rack is receiving security patches only, and effort should be -made to move to Rack 3. - -Starting in Ruby 3.4 the `base64` dependency will no longer be a default gem, -and may cause a warning or error about `base64` being missing. To correct this, -add `base64` as a dependency to your project. - -## Installation - -Add the rack gem to your application bundle, or follow the instructions provided -by a [supported web framework](#supported-web-frameworks): - -```bash -# Install it generally: -$ gem install rack - -# or, add it to your current application gemfile: -$ bundle add rack -``` - -If you need features from `Rack::Session` or `bin/rackup` please add those gems separately. - -```bash -$ gem install rack-session rackup -``` - -## Usage - -Create a file called `config.ru` with the following contents: - -```ruby -run do |env| - [200, {}, ["Hello World"]] -end -``` - -Run this using the rackup gem or another [supported web -server](#supported-web-servers). - -```bash -$ gem install rackup -$ rackup -$ curl http://localhost:9292 -Hello World -``` - -## Supported web servers - -Rack is supported by a wide range of servers, including: - -* [Agoo](https://github.com/ohler55/agoo) -* [Falcon](https://github.com/socketry/falcon) -* [Iodine](https://github.com/boazsegev/iodine) -* [NGINX Unit](https://unit.nginx.org/) -* [Phusion Passenger](https://www.phusionpassenger.com/) (which is mod_rack for - Apache and for nginx) -* [Puma](https://puma.io/) -* [Thin](https://github.com/macournoyer/thin) -* [Unicorn](https://yhbt.net/unicorn/) -* [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) -* [Lamby](https://lamby.custominktech.com) (for AWS Lambda) - -You will need to consult the server documentation to find out what features and -limitations they may have. In general, any valid Rack app will run the same on -all these servers, without changing anything. - -### Rackup - -Rack provides a separate gem, [rackup](https://github.com/rack/rackup) which is -a generic interface for running a Rack application on supported servers, which -include `WEBRick`, `Puma`, `Falcon` and others. - -## Supported web frameworks - -These frameworks and many others support the [Rack Specification]: - -* [Camping](https://github.com/camping/camping) -* [Hanami](https://hanamirb.org/) -* [Ramaze](https://github.com/ramaze/ramaze) -* [Padrino](https://padrinorb.com/) -* [Roda](https://github.com/jeremyevans/roda) -* [Ruby on Rails](https://rubyonrails.org/) -* [Rum](https://github.com/leahneukirchen/rum) -* [Sinatra](https://sinatrarb.com/) -* [Utopia](https://github.com/socketry/utopia) -* [WABuR](https://github.com/ohler55/wabur) - -## Available middleware shipped with Rack - -Between the server and the framework, Rack can be customized to your -applications needs using middleware. Rack itself ships with the following -middleware: - -* `Rack::CommonLogger` for creating Apache-style logfiles. -* `Rack::ConditionalGet` for returning [Not - Modified](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) - responses when the response has not changed. -* `Rack::Config` for modifying the environment before processing the request. -* `Rack::ContentLength` for setting a `content-length` header based on body - size. -* `Rack::ContentType` for setting a default `content-type` header for responses. -* `Rack::Deflater` for compressing responses with gzip. -* `Rack::ETag` for setting `etag` header on bodies that can be buffered. -* `Rack::Events` for providing easy hooks when a request is received and when - the response is sent. -* `Rack::Files` for serving static files. -* `Rack::Head` for returning an empty body for HEAD requests. -* `Rack::Lint` for checking conformance to the [Rack Specification]. -* `Rack::Lock` for serializing requests using a mutex. -* `Rack::Logger` for setting a logger to handle logging errors. -* `Rack::MethodOverride` for modifying the request method based on a submitted - parameter. -* `Rack::Recursive` for including data from other paths in the application, and - for performing internal redirects. -* `Rack::Reloader` for reloading files if they have been modified. -* `Rack::Runtime` for including a response header with the time taken to process - the request. -* `Rack::Sendfile` for working with web servers that can use optimized file - serving for file system paths. -* `Rack::ShowException` for catching unhandled exceptions and presenting them in - a nice and helpful way with clickable backtrace. -* `Rack::ShowStatus` for using nice error pages for empty client error - responses. -* `Rack::Static` for more configurable serving of static files. -* `Rack::TempfileReaper` for removing temporary files creating during a request. - -All these components use the same interface, which is described in detail in the -[Rack Specification]. These optional components can be used in any way you wish. - -### Convenience interfaces - -If you want to develop outside of existing frameworks, implement your own ones, -or develop middleware, Rack provides many helpers to create Rack applications -quickly and without doing the same web stuff all over: - -* `Rack::Request` which also provides query string parsing and multipart - handling. -* `Rack::Response` for convenient generation of HTTP replies and cookie - handling. -* `Rack::MockRequest` and `Rack::MockResponse` for efficient and quick testing - of Rack application without real HTTP round-trips. -* `Rack::Cascade` for trying additional Rack applications if an application - returns a not found or method not supported response. -* `Rack::Directory` for serving files under a given directory, with directory - indexes. -* `Rack::MediaType` for parsing content-type headers. -* `Rack::Mime` for determining content-type based on file extension. -* `Rack::RewindableInput` for making any IO object rewindable, using a temporary - file buffer. -* `Rack::URLMap` to route to multiple applications inside the same process. - -## Configuration - -Rack exposes several configuration parameters to control various features of the -implementation. - -### `param_depth_limit` - -```ruby -Rack::Utils.param_depth_limit = 32 # default -``` - -The maximum amount of nesting allowed in parameters. For example, if set to 3, -this query string would be allowed: - -``` -?a[b][c]=d -``` - -but this query string would not be allowed: - -``` -?a[b][c][d]=e -``` - -Limiting the depth prevents a possible stack overflow when parsing parameters. - -### `multipart_file_limit` - -```ruby -Rack::Utils.multipart_file_limit = 128 # default -``` - -The maximum number of parts with a filename a request can contain. Accepting -too many parts can lead to the server running out of file handles. - -The default is 128, which means that a single request can't upload more than 128 -files at once. Set to 0 for no limit. - -Can also be set via the `RACK_MULTIPART_FILE_LIMIT` environment variable. - -(This is also aliased as `multipart_part_limit` and `RACK_MULTIPART_PART_LIMIT` for compatibility) - - -### `multipart_total_part_limit` - -The maximum total number of parts a request can contain of any type, including -both file and non-file form fields. - -The default is 4096, which means that a single request can't contain more than -4096 parts. - -Set to 0 for no limit. - -Can also be set via the `RACK_MULTIPART_TOTAL_PART_LIMIT` environment variable. - - -## Changelog - -See [CHANGELOG.md](CHANGELOG.md). - -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md) for specific details about how to make a -contribution to Rack. - -Please post bugs, suggestions and patches to [GitHub -Issues](https://github.com/rack/rack/issues). - -Please check our [Security Policy](https://github.com/rack/rack/security/policy) -for responsible disclosure and security bug reporting process. Due to wide usage -of the library, it is strongly preferred that we manage timing in order to -provide viable patches at the time of disclosure. Your assistance in this matter -is greatly appreciated. - -## See Also - -### `rack-contrib` - -The plethora of useful middleware created the need for a project that collects -fresh Rack middleware. `rack-contrib` includes a variety of add-on components -for Rack and it is easy to contribute new modules. - -* https://github.com/rack/rack-contrib - -### `rack-session` - -Provides convenient session management for Rack. - -* https://github.com/rack/rack-session - -## Thanks - -The Rack Core Team, consisting of - -* Aaron Patterson [tenderlove](https://github.com/tenderlove) -* Samuel Williams [ioquatix](https://github.com/ioquatix) -* Jeremy Evans [jeremyevans](https://github.com/jeremyevans) -* Eileen Uchitelle [eileencodes](https://github.com/eileencodes) -* Matthew Draper [matthewd](https://github.com/matthewd) -* Rafael França [rafaelfranca](https://github.com/rafaelfranca) - -and the Rack Alumni - -* Ryan Tomayko [rtomayko](https://github.com/rtomayko) -* Scytrin dai Kinthra [scytrin](https://github.com/scytrin) -* Leah Neukirchen [leahneukirchen](https://github.com/leahneukirchen) -* James Tucker [raggi](https://github.com/raggi) -* Josh Peek [josh](https://github.com/josh) -* José Valim [josevalim](https://github.com/josevalim) -* Michael Fellinger [manveru](https://github.com/manveru) -* Santiago Pastorino [spastorino](https://github.com/spastorino) -* Konstantin Haase [rkh](https://github.com/rkh) - -would like to thank: - -* Adrian Madrid, for the LiteSpeed handler. -* Christoffer Sawicki, for the first Rails adapter and `Rack::Deflater`. -* Tim Fletcher, for the HTTP authentication code. -* Luc Heinrich for the Cookie sessions, the static file handler and bugfixes. -* Armin Ronacher, for the logo and racktools. -* Alex Beregszaszi, Alexander Kahn, Anil Wadghule, Aredridel, Ben Alpert, Dan - Kubb, Daniel Roethlisberger, Matt Todd, Tom Robinson, Phil Hagelberg, S. Brent - Faulkner, Bosko Milekic, Daniel Rodríguez Troitiño, Genki Takiuchi, Geoffrey - Grosenbach, Julien Sanchez, Kamal Fariz Mahyuddin, Masayoshi Takahashi, - Patrick Aljordm, Mig, Kazuhiro Nishiyama, Jon Bardin, Konstantin Haase, Larry - Siden, Matias Korhonen, Sam Ruby, Simon Chiang, Tim Connor, Timur Batyrshin, - and Zach Brock for bug fixing and other improvements. -* Eric Wong, Hongli Lai, Jeremy Kemper for their continuous support and API - improvements. -* Yehuda Katz and Carl Lerche for refactoring rackup. -* Brian Candler, for `Rack::ContentType`. -* Graham Batty, for improved handler loading. -* Stephen Bannasch, for bug reports and documentation. -* Gary Wright, for proposing a better `Rack::Response` interface. -* Jonathan Buch, for improvements regarding `Rack::Response`. -* Armin Röhrl, for tracking down bugs in the Cookie generator. -* Alexander Kellett for testing the Gem and reviewing the announcement. -* Marcus Rückert, for help with configuring and debugging lighttpd. -* The WSGI team for the well-done and documented work they've done and Rack - builds up on. -* All bug reporters and patch contributors not mentioned above. - -## License - -Rack is released under the [MIT License](MIT-LICENSE). - -[Rack Specification]: SPEC.rdoc -[Security Policy]: SECURITY.md diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/SPEC.rdoc b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/SPEC.rdoc deleted file mode 100644 index ed5d982..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/SPEC.rdoc +++ /dev/null @@ -1,365 +0,0 @@ -This specification aims to formalize the Rack protocol. You -can (and should) use Rack::Lint to enforce it. - -When you develop middleware, be sure to add a Lint before and -after to catch all mistakes. - -= Rack applications - -A Rack application is a Ruby object (not a class) that -responds to +call+. -It takes exactly one argument, the *environment* -and returns a non-frozen Array of exactly three values: -The *status*, -the *headers*, -and the *body*. - -== The Environment - -The environment must be an unfrozen instance of Hash that includes -CGI-like headers. The Rack application is free to modify the -environment. - -The environment is required to include these variables -(adopted from {PEP 333}[https://peps.python.org/pep-0333/]), except when they'd be empty, but see -below. -REQUEST_METHOD:: The HTTP request method, such as - "GET" or "POST". This cannot ever - be an empty string, and so is - always required. -SCRIPT_NAME:: The initial portion of the request - URL's "path" that corresponds to the - application object, so that the - application knows its virtual - "location". This may be an empty - string, if the application corresponds - to the "root" of the server. -PATH_INFO:: The remainder of the request URL's - "path", designating the virtual - "location" of the request's target - within the application. This may be an - empty string, if the request URL targets - the application root and does not have a - trailing slash. This value may be - percent-encoded when originating from - a URL. -QUERY_STRING:: The portion of the request URL that - follows the ?, if any. May be - empty, but is always required! -SERVER_NAME:: When combined with SCRIPT_NAME and - PATH_INFO, these variables can be - used to complete the URL. Note, however, - that HTTP_HOST, if present, - should be used in preference to - SERVER_NAME for reconstructing - the request URL. - SERVER_NAME can never be an empty - string, and so is always required. -SERVER_PORT:: An optional +Integer+ which is the port the - server is running on. Should be specified if - the server is running on a non-standard port. -SERVER_PROTOCOL:: A string representing the HTTP version used - for the request. -HTTP_ Variables:: Variables corresponding to the - client-supplied HTTP request - headers (i.e., variables whose - names begin with HTTP_). The - presence or absence of these - variables should correspond with - the presence or absence of the - appropriate HTTP header in the - request. See - {RFC3875 section 4.1.18}[https://tools.ietf.org/html/rfc3875#section-4.1.18] - for specific behavior. -In addition to this, the Rack environment must include these -Rack-specific variables: -rack.url_scheme:: +http+ or +https+, depending on the - request URL. -rack.input:: See below, the input stream. -rack.errors:: See below, the error stream. -rack.hijack?:: See below, if present and true, indicates - that the server supports partial hijacking. -rack.hijack:: See below, if present, an object responding - to +call+ that is used to perform a full - hijack. -rack.protocol:: An optional +Array+ of +String+, containing - the protocols advertised by the client in - the +upgrade+ header (HTTP/1) or the - +:protocol+ pseudo-header (HTTP/2). -Additional environment specifications have approved to -standardized middleware APIs. None of these are required to -be implemented by the server. -rack.session:: A hash-like interface for storing - request session data. - The store must implement: - store(key, value) (aliased as []=); - fetch(key, default = nil) (aliased as []); - delete(key); - clear; - to_hash (returning unfrozen Hash instance); -rack.logger:: A common object interface for logging messages. - The object must implement: - info(message, &block) - debug(message, &block) - warn(message, &block) - error(message, &block) - fatal(message, &block) -rack.multipart.buffer_size:: An Integer hint to the multipart parser as to what chunk size to use for reads and writes. -rack.multipart.tempfile_factory:: An object responding to #call with two arguments, the filename and content_type given for the multipart form field, and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate the tempfile for each multipart form file upload field, rather than the default class of Tempfile. -The server or the application can store their own data in the -environment, too. The keys must contain at least one dot, -and should be prefixed uniquely. The prefix rack. -is reserved for use with the Rack core distribution and other -accepted specifications and must not be used otherwise. - -The SERVER_PORT must be an Integer if set. -The SERVER_NAME must be a valid authority as defined by RFC7540. -The HTTP_HOST must be a valid authority as defined by RFC7540. -The SERVER_PROTOCOL must match the regexp HTTP/\d(\.\d)?. -The environment must not contain the keys -HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH -(use the versions without HTTP_). -The CGI keys (named without a period) must have String values. -If the string values for CGI keys contain non-ASCII characters, -they should use ASCII-8BIT encoding. -There are the following restrictions: -* rack.url_scheme must either be +http+ or +https+. -* There may be a valid input stream in rack.input. -* There must be a valid error stream in rack.errors. -* There may be a valid hijack callback in rack.hijack -* There may be a valid early hints callback in rack.early_hints -* The REQUEST_METHOD must be a valid token. -* The SCRIPT_NAME, if non-empty, must start with / -* The PATH_INFO, if provided, must be a valid request target or an empty string. - * Only OPTIONS requests may have PATH_INFO set to * (asterisk-form). - * Only CONNECT requests may have PATH_INFO set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. - * CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form). - * Otherwise, PATH_INFO must start with a / and must not include a fragment part starting with '#' (origin-form). -* The CONTENT_LENGTH, if given, must consist of digits only. -* One of SCRIPT_NAME or PATH_INFO must be - set. PATH_INFO should be / if - SCRIPT_NAME is empty. - SCRIPT_NAME never should be /, but instead be empty. -rack.response_finished:: An array of callables run by the server after the response has been -processed. This would typically be invoked after sending the response to the client, but it could also be -invoked if an error occurs while generating the response or sending the response; in that case, the error -argument will be a subclass of +Exception+. -The callables are invoked with +env, status, headers, error+ arguments and should not raise any -exceptions. They should be invoked in reverse order of registration. - -=== The Input Stream - -The input stream is an IO-like object which contains the raw HTTP -POST data. -When applicable, its external encoding must be "ASCII-8BIT" and it -must be opened in binary mode. -The input stream must respond to +gets+, +each+, and +read+. -* +gets+ must be called without arguments and return a string, - or +nil+ on EOF. -* +read+ behaves like IO#read. - Its signature is read([length, [buffer]]). - - If given, +length+ must be a non-negative Integer (>= 0) or +nil+, - and +buffer+ must be a String and may not be nil. - - If +length+ is given and not nil, then this method reads at most - +length+ bytes from the input stream. - - If +length+ is not given or nil, then this method reads - all data until EOF. - - When EOF is reached, this method returns nil if +length+ is given - and not nil, or "" if +length+ is not given or is nil. - - If +buffer+ is given, then the read data will be placed - into +buffer+ instead of a newly created String object. -* +each+ must be called without arguments and only yield Strings. -* +close+ can be called on the input stream to indicate that - any remaining input is not needed. - -=== The Error Stream - -The error stream must respond to +puts+, +write+ and +flush+. -* +puts+ must be called with a single argument that responds to +to_s+. -* +write+ must be called with a single argument that is a String. -* +flush+ must be called without arguments and must be called - in order to make the error appear for sure. -* +close+ must never be called on the error stream. - -=== Hijacking - -The hijacking interfaces provides a means for an application to take -control of the HTTP connection. There are two distinct hijack -interfaces: full hijacking where the application takes over the raw -connection, and partial hijacking where the application takes over -just the response body stream. In both cases, the application is -responsible for closing the hijacked stream. - -Full hijacking only works with HTTP/1. Partial hijacking is functionally -equivalent to streaming bodies, and is still optionally supported for -backwards compatibility with older Rack versions. - -==== Full Hijack - -Full hijack is used to completely take over an HTTP/1 connection. It -occurs before any headers are written and causes the request to -ignores any response generated by the application. - -It is intended to be used when applications need access to raw HTTP/1 -connection. - -If +rack.hijack+ is present in +env+, it must respond to +call+ -and return an +IO+ instance which can be used to read and write -to the underlying connection using HTTP/1 semantics and -formatting. - -==== Partial Hijack - -Partial hijack is used for bi-directional streaming of the request and -response body. It occurs after the status and headers are written by -the server and causes the server to ignore the Body of the response. - -It is intended to be used when applications need bi-directional -streaming. - -If +rack.hijack?+ is present in +env+ and truthy, -an application may set the special response header +rack.hijack+ -to an object that responds to +call+, -accepting a +stream+ argument. - -After the response status and headers have been sent, this hijack -callback will be invoked with a +stream+ argument which follows the -same interface as outlined in "Streaming Body". Servers must -ignore the +body+ part of the response tuple when the -+rack.hijack+ response header is present. Using an empty +Array+ -instance is recommended. - -The special response header +rack.hijack+ must only be set -if the request +env+ has a truthy +rack.hijack?+. - -=== Early Hints - -The application or any middleware may call the rack.early_hints -with an object which would be valid as the headers of a Rack response. - -If rack.early_hints is present, it must respond to #call. -If rack.early_hints is called, it must be called with -valid Rack response headers. - -== The Response - -=== The Status - -This is an HTTP status. It must be an Integer greater than or equal to -100. - -=== The Headers - -The headers must be a unfrozen Hash. -The header keys must be Strings. -Special headers starting "rack." are for communicating with the -server, and must not be sent back to the client. -The header must not contain a +Status+ key. -Header keys must conform to RFC7230 token specification, i.e. cannot -contain non-printable ASCII, DQUOTE or "(),/:;<=>?@[\]{}". -Header keys must not contain uppercase ASCII characters (A-Z). -Header values must be either a String instance, -or an Array of String instances, -such that each String instance must not contain characters below 037. - -==== The +content-type+ Header - -There must not be a content-type header key when the +Status+ is 1xx, -204, or 304. - -==== The +content-length+ Header - -There must not be a content-length header key when the -+Status+ is 1xx, 204, or 304. - -==== The +rack.protocol+ Header - -If the +rack.protocol+ header is present, it must be a +String+, and -must be one of the values from the +rack.protocol+ array from the -environment. - -Setting this value informs the server that it should perform a -connection upgrade. In HTTP/1, this is done using the +upgrade+ -header. In HTTP/2, this is done by accepting the request. - -=== The Body - -The Body is typically an +Array+ of +String+ instances, an enumerable -that yields +String+ instances, a +Proc+ instance, or a File-like -object. - -The Body must respond to +each+ or +call+. It may optionally respond -to +to_path+ or +to_ary+. A Body that responds to +each+ is considered -to be an Enumerable Body. A Body that responds to +call+ is considered -to be a Streaming Body. - -A Body that responds to both +each+ and +call+ must be treated as an -Enumerable Body, not a Streaming Body. If it responds to +each+, you -must call +each+ and not +call+. If the Body doesn't respond to -+each+, then you can assume it responds to +call+. - -The Body must either be consumed or returned. The Body is consumed by -optionally calling either +each+ or +call+. -Then, if the Body responds to +close+, it must be called to release -any resources associated with the generation of the body. -In other words, +close+ must always be called at least once; typically -after the web server has sent the response to the client, but also in -cases where the Rack application makes internal/virtual requests and -discards the response. - - -After calling +close+, the Body is considered closed and should not -be consumed again. -If the original Body is replaced by a new Body, the new Body must -also consume the original Body by calling +close+ if possible. - -If the Body responds to +to_path+, it must return a +String+ -path for the local file system whose contents are identical -to that produced by calling +each+; this may be used by the -server as an alternative, possibly more efficient way to -transport the response. The +to_path+ method does not consume -the body. - -==== Enumerable Body - -The Enumerable Body must respond to +each+. -It must only be called once. -It must not be called after being closed, -and must only yield String values. - -Middleware must not call +each+ directly on the Body. -Instead, middleware can return a new Body that calls +each+ on the -original Body, yielding at least once per iteration. - -If the Body responds to +to_ary+, it must return an +Array+ whose -contents are identical to that produced by calling +each+. -Middleware may call +to_ary+ directly on the Body and return a new -Body in its place. In other words, middleware can only process the -Body directly if it responds to +to_ary+. If the Body responds to both -+to_ary+ and +close+, its implementation of +to_ary+ must call -+close+. - -==== Streaming Body - -The Streaming Body must respond to +call+. -It must only be called once. -It must not be called after being closed. -It takes a +stream+ argument. - -The +stream+ argument must implement: -read, write, <<, flush, close, close_read, close_write, closed? - -The semantics of these IO methods must be a best effort match to -those of a normal Ruby IO or Socket object, using standard arguments -and raising standard exceptions. Servers are encouraged to simply -pass on real IO objects, although it is recognized that this approach -is not directly compatible with HTTP/2. - -== Thanks -Some parts of this specification are adopted from {PEP 333 – Python Web Server Gateway Interface v1.0}[https://peps.python.org/pep-0333/] -I'd like to thank everyone involved in that effort. diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack.rb deleted file mode 100644 index 6021248..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack.rb +++ /dev/null @@ -1,66 +0,0 @@ -# socket-patch: patched rack-3.1.8 (spike marker) -# frozen_string_literal: true - -# Copyright (C) 2007-2019 Leah Neukirchen -# -# Rack is freely distributable under the terms of an MIT-style license. -# See MIT-LICENSE or https://opensource.org/licenses/MIT. - -# The Rack main module, serving as a namespace for all core Rack -# modules and classes. -# -# All modules meant for use in your application are autoloaded here, -# so it should be enough just to require 'rack' in your code. - -require_relative 'rack/version' -require_relative 'rack/constants' - -module Rack - autoload :BadRequest, "rack/bad_request" - autoload :BodyProxy, "rack/body_proxy" - autoload :Builder, "rack/builder" - autoload :Cascade, "rack/cascade" - autoload :CommonLogger, "rack/common_logger" - autoload :ConditionalGet, "rack/conditional_get" - autoload :Config, "rack/config" - autoload :ContentLength, "rack/content_length" - autoload :ContentType, "rack/content_type" - autoload :Deflater, "rack/deflater" - autoload :Directory, "rack/directory" - autoload :ETag, "rack/etag" - autoload :Events, "rack/events" - autoload :Files, "rack/files" - autoload :ForwardRequest, "rack/recursive" - autoload :Head, "rack/head" - autoload :Headers, "rack/headers" - autoload :Lint, "rack/lint" - autoload :Lock, "rack/lock" - autoload :Logger, "rack/logger" - autoload :MediaType, "rack/media_type" - autoload :MethodOverride, "rack/method_override" - autoload :Mime, "rack/mime" - autoload :MockRequest, "rack/mock_request" - autoload :MockResponse, "rack/mock_response" - autoload :Multipart, "rack/multipart" - autoload :NullLogger, "rack/null_logger" - autoload :QueryParser, "rack/query_parser" - autoload :Recursive, "rack/recursive" - autoload :Reloader, "rack/reloader" - autoload :Request, "rack/request" - autoload :Response, "rack/response" - autoload :RewindableInput, "rack/rewindable_input" - autoload :Runtime, "rack/runtime" - autoload :Sendfile, "rack/sendfile" - autoload :ShowExceptions, "rack/show_exceptions" - autoload :ShowStatus, "rack/show_status" - autoload :Static, "rack/static" - autoload :TempfileReaper, "rack/tempfile_reaper" - autoload :URLMap, "rack/urlmap" - autoload :Utils, "rack/utils" - - module Auth - autoload :Basic, "rack/auth/basic" - autoload :AbstractHandler, "rack/auth/abstract/handler" - autoload :AbstractRequest, "rack/auth/abstract/request" - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb deleted file mode 100644 index 4731ee8..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../constants' - -module Rack - module Auth - # Rack::Auth::AbstractHandler implements common authentication functionality. - # - # +realm+ should be set for all handlers. - - class AbstractHandler - - attr_accessor :realm - - def initialize(app, realm = nil, &authenticator) - @app, @realm, @authenticator = app, realm, authenticator - end - - - private - - def unauthorized(www_authenticate = challenge) - return [ 401, - { CONTENT_TYPE => 'text/plain', - CONTENT_LENGTH => '0', - 'www-authenticate' => www_authenticate.to_s }, - [] - ] - end - - def bad_request - return [ 400, - { CONTENT_TYPE => 'text/plain', - CONTENT_LENGTH => '0' }, - [] - ] - end - - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb deleted file mode 100644 index f872331..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../request' - -module Rack - module Auth - class AbstractRequest - - def initialize(env) - @env = env - end - - def request - @request ||= Request.new(@env) - end - - def provided? - !authorization_key.nil? && valid? - end - - def valid? - !@env[authorization_key].nil? - end - - def parts - @parts ||= @env[authorization_key].split(' ', 2) - end - - def scheme - @scheme ||= parts.first&.downcase - end - - def params - @params ||= parts.last - end - - - private - - AUTHORIZATION_KEYS = ['HTTP_AUTHORIZATION', 'X-HTTP_AUTHORIZATION', 'X_HTTP_AUTHORIZATION'] - - def authorization_key - @authorization_key ||= AUTHORIZATION_KEYS.detect { |key| @env.has_key?(key) } - end - - end - - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb deleted file mode 100644 index 67ffc49..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require_relative 'abstract/handler' -require_relative 'abstract/request' - -module Rack - module Auth - # Rack::Auth::Basic implements HTTP Basic Authentication, as per RFC 2617. - # - # Initialize with the Rack application that you want protecting, - # and a block that checks if a username and password pair are valid. - - class Basic < AbstractHandler - - def call(env) - auth = Basic::Request.new(env) - - return unauthorized unless auth.provided? - - return bad_request unless auth.basic? - - if valid?(auth) - env['REMOTE_USER'] = auth.username - - return @app.call(env) - end - - unauthorized - end - - - private - - def challenge - 'Basic realm="%s"' % realm - end - - def valid?(auth) - @authenticator.call(*auth.credentials) - end - - class Request < Auth::AbstractRequest - def basic? - "basic" == scheme && credentials.length == 2 - end - - def credentials - @credentials ||= params.unpack1('m').split(':', 2) - end - - def username - credentials.first - end - end - - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/bad_request.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/bad_request.rb deleted file mode 100644 index 8eaa94e..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/bad_request.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Represents a 400 Bad Request error when input data fails to meet the - # requirements. - module BadRequest - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb deleted file mode 100644 index 7291579..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Proxy for response bodies allowing calling a block when - # the response body is closed (after the response has been fully - # sent to the client). - class BodyProxy - # Set the response body to wrap, and the block to call when the - # response has been fully sent. - def initialize(body, &block) - @body = body - @block = block - @closed = false - end - - # Return whether the wrapped body responds to the method. - def respond_to_missing?(method_name, include_all = false) - case method_name - when :to_str - false - else - super or @body.respond_to?(method_name, include_all) - end - end - - # If not already closed, close the wrapped body and - # then call the block the proxy was initialized with. - def close - return if @closed - @closed = true - begin - @body.close if @body.respond_to?(:close) - ensure - @block.call - end - end - - # Whether the proxy is closed. The proxy starts as not closed, - # and becomes closed on the first call to close. - def closed? - @closed - end - - # Delegate missing methods to the wrapped body. - def method_missing(method_name, *args, &block) - case method_name - when :to_str - super - when :to_ary - begin - @body.__send__(method_name, *args, &block) - ensure - close - end - else - @body.__send__(method_name, *args, &block) - end - end - # :nocov: - ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) - # :nocov: - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/builder.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/builder.rb deleted file mode 100644 index 9faeffb..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/builder.rb +++ /dev/null @@ -1,290 +0,0 @@ -# frozen_string_literal: true - -require_relative 'urlmap' - -module Rack; end -Rack::BUILDER_TOPLEVEL_BINDING = ->(builder){builder.instance_eval{binding}} - -module Rack - # Rack::Builder provides a domain-specific language (DSL) to construct Rack - # applications. It is primarily used to parse +config.ru+ files which - # instantiate several middleware and a final application which are hosted - # by a Rack-compatible web server. - # - # Example: - # - # app = Rack::Builder.new do - # use Rack::CommonLogger - # map "/ok" do - # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } - # end - # end - # - # run app - # - # Or - # - # app = Rack::Builder.app do - # use Rack::CommonLogger - # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } - # end - # - # run app - # - # +use+ adds middleware to the stack, +run+ dispatches to an application. - # You can use +map+ to construct a Rack::URLMap in a convenient way. - class Builder - - # https://stackoverflow.com/questions/2223882/whats-the-difference-between-utf-8-and-utf-8-without-bom - UTF_8_BOM = '\xef\xbb\xbf' - - # Parse the given config file to get a Rack application. - # - # If the config file ends in +.ru+, it is treated as a - # rackup file and the contents will be treated as if - # specified inside a Rack::Builder block. - # - # If the config file does not end in +.ru+, it is - # required and Rack will use the basename of the file - # to guess which constant will be the Rack application to run. - # - # Examples: - # - # Rack::Builder.parse_file('config.ru') - # # Rack application built using Rack::Builder.new - # - # Rack::Builder.parse_file('app.rb') - # # requires app.rb, which can be anywhere in Ruby's - # # load path. After requiring, assumes App constant - # # is a Rack application - # - # Rack::Builder.parse_file('./my_app.rb') - # # requires ./my_app.rb, which should be in the - # # process's current directory. After requiring, - # # assumes MyApp constant is a Rack application - def self.parse_file(path, **options) - if path.end_with?('.ru') - return self.load_file(path, **options) - else - require path - return Object.const_get(::File.basename(path, '.rb').split('_').map(&:capitalize).join('')) - end - end - - # Load the given file as a rackup file, treating the - # contents as if specified inside a Rack::Builder block. - # - # Ignores content in the file after +__END__+, so that - # use of +__END__+ will not result in a syntax error. - # - # Example config.ru file: - # - # $ cat config.ru - # - # use Rack::ContentLength - # require './app.rb' - # run App - def self.load_file(path, **options) - config = ::File.read(path) - config.slice!(/\A#{UTF_8_BOM}/) if config.encoding == Encoding::UTF_8 - - if config[/^#\\(.*)/] - fail "Parsing options from the first comment line is no longer supported: #{path}" - end - - config.sub!(/^__END__\n.*\Z/m, '') - - return new_from_string(config, path, **options) - end - - # Evaluate the given +builder_script+ string in the context of - # a Rack::Builder block, returning a Rack application. - def self.new_from_string(builder_script, path = "(rackup)", **options) - builder = self.new(**options) - - # We want to build a variant of TOPLEVEL_BINDING with self as a Rack::Builder instance. - # We cannot use instance_eval(String) as that would resolve constants differently. - binding = BUILDER_TOPLEVEL_BINDING.call(builder) - eval(builder_script, binding, path) - - return builder.to_app - end - - # Initialize a new Rack::Builder instance. +default_app+ specifies the - # default application if +run+ is not called later. If a block - # is given, it is evaluated in the context of the instance. - def initialize(default_app = nil, **options, &block) - @use = [] - @map = nil - @run = default_app - @warmup = nil - @freeze_app = false - @options = options - - instance_eval(&block) if block_given? - end - - # Any options provided to the Rack::Builder instance at initialization. - # These options can be server-specific. Some general options are: - # - # * +:isolation+: One of +process+, +thread+ or +fiber+. The execution - # isolation model to use. - attr :options - - # Create a new Rack::Builder instance and return the Rack application - # generated from it. - def self.app(default_app = nil, &block) - self.new(default_app, &block).to_app - end - - # Specifies middleware to use in a stack. - # - # class Middleware - # def initialize(app) - # @app = app - # end - # - # def call(env) - # env["rack.some_header"] = "setting an example" - # @app.call(env) - # end - # end - # - # use Middleware - # run lambda { |env| [200, { "content-type" => "text/plain" }, ["OK"]] } - # - # All requests through to this application will first be processed by the middleware class. - # The +call+ method in this example sets an additional environment key which then can be - # referenced in the application if required. - def use(middleware, *args, &block) - if @map - mapping, @map = @map, nil - @use << proc { |app| generate_map(app, mapping) } - end - @use << proc { |app| middleware.new(app, *args, &block) } - end - # :nocov: - ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true) - # :nocov: - - # Takes a block or argument that is an object that responds to #call and - # returns a Rack response. - # - # You can use a block: - # - # run do |env| - # [200, { "content-type" => "text/plain" }, ["Hello World!"]] - # end - # - # You can also provide a lambda: - # - # run lambda { |env| [200, { "content-type" => "text/plain" }, ["OK"]] } - # - # You can also provide a class instance: - # - # class Heartbeat - # def call(env) - # [200, { "content-type" => "text/plain" }, ["OK"]] - # end - # end - # - # run Heartbeat.new - # - def run(app = nil, &block) - raise ArgumentError, "Both app and block given!" if app && block_given? - - @run = app || block - end - - # Takes a lambda or block that is used to warm-up the application. This block is called - # before the Rack application is returned by to_app. - # - # warmup do |app| - # client = Rack::MockRequest.new(app) - # client.get('/') - # end - # - # use SomeMiddleware - # run MyApp - def warmup(prc = nil, &block) - @warmup = prc || block - end - - # Creates a route within the application. Routes under the mapped path will be sent to - # the Rack application specified by run inside the block. Other requests will be sent to the - # default application specified by run outside the block. - # - # class App - # def call(env) - # [200, {'content-type' => 'text/plain'}, ["Hello World"]] - # end - # end - # - # class Heartbeat - # def call(env) - # [200, { "content-type" => "text/plain" }, ["OK"]] - # end - # end - # - # app = Rack::Builder.app do - # map '/heartbeat' do - # run Heartbeat.new - # end - # run App.new - # end - # - # run app - # - # The +use+ method can also be used inside the block to specify middleware to run under a specific path: - # - # app = Rack::Builder.app do - # map '/heartbeat' do - # use Middleware - # run Heartbeat.new - # end - # run App.new - # end - # - # This example includes a piece of middleware which will run before +/heartbeat+ requests hit +Heartbeat+. - # - # Note that providing a +path+ of +/+ will ignore any default application given in a +run+ statement - # outside the block. - def map(path, &block) - @map ||= {} - @map[path] = block - end - - # Freeze the app (set using run) and all middleware instances when building the application - # in to_app. - def freeze_app - @freeze_app = true - end - - # Return the Rack application generated by this instance. - def to_app - app = @map ? generate_map(@run, @map) : @run - fail "missing run or map statement" unless app - app.freeze if @freeze_app - app = @use.reverse.inject(app) { |a, e| e[a].tap { |x| x.freeze if @freeze_app } } - @warmup.call(app) if @warmup - app - end - - # Call the Rack application generated by this builder instance. Note that - # this rebuilds the Rack application and runs the warmup code (if any) - # every time it is called, so it should not be used if performance is important. - def call(env) - to_app.call(env) - end - - private - - # Generate a URLMap instance by generating new Rack applications for each - # map block in this instance. - def generate_map(default_app, mapping) - mapped = default_app ? { '/' => default_app } : {} - mapping.each { |r, b| mapped[r] = self.class.new(default_app, &b).to_app } - URLMap.new(mapped) - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/cascade.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/cascade.rb deleted file mode 100644 index 9c952fd..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/cascade.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' - -module Rack - # Rack::Cascade tries a request on several apps, and returns the - # first response that is not 404 or 405 (or in a list of configured - # status codes). If all applications tried return one of the configured - # status codes, return the last response. - - class Cascade - # An array of applications to try in order. - attr_reader :apps - - # Set the apps to send requests to, and what statuses result in - # cascading. Arguments: - # - # apps: An enumerable of rack applications. - # cascade_for: The statuses to use cascading for. If a response is received - # from an app, the next app is tried. - def initialize(apps, cascade_for = [404, 405]) - @apps = [] - apps.each { |app| add app } - - @cascade_for = {} - [*cascade_for].each { |status| @cascade_for[status] = true } - end - - # Call each app in order. If the responses uses a status that requires - # cascading, try the next app. If all responses require cascading, - # return the response from the last app. - def call(env) - return [404, { CONTENT_TYPE => "text/plain" }, []] if @apps.empty? - result = nil - last_body = nil - - @apps.each do |app| - # The SPEC says that the body must be closed after it has been iterated - # by the server, or if it is replaced by a middleware action. Cascade - # replaces the body each time a cascade happens. It is assumed that nil - # does not respond to close, otherwise the previous application body - # will be closed. The final application body will not be closed, as it - # will be passed to the server as a result. - last_body.close if last_body.respond_to? :close - - result = app.call(env) - return result unless @cascade_for.include?(result[0].to_i) - last_body = result[2] - end - - result - end - - # Append an app to the list of apps to cascade. This app will - # be tried last. - def add(app) - @apps << app - end - - # Whether the given app is one of the apps to cascade to. - def include?(app) - @apps.include?(app) - end - - alias_method :<<, :add - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/common_logger.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/common_logger.rb deleted file mode 100644 index 2feb067..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/common_logger.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' -require_relative 'body_proxy' -require_relative 'request' - -module Rack - # Rack::CommonLogger forwards every request to the given +app+, and - # logs a line in the - # {Apache common log format}[http://httpd.apache.org/docs/1.3/logs.html#common] - # to the configured logger. - class CommonLogger - # Common Log Format: http://httpd.apache.org/docs/1.3/logs.html#common - # - # lilith.local - - [07/Aug/2006 23:58:02 -0400] "GET / HTTP/1.1" 500 - - # - # %{%s - %s [%s] "%s %s%s %s" %d %s\n} % - # - # The actual format is slightly different than the above due to the - # separation of SCRIPT_NAME and PATH_INFO, and because the elapsed - # time in seconds is included at the end. - FORMAT = %{%s - %s [%s] "%s %s%s%s %s" %d %s %0.4f\n} - - # +logger+ can be any object that supports the +write+ or +<<+ methods, - # which includes the standard library Logger. These methods are called - # with a single string argument, the log message. - # If +logger+ is nil, CommonLogger will fall back env['rack.errors']. - def initialize(app, logger = nil) - @app = app - @logger = logger - end - - # Log all requests in common_log format after a response has been - # returned. Note that if the app raises an exception, the request - # will not be logged, so if exception handling middleware are used, - # they should be loaded after this middleware. Additionally, because - # the logging happens after the request body has been fully sent, any - # exceptions raised during the sending of the response body will - # cause the request not to be logged. - def call(env) - began_at = Utils.clock_time - status, headers, body = response = @app.call(env) - - response[2] = BodyProxy.new(body) { log(env, status, headers, began_at) } - response - end - - private - - # Log the request to the configured logger. - def log(env, status, response_headers, began_at) - request = Rack::Request.new(env) - length = extract_content_length(response_headers) - - msg = sprintf(FORMAT, - request.ip || "-", - request.get_header("REMOTE_USER") || "-", - Time.now.strftime("%d/%b/%Y:%H:%M:%S %z"), - request.request_method, - request.script_name, - request.path_info, - request.query_string.empty? ? "" : "?#{request.query_string}", - request.get_header(SERVER_PROTOCOL), - status.to_s[0..3], - length, - Utils.clock_time - began_at) - - msg.gsub!(/[^[:print:]\n]/) { |c| sprintf("\\x%x", c.ord) } - - logger = @logger || request.get_header(RACK_ERRORS) - # Standard library logger doesn't support write but it supports << which actually - # calls to write on the log device without formatting - if logger.respond_to?(:write) - logger.write(msg) - else - logger << msg - end - end - - # Attempt to determine the content length for the response to - # include it in the logged data. - def extract_content_length(headers) - value = headers[CONTENT_LENGTH] - !value || value.to_s == '0' ? '-' : value - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb deleted file mode 100644 index c3b334a..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' -require_relative 'body_proxy' - -module Rack - - # Middleware that enables conditional GET using if-none-match and - # if-modified-since. The application should set either or both of the - # last-modified or etag response headers according to RFC 2616. When - # either of the conditions is met, the response body is set to be zero - # length and the response status is set to 304 Not Modified. - # - # Applications that defer response body generation until the body's each - # message is received will avoid response body generation completely when - # a conditional GET matches. - # - # Adapted from Michael Klishin's Merb implementation: - # https://github.com/wycats/merb/blob/master/merb-core/lib/merb-core/rack/middleware/conditional_get.rb - class ConditionalGet - def initialize(app) - @app = app - end - - # Return empty 304 response if the response has not been - # modified since the last request. - def call(env) - case env[REQUEST_METHOD] - when "GET", "HEAD" - status, headers, body = response = @app.call(env) - - if status == 200 && fresh?(env, headers) - response[0] = 304 - headers.delete(CONTENT_TYPE) - headers.delete(CONTENT_LENGTH) - response[2] = Rack::BodyProxy.new([]) do - body.close if body.respond_to?(:close) - end - end - response - else - @app.call(env) - end - end - - private - - # Return whether the response has not been modified since the - # last request. - def fresh?(env, headers) - # if-none-match has priority over if-modified-since per RFC 7232 - if none_match = env['HTTP_IF_NONE_MATCH'] - etag_matches?(none_match, headers) - elsif (modified_since = env['HTTP_IF_MODIFIED_SINCE']) && (modified_since = to_rfc2822(modified_since)) - modified_since?(modified_since, headers) - end - end - - # Whether the etag response header matches the if-none-match request header. - # If so, the request has not been modified. - def etag_matches?(none_match, headers) - headers[ETAG] == none_match - end - - # Whether the last-modified response header matches the if-modified-since - # request header. If so, the request has not been modified. - def modified_since?(modified_since, headers) - last_modified = to_rfc2822(headers['last-modified']) and - modified_since >= last_modified - end - - # Return a Time object for the given string (which should be in RFC2822 - # format), or nil if the string cannot be parsed. - def to_rfc2822(since) - # shortest possible valid date is the obsolete: 1 Nov 97 09:55 A - # anything shorter is invalid, this avoids exceptions for common cases - # most common being the empty string - if since && since.length >= 16 - # NOTE: there is no trivial way to write this in a non exception way - # _rfc2822 returns a hash but is not that usable - Time.rfc2822(since) rescue nil - end - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/config.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/config.rb deleted file mode 100644 index 41f6f7d..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/config.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Rack::Config modifies the environment using the block given during - # initialization. - # - # Example: - # use Rack::Config do |env| - # env['my-key'] = 'some-value' - # end - class Config - def initialize(app, &block) - @app = app - @block = block - end - - def call(env) - @block.call(env) - @app.call(env) - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/constants.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/constants.rb deleted file mode 100644 index e9b6e10..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/constants.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Request env keys - HTTP_HOST = 'HTTP_HOST' - HTTP_PORT = 'HTTP_PORT' - HTTPS = 'HTTPS' - PATH_INFO = 'PATH_INFO' - REQUEST_METHOD = 'REQUEST_METHOD' - REQUEST_PATH = 'REQUEST_PATH' - SCRIPT_NAME = 'SCRIPT_NAME' - QUERY_STRING = 'QUERY_STRING' - SERVER_PROTOCOL = 'SERVER_PROTOCOL' - SERVER_NAME = 'SERVER_NAME' - SERVER_PORT = 'SERVER_PORT' - HTTP_COOKIE = 'HTTP_COOKIE' - - # Response Header Keys - CACHE_CONTROL = 'cache-control' - CONTENT_LENGTH = 'content-length' - CONTENT_TYPE = 'content-type' - ETAG = 'etag' - EXPIRES = 'expires' - SET_COOKIE = 'set-cookie' - TRANSFER_ENCODING = 'transfer-encoding' - - # HTTP method verbs - GET = 'GET' - POST = 'POST' - PUT = 'PUT' - PATCH = 'PATCH' - DELETE = 'DELETE' - HEAD = 'HEAD' - OPTIONS = 'OPTIONS' - CONNECT = 'CONNECT' - LINK = 'LINK' - UNLINK = 'UNLINK' - TRACE = 'TRACE' - - # Rack environment variables - RACK_VERSION = 'rack.version' - RACK_TEMPFILES = 'rack.tempfiles' - RACK_EARLY_HINTS = 'rack.early_hints' - RACK_ERRORS = 'rack.errors' - RACK_LOGGER = 'rack.logger' - RACK_INPUT = 'rack.input' - RACK_SESSION = 'rack.session' - RACK_SESSION_OPTIONS = 'rack.session.options' - RACK_SHOWSTATUS_DETAIL = 'rack.showstatus.detail' - RACK_URL_SCHEME = 'rack.url_scheme' - RACK_HIJACK = 'rack.hijack' - RACK_IS_HIJACK = 'rack.hijack?' - RACK_RECURSIVE_INCLUDE = 'rack.recursive.include' - RACK_MULTIPART_BUFFER_SIZE = 'rack.multipart.buffer_size' - RACK_MULTIPART_TEMPFILE_FACTORY = 'rack.multipart.tempfile_factory' - RACK_RESPONSE_FINISHED = 'rack.response_finished' - RACK_REQUEST_FORM_INPUT = 'rack.request.form_input' - RACK_REQUEST_FORM_HASH = 'rack.request.form_hash' - RACK_REQUEST_FORM_PAIRS = 'rack.request.form_pairs' - RACK_REQUEST_FORM_VARS = 'rack.request.form_vars' - RACK_REQUEST_FORM_ERROR = 'rack.request.form_error' - RACK_REQUEST_COOKIE_HASH = 'rack.request.cookie_hash' - RACK_REQUEST_COOKIE_STRING = 'rack.request.cookie_string' - RACK_REQUEST_QUERY_HASH = 'rack.request.query_hash' - RACK_REQUEST_QUERY_STRING = 'rack.request.query_string' - RACK_METHODOVERRIDE_ORIGINAL_METHOD = 'rack.methodoverride.original_method' -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_length.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_length.rb deleted file mode 100644 index cbac93a..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_length.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' - -module Rack - - # Sets the content-length header on responses that do not specify - # a content-length or transfer-encoding header. Note that this - # does not fix responses that have an invalid content-length - # header specified. - class ContentLength - include Rack::Utils - - def initialize(app) - @app = app - end - - def call(env) - status, headers, body = response = @app.call(env) - - if !STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) && - !headers[CONTENT_LENGTH] && - !headers[TRANSFER_ENCODING] && - body.respond_to?(:to_ary) - - response[2] = body = body.to_ary - headers[CONTENT_LENGTH] = body.sum(&:bytesize).to_s - end - - response - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_type.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_type.rb deleted file mode 100644 index 19f0782..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/content_type.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' - -module Rack - - # Sets the content-type header on responses which don't have one. - # - # Builder Usage: - # use Rack::ContentType, "text/plain" - # - # When no content type argument is provided, "text/html" is the - # default. - class ContentType - include Rack::Utils - - def initialize(app, content_type = "text/html") - @app = app - @content_type = content_type - end - - def call(env) - status, headers, _ = response = @app.call(env) - - unless STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) - headers[CONTENT_TYPE] ||= @content_type - end - - response - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/deflater.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/deflater.rb deleted file mode 100644 index cc01c32..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/deflater.rb +++ /dev/null @@ -1,158 +0,0 @@ -# frozen_string_literal: true - -require "zlib" -require "time" # for Time.httpdate - -require_relative 'constants' -require_relative 'utils' -require_relative 'request' -require_relative 'body_proxy' - -module Rack - # This middleware enables content encoding of http responses, - # usually for purposes of compression. - # - # Currently supported encodings: - # - # * gzip - # * identity (no transformation) - # - # This middleware automatically detects when encoding is supported - # and allowed. For example no encoding is made when a cache - # directive of 'no-transform' is present, when the response status - # code is one that doesn't allow an entity body, or when the body - # is empty. - # - # Note that despite the name, Deflater does not support the +deflate+ - # encoding. - class Deflater - # Creates Rack::Deflater middleware. Options: - # - # :if :: a lambda enabling / disabling deflation based on returned boolean value - # (e.g use Rack::Deflater, :if => lambda { |*, body| sum=0; body.each { |i| sum += i.length }; sum > 512 }). - # However, be aware that calling `body.each` inside the block will break cases where `body.each` is not idempotent, - # such as when it is an +IO+ instance. - # :include :: a list of content types that should be compressed. By default, all content types are compressed. - # :sync :: determines if the stream is going to be flushed after every chunk. Flushing after every chunk reduces - # latency for time-sensitive streaming applications, but hurts compression and throughput. - # Defaults to +true+. - def initialize(app, options = {}) - @app = app - @condition = options[:if] - @compressible_types = options[:include] - @sync = options.fetch(:sync, true) - end - - def call(env) - status, headers, body = response = @app.call(env) - - unless should_deflate?(env, status, headers, body) - return response - end - - request = Request.new(env) - - encoding = Utils.select_best_encoding(%w(gzip identity), - request.accept_encoding) - - # Set the Vary HTTP header. - vary = headers["vary"].to_s.split(",").map(&:strip) - unless vary.include?("*") || vary.any?{|v| v.downcase == 'accept-encoding'} - headers["vary"] = vary.push("Accept-Encoding").join(",") - end - - case encoding - when "gzip" - headers['content-encoding'] = "gzip" - headers.delete(CONTENT_LENGTH) - mtime = headers["last-modified"] - mtime = Time.httpdate(mtime).to_i if mtime - response[2] = GzipStream.new(body, mtime, @sync) - response - when "identity" - response - else # when nil - # Only possible encoding values here are 'gzip', 'identity', and nil - message = "An acceptable encoding for the requested resource #{request.fullpath} could not be found." - bp = Rack::BodyProxy.new([message]) { body.close if body.respond_to?(:close) } - [406, { CONTENT_TYPE => "text/plain", CONTENT_LENGTH => message.length.to_s }, bp] - end - end - - # Body class used for gzip encoded responses. - class GzipStream - - BUFFER_LENGTH = 128 * 1_024 - - # Initialize the gzip stream. Arguments: - # body :: Response body to compress with gzip - # mtime :: The modification time of the body, used to set the - # modification time in the gzip header. - # sync :: Whether to flush each gzip chunk as soon as it is ready. - def initialize(body, mtime, sync) - @body = body - @mtime = mtime - @sync = sync - end - - # Yield gzip compressed strings to the given block. - def each(&block) - @writer = block - gzip = ::Zlib::GzipWriter.new(self) - gzip.mtime = @mtime if @mtime - # @body.each is equivalent to @body.gets (slow) - if @body.is_a? ::File # XXX: Should probably be ::IO - while part = @body.read(BUFFER_LENGTH) - gzip.write(part) - gzip.flush if @sync - end - else - @body.each { |part| - # Skip empty strings, as they would result in no output, - # and flushing empty parts would raise Zlib::BufError. - next if part.empty? - gzip.write(part) - gzip.flush if @sync - } - end - ensure - gzip.finish - end - - # Call the block passed to #each with the gzipped data. - def write(data) - @writer.call(data) - end - - # Close the original body if possible. - def close - @body.close if @body.respond_to?(:close) - end - end - - private - - # Whether the body should be compressed. - def should_deflate?(env, status, headers, body) - # Skip compressing empty entity body responses and responses with - # no-transform set. - if Utils::STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) || - /\bno-transform\b/.match?(headers[CACHE_CONTROL].to_s) || - headers['content-encoding']&.!~(/\bidentity\b/) - return false - end - - # Skip if @compressible_types are given and does not include request's content type - return false if @compressible_types && !(headers.has_key?(CONTENT_TYPE) && @compressible_types.include?(headers[CONTENT_TYPE][/[^;]*/])) - - # Skip if @condition lambda is given and evaluates to false - return false if @condition && !@condition.call(env, status, headers, body) - - # No point in compressing empty body, also handles usage with - # Rack::Sendfile. - return false if headers[CONTENT_LENGTH] == '0' - - true - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/directory.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/directory.rb deleted file mode 100644 index 089623f..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/directory.rb +++ /dev/null @@ -1,205 +0,0 @@ -# frozen_string_literal: true - -require 'time' - -require_relative 'constants' -require_relative 'utils' -require_relative 'head' -require_relative 'mime' -require_relative 'files' - -module Rack - # Rack::Directory serves entries below the +root+ given, according to the - # path info of the Rack request. If a directory is found, the file's contents - # will be presented in an html based index. If a file is found, the env will - # be passed to the specified +app+. - # - # If +app+ is not specified, a Rack::Files of the same +root+ will be used. - - class Directory - DIR_FILE = "%s%s%s%s\n" - DIR_PAGE_HEADER = <<-PAGE - - %s - - - -

%s

-
- - - - - - - - PAGE - DIR_PAGE_FOOTER = <<-PAGE -
NameSizeTypeLast Modified
-
- - PAGE - - # Body class for directory entries, showing an index page with links - # to each file. - class DirectoryBody < Struct.new(:root, :path, :files) - # Yield strings for each part of the directory entry - def each - show_path = Utils.escape_html(path.sub(/^#{root}/, '')) - yield(DIR_PAGE_HEADER % [ show_path, show_path ]) - - unless path.chomp('/') == root - yield(DIR_FILE % DIR_FILE_escape(files.call('..'))) - end - - Dir.foreach(path) do |basename| - next if basename.start_with?('.') - next unless f = files.call(basename) - yield(DIR_FILE % DIR_FILE_escape(f)) - end - - yield(DIR_PAGE_FOOTER) - end - - private - - # Escape each element in the array of html strings. - def DIR_FILE_escape(htmls) - htmls.map { |e| Utils.escape_html(e) } - end - end - - # The root of the directory hierarchy. Only requests for files and - # directories inside of the root directory are supported. - attr_reader :root - - # Set the root directory and application for serving files. - def initialize(root, app = nil) - @root = ::File.expand_path(root) - @app = app || Files.new(@root) - @head = Head.new(method(:get)) - end - - def call(env) - # strip body if this is a HEAD call - @head.call env - end - - # Internals of request handling. Similar to call but does - # not remove body for HEAD requests. - def get(env) - script_name = env[SCRIPT_NAME] - path_info = Utils.unescape_path(env[PATH_INFO]) - - if client_error_response = check_bad_request(path_info) || check_forbidden(path_info) - client_error_response - else - path = ::File.join(@root, path_info) - list_path(env, path, path_info, script_name) - end - end - - # Rack response to use for requests with invalid paths, or nil if path is valid. - def check_bad_request(path_info) - return if Utils.valid_path?(path_info) - - body = "Bad Request\n" - [400, { CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => body.bytesize.to_s, - "x-cascade" => "pass" }, [body]] - end - - # Rack response to use for requests with paths outside the root, or nil if path is inside the root. - def check_forbidden(path_info) - return unless path_info.include? ".." - return if ::File.expand_path(::File.join(@root, path_info)).start_with?(@root) - - body = "Forbidden\n" - [403, { CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => body.bytesize.to_s, - "x-cascade" => "pass" }, [body]] - end - - # Rack response to use for directories under the root. - def list_directory(path_info, path, script_name) - url_head = (script_name.split('/') + path_info.split('/')).map do |part| - Utils.escape_path part - end - - # Globbing not safe as path could contain glob metacharacters - body = DirectoryBody.new(@root, path, ->(basename) do - stat = stat(::File.join(path, basename)) - next unless stat - - url = ::File.join(*url_head + [Utils.escape_path(basename)]) - mtime = stat.mtime.httpdate - if stat.directory? - type = 'directory' - size = '-' - url << '/' - if basename == '..' - basename = 'Parent Directory' - else - basename << '/' - end - else - type = Mime.mime_type(::File.extname(basename)) - size = filesize_format(stat.size) - end - - [ url, basename, size, type, mtime ] - end) - - [ 200, { CONTENT_TYPE => 'text/html; charset=utf-8' }, body ] - end - - # File::Stat for the given path, but return nil for missing/bad entries. - def stat(path) - ::File.stat(path) - rescue Errno::ENOENT, Errno::ELOOP - return nil - end - - # Rack response to use for files and directories under the root. - # Unreadable and non-file, non-directory entries will get a 404 response. - def list_path(env, path, path_info, script_name) - if (stat = stat(path)) && stat.readable? - return @app.call(env) if stat.file? - return list_directory(path_info, path, script_name) if stat.directory? - end - - entity_not_found(path_info) - end - - # Rack response to use for unreadable and non-file, non-directory entries. - def entity_not_found(path_info) - body = "Entity not found: #{path_info}\n" - [404, { CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => body.bytesize.to_s, - "x-cascade" => "pass" }, [body]] - end - - # Stolen from Ramaze - FILESIZE_FORMAT = [ - ['%.1fT', 1 << 40], - ['%.1fG', 1 << 30], - ['%.1fM', 1 << 20], - ['%.1fK', 1 << 10], - ] - - # Provide human readable file sizes - def filesize_format(int) - FILESIZE_FORMAT.each do |format, size| - return format % (int.to_f / size) if int >= size - end - - "#{int}B" - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/etag.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/etag.rb deleted file mode 100644 index fa78b47..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/etag.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -require 'digest/sha2' - -require_relative 'constants' -require_relative 'utils' - -module Rack - # Automatically sets the etag header on all String bodies. - # - # The etag header is skipped if etag or last-modified headers are sent or if - # a sendfile body (body.responds_to :to_path) is given (since such cases - # should be handled by apache/nginx). - # - # On initialization, you can pass two parameters: a cache-control directive - # used when etag is absent and a directive when it is present. The first - # defaults to nil, while the second defaults to "max-age=0, private, must-revalidate" - class ETag - ETAG_STRING = Rack::ETAG - DEFAULT_CACHE_CONTROL = "max-age=0, private, must-revalidate" - - def initialize(app, no_cache_control = nil, cache_control = DEFAULT_CACHE_CONTROL) - @app = app - @cache_control = cache_control - @no_cache_control = no_cache_control - end - - def call(env) - status, headers, body = response = @app.call(env) - - if etag_status?(status) && body.respond_to?(:to_ary) && !skip_caching?(headers) - body = body.to_ary - digest = digest_body(body) - headers[ETAG_STRING] = %(W/"#{digest}") if digest - end - - unless headers[CACHE_CONTROL] - if digest - headers[CACHE_CONTROL] = @cache_control if @cache_control - else - headers[CACHE_CONTROL] = @no_cache_control if @no_cache_control - end - end - - response - end - - private - - def etag_status?(status) - status == 200 || status == 201 - end - - def skip_caching?(headers) - headers.key?(ETAG_STRING) || headers.key?('last-modified') - end - - def digest_body(body) - digest = nil - - body.each do |part| - (digest ||= Digest::SHA256.new) << part unless part.empty? - end - - digest && digest.hexdigest.byteslice(0,32) - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/events.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/events.rb deleted file mode 100644 index c7bb201..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/events.rb +++ /dev/null @@ -1,157 +0,0 @@ -# frozen_string_literal: true - -require_relative 'body_proxy' -require_relative 'request' -require_relative 'response' - -module Rack - ### This middleware provides hooks to certain places in the request / - # response lifecycle. This is so that middleware that don't need to filter - # the response data can safely leave it alone and not have to send messages - # down the traditional "rack stack". - # - # The events are: - # - # * on_start(request, response) - # - # This event is sent at the start of the request, before the next - # middleware in the chain is called. This method is called with a request - # object, and a response object. Right now, the response object is always - # nil, but in the future it may actually be a real response object. - # - # * on_commit(request, response) - # - # The response has been committed. The application has returned, but the - # response has not been sent to the webserver yet. This method is always - # called with a request object and the response object. The response - # object is constructed from the rack triple that the application returned. - # Changes may still be made to the response object at this point. - # - # * on_send(request, response) - # - # The webserver has started iterating over the response body and presumably - # has started sending data over the wire. This method is always called with - # a request object and the response object. The response object is - # constructed from the rack triple that the application returned. Changes - # SHOULD NOT be made to the response object as the webserver has already - # started sending data. Any mutations will likely result in an exception. - # - # * on_finish(request, response) - # - # The webserver has closed the response, and all data has been written to - # the response socket. The request and response object should both be - # read-only at this point. The body MAY NOT be available on the response - # object as it may have been flushed to the socket. - # - # * on_error(request, response, error) - # - # An exception has occurred in the application or an `on_commit` event. - # This method will get the request, the response (if available) and the - # exception that was raised. - # - # ## Order - # - # `on_start` is called on the handlers in the order that they were passed to - # the constructor. `on_commit`, on_send`, `on_finish`, and `on_error` are - # called in the reverse order. `on_finish` handlers are called inside an - # `ensure` block, so they are guaranteed to be called even if something - # raises an exception. If something raises an exception in a `on_finish` - # method, then nothing is guaranteed. - - class Events - module Abstract - def on_start(req, res) - end - - def on_commit(req, res) - end - - def on_send(req, res) - end - - def on_finish(req, res) - end - - def on_error(req, res, e) - end - end - - class EventedBodyProxy < Rack::BodyProxy # :nodoc: - attr_reader :request, :response - - def initialize(body, request, response, handlers, &block) - super(body, &block) - @request = request - @response = response - @handlers = handlers - end - - def each - @handlers.reverse_each { |handler| handler.on_send request, response } - super - end - end - - class BufferedResponse < Rack::Response::Raw # :nodoc: - attr_reader :body - - def initialize(status, headers, body) - super(status, headers) - @body = body - end - - def to_a; [status, headers, body]; end - end - - def initialize(app, handlers) - @app = app - @handlers = handlers - end - - def call(env) - request = make_request env - on_start request, nil - - begin - status, headers, body = @app.call request.env - response = make_response status, headers, body - on_commit request, response - rescue StandardError => e - on_error request, response, e - on_finish request, response - raise - end - - body = EventedBodyProxy.new(body, request, response, @handlers) do - on_finish request, response - end - [response.status, response.headers, body] - end - - private - - def on_error(request, response, e) - @handlers.reverse_each { |handler| handler.on_error request, response, e } - end - - def on_commit(request, response) - @handlers.reverse_each { |handler| handler.on_commit request, response } - end - - def on_start(request, response) - @handlers.each { |handler| handler.on_start request, nil } - end - - def on_finish(request, response) - @handlers.reverse_each { |handler| handler.on_finish request, response } - end - - def make_request(env) - Rack::Request.new env - end - - def make_response(status, headers, body) - BufferedResponse.new status, headers, body - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/files.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/files.rb deleted file mode 100644 index 5b8353f..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/files.rb +++ /dev/null @@ -1,216 +0,0 @@ -# frozen_string_literal: true - -require 'time' - -require_relative 'constants' -require_relative 'head' -require_relative 'utils' -require_relative 'request' -require_relative 'mime' - -module Rack - # Rack::Files serves files below the +root+ directory given, according to the - # path info of the Rack request. - # e.g. when Rack::Files.new("/etc") is used, you can access 'passwd' file - # as http://localhost:9292/passwd - # - # Handlers can detect if bodies are a Rack::Files, and use mechanisms - # like sendfile on the +path+. - - class Files - ALLOWED_VERBS = %w[GET HEAD OPTIONS] - ALLOW_HEADER = ALLOWED_VERBS.join(', ') - MULTIPART_BOUNDARY = 'AaB03x' - - attr_reader :root - - def initialize(root, headers = {}, default_mime = 'text/plain') - @root = (::File.expand_path(root) if root) - @headers = headers - @default_mime = default_mime - @head = Rack::Head.new(lambda { |env| get env }) - end - - def call(env) - # HEAD requests drop the response body, including 4xx error messages. - @head.call env - end - - def get(env) - request = Rack::Request.new env - unless ALLOWED_VERBS.include? request.request_method - return fail(405, "Method Not Allowed", { 'allow' => ALLOW_HEADER }) - end - - path_info = Utils.unescape_path request.path_info - return fail(400, "Bad Request") unless Utils.valid_path?(path_info) - - clean_path_info = Utils.clean_path_info(path_info) - path = ::File.join(@root, clean_path_info) - - available = begin - ::File.file?(path) && ::File.readable?(path) - rescue SystemCallError - # Not sure in what conditions this exception can occur, but this - # is a safe way to handle such an error. - # :nocov: - false - # :nocov: - end - - if available - serving(request, path) - else - fail(404, "File not found: #{path_info}") - end - end - - def serving(request, path) - if request.options? - return [200, { 'allow' => ALLOW_HEADER, CONTENT_LENGTH => '0' }, []] - end - last_modified = ::File.mtime(path).httpdate - return [304, {}, []] if request.get_header('HTTP_IF_MODIFIED_SINCE') == last_modified - - headers = { "last-modified" => last_modified } - mime_type = mime_type path, @default_mime - headers[CONTENT_TYPE] = mime_type if mime_type - - # Set custom headers - headers.merge!(@headers) if @headers - - status = 200 - size = filesize path - - ranges = Rack::Utils.get_byte_ranges(request.get_header('HTTP_RANGE'), size) - if ranges.nil? - # No ranges: - ranges = [0..size - 1] - elsif ranges.empty? - # Unsatisfiable. Return error, and file size: - response = fail(416, "Byte range unsatisfiable") - response[1]["content-range"] = "bytes */#{size}" - return response - else - # Partial content - partial_content = true - - if ranges.size == 1 - range = ranges[0] - headers["content-range"] = "bytes #{range.begin}-#{range.end}/#{size}" - else - headers[CONTENT_TYPE] = "multipart/byteranges; boundary=#{MULTIPART_BOUNDARY}" - end - - status = 206 - body = BaseIterator.new(path, ranges, mime_type: mime_type, size: size) - size = body.bytesize - end - - headers[CONTENT_LENGTH] = size.to_s - - if request.head? - body = [] - elsif !partial_content - body = Iterator.new(path, ranges, mime_type: mime_type, size: size) - end - - [status, headers, body] - end - - class BaseIterator - attr_reader :path, :ranges, :options - - def initialize(path, ranges, options) - @path = path - @ranges = ranges - @options = options - end - - def each - ::File.open(path, "rb") do |file| - ranges.each do |range| - yield multipart_heading(range) if multipart? - - each_range_part(file, range) do |part| - yield part - end - end - - yield "\r\n--#{MULTIPART_BOUNDARY}--\r\n" if multipart? - end - end - - def bytesize - size = ranges.inject(0) do |sum, range| - sum += multipart_heading(range).bytesize if multipart? - sum += range.size - end - size += "\r\n--#{MULTIPART_BOUNDARY}--\r\n".bytesize if multipart? - size - end - - def close; end - - private - - def multipart? - ranges.size > 1 - end - - def multipart_heading(range) -<<-EOF -\r ---#{MULTIPART_BOUNDARY}\r -content-type: #{options[:mime_type]}\r -content-range: bytes #{range.begin}-#{range.end}/#{options[:size]}\r -\r -EOF - end - - def each_range_part(file, range) - file.seek(range.begin) - remaining_len = range.end - range.begin + 1 - while remaining_len > 0 - part = file.read([8192, remaining_len].min) - break unless part - remaining_len -= part.length - - yield part - end - end - end - - class Iterator < BaseIterator - alias :to_path :path - end - - private - - def fail(status, body, headers = {}) - body += "\n" - - [ - status, - { - CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => body.size.to_s, - "x-cascade" => "pass" - }.merge!(headers), - [body] - ] - end - - # The MIME type for the contents of the file located at @path - def mime_type(path, default_mime) - Mime.mime_type(::File.extname(path), default_mime) - end - - def filesize(path) - # We check via File::size? whether this file provides size info - # via stat (e.g. /proc files often don't), otherwise we have to - # figure it out by reading the whole file into memory. - ::File.size?(path) || ::File.read(path).bytesize - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/head.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/head.rb deleted file mode 100644 index c1c430f..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/head.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'body_proxy' - -module Rack - # Rack::Head returns an empty body for all HEAD requests. It leaves - # all other requests unchanged. - class Head - def initialize(app) - @app = app - end - - def call(env) - _, _, body = response = @app.call(env) - - if env[REQUEST_METHOD] == HEAD - response[2] = Rack::BodyProxy.new([]) do - body.close if body.respond_to? :close - end - end - - response - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/headers.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/headers.rb deleted file mode 100644 index cedf3a8..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/headers.rb +++ /dev/null @@ -1,238 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Rack::Headers is a Hash subclass that downcases all keys. It's designed - # to be used by rack applications that don't implement the Rack 3 SPEC - # (by using non-lowercase response header keys), automatically handling - # the downcasing of keys. - class Headers < Hash - KNOWN_HEADERS = {} - %w( - Accept-CH - Accept-Patch - Accept-Ranges - Access-Control-Allow-Credentials - Access-Control-Allow-Headers - Access-Control-Allow-Methods - Access-Control-Allow-Origin - Access-Control-Expose-Headers - Access-Control-Max-Age - Age - Allow - Alt-Svc - Cache-Control - Connection - Content-Disposition - Content-Encoding - Content-Language - Content-Length - Content-Location - Content-MD5 - Content-Range - Content-Security-Policy - Content-Security-Policy-Report-Only - Content-Type - Date - Delta-Base - ETag - Expect-CT - Expires - Feature-Policy - IM - Last-Modified - Link - Location - NEL - P3P - Permissions-Policy - Pragma - Preference-Applied - Proxy-Authenticate - Public-Key-Pins - Referrer-Policy - Refresh - Report-To - Retry-After - Server - Set-Cookie - Status - Strict-Transport-Security - Timing-Allow-Origin - Tk - Trailer - Transfer-Encoding - Upgrade - Vary - Via - WWW-Authenticate - Warning - X-Cascade - X-Content-Duration - X-Content-Security-Policy - X-Content-Type-Options - X-Correlation-ID - X-Correlation-Id - X-Download-Options - X-Frame-Options - X-Permitted-Cross-Domain-Policies - X-Powered-By - X-Redirect-By - X-Request-ID - X-Request-Id - X-Runtime - X-UA-Compatible - X-WebKit-CS - X-XSS-Protection - ).each do |str| - downcased = str.downcase.freeze - KNOWN_HEADERS[str] = KNOWN_HEADERS[downcased] = downcased - end - - def self.[](*items) - if items.length % 2 != 0 - if items.length == 1 && items.first.is_a?(Hash) - new.merge!(items.first) - else - raise ArgumentError, "odd number of arguments for Rack::Headers" - end - else - hash = new - loop do - break if items.length == 0 - key = items.shift - value = items.shift - hash[key] = value - end - hash - end - end - - def [](key) - super(downcase_key(key)) - end - - def []=(key, value) - super(KNOWN_HEADERS[key] || key.downcase.freeze, value) - end - alias store []= - - def assoc(key) - super(downcase_key(key)) - end - - def compare_by_identity - raise TypeError, "Rack::Headers cannot compare by identity, use regular Hash" - end - - def delete(key) - super(downcase_key(key)) - end - - def dig(key, *a) - super(downcase_key(key), *a) - end - - def fetch(key, *default, &block) - key = downcase_key(key) - super - end - - def fetch_values(*a) - super(*a.map!{|key| downcase_key(key)}) - end - - def has_key?(key) - super(downcase_key(key)) - end - alias include? has_key? - alias key? has_key? - alias member? has_key? - - def invert - hash = self.class.new - each{|key, value| hash[value] = key} - hash - end - - def merge(hash, &block) - dup.merge!(hash, &block) - end - - def reject(&block) - hash = dup - hash.reject!(&block) - hash - end - - def replace(hash) - clear - update(hash) - end - - def select(&block) - hash = dup - hash.select!(&block) - hash - end - - def to_proc - lambda{|x| self[x]} - end - - def transform_values(&block) - dup.transform_values!(&block) - end - - def update(hash, &block) - hash.each do |key, value| - self[key] = if block_given? && include?(key) - block.call(key, self[key], value) - else - value - end - end - self - end - alias merge! update - - def values_at(*keys) - keys.map{|key| self[key]} - end - - # :nocov: - if RUBY_VERSION >= '2.5' - # :nocov: - def slice(*a) - h = self.class.new - a.each{|k| h[k] = self[k] if has_key?(k)} - h - end - - def transform_keys(&block) - dup.transform_keys!(&block) - end - - def transform_keys! - hash = self.class.new - each do |k, v| - hash[yield k] = v - end - replace(hash) - end - end - - # :nocov: - if RUBY_VERSION >= '3.0' - # :nocov: - def except(*a) - super(*a.map!{|key| downcase_key(key)}) - end - end - - private - - def downcase_key(key) - key.is_a?(String) ? KNOWN_HEADERS[key] || key.downcase : key - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lint.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lint.rb deleted file mode 100644 index 4f36c2e..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lint.rb +++ /dev/null @@ -1,991 +0,0 @@ -# frozen_string_literal: true - -require 'forwardable' -require 'uri' - -require_relative 'constants' -require_relative 'utils' - -module Rack - # Rack::Lint validates your application and the requests and - # responses according to the Rack spec. - - class Lint - REQUEST_PATH_ORIGIN_FORM = /\A\/[^#]*\z/ - REQUEST_PATH_ABSOLUTE_FORM = /\A#{Utils::URI_PARSER.make_regexp}\z/ - REQUEST_PATH_AUTHORITY_FORM = /\A[^\/:]+:\d+\z/ - REQUEST_PATH_ASTERISK_FORM = '*' - - def initialize(app) - @app = app - end - - # :stopdoc: - - class LintError < RuntimeError; end - # AUTHORS: n.b. The trailing whitespace between paragraphs is important and - # should not be removed. The whitespace creates paragraphs in the RDoc - # output. - # - ## This specification aims to formalize the Rack protocol. You - ## can (and should) use Rack::Lint to enforce it. - ## - ## When you develop middleware, be sure to add a Lint before and - ## after to catch all mistakes. - ## - ## = Rack applications - ## - ## A Rack application is a Ruby object (not a class) that - ## responds to +call+. - def call(env = nil) - Wrapper.new(@app, env).response - end - - class Wrapper - def initialize(app, env) - @app = app - @env = env - @response = nil - @head_request = false - - @status = nil - @headers = nil - @body = nil - @invoked = nil - @content_length = nil - @closed = false - @size = 0 - end - - def response - ## It takes exactly one argument, the *environment* - raise LintError, "No env given" unless @env - check_environment(@env) - - ## and returns a non-frozen Array of exactly three values: - @response = @app.call(@env) - raise LintError, "response is not an Array, but #{@response.class}" unless @response.kind_of? Array - raise LintError, "response is frozen" if @response.frozen? - raise LintError, "response array has #{@response.size} elements instead of 3" unless @response.size == 3 - - @status, @headers, @body = @response - ## The *status*, - check_status(@status) - - ## the *headers*, - check_headers(@headers) - - hijack_proc = check_hijack_response(@headers, @env) - if hijack_proc - @headers[RACK_HIJACK] = hijack_proc - end - - ## and the *body*. - check_content_type_header(@status, @headers) - check_content_length_header(@status, @headers) - check_rack_protocol_header(@status, @headers) - @head_request = @env[REQUEST_METHOD] == HEAD - - @lint = (@env['rack.lint'] ||= []) << self - - if (@env['rack.lint.body_iteration'] ||= 0) > 0 - raise LintError, "Middleware must not call #each directly" - end - - return [@status, @headers, self] - end - - ## - ## == The Environment - ## - def check_environment(env) - ## The environment must be an unfrozen instance of Hash that includes - ## CGI-like headers. The Rack application is free to modify the - ## environment. - raise LintError, "env #{env.inspect} is not a Hash, but #{env.class}" unless env.kind_of? Hash - raise LintError, "env should not be frozen, but is" if env.frozen? - - ## - ## The environment is required to include these variables - ## (adopted from {PEP 333}[https://peps.python.org/pep-0333/]), except when they'd be empty, but see - ## below. - - ## REQUEST_METHOD:: The HTTP request method, such as - ## "GET" or "POST". This cannot ever - ## be an empty string, and so is - ## always required. - - ## SCRIPT_NAME:: The initial portion of the request - ## URL's "path" that corresponds to the - ## application object, so that the - ## application knows its virtual - ## "location". This may be an empty - ## string, if the application corresponds - ## to the "root" of the server. - - ## PATH_INFO:: The remainder of the request URL's - ## "path", designating the virtual - ## "location" of the request's target - ## within the application. This may be an - ## empty string, if the request URL targets - ## the application root and does not have a - ## trailing slash. This value may be - ## percent-encoded when originating from - ## a URL. - - ## QUERY_STRING:: The portion of the request URL that - ## follows the ?, if any. May be - ## empty, but is always required! - - ## SERVER_NAME:: When combined with SCRIPT_NAME and - ## PATH_INFO, these variables can be - ## used to complete the URL. Note, however, - ## that HTTP_HOST, if present, - ## should be used in preference to - ## SERVER_NAME for reconstructing - ## the request URL. - ## SERVER_NAME can never be an empty - ## string, and so is always required. - - ## SERVER_PORT:: An optional +Integer+ which is the port the - ## server is running on. Should be specified if - ## the server is running on a non-standard port. - - ## SERVER_PROTOCOL:: A string representing the HTTP version used - ## for the request. - - ## HTTP_ Variables:: Variables corresponding to the - ## client-supplied HTTP request - ## headers (i.e., variables whose - ## names begin with HTTP_). The - ## presence or absence of these - ## variables should correspond with - ## the presence or absence of the - ## appropriate HTTP header in the - ## request. See - ## {RFC3875 section 4.1.18}[https://tools.ietf.org/html/rfc3875#section-4.1.18] - ## for specific behavior. - - ## In addition to this, the Rack environment must include these - ## Rack-specific variables: - - ## rack.url_scheme:: +http+ or +https+, depending on the - ## request URL. - - ## rack.input:: See below, the input stream. - - ## rack.errors:: See below, the error stream. - - ## rack.hijack?:: See below, if present and true, indicates - ## that the server supports partial hijacking. - - ## rack.hijack:: See below, if present, an object responding - ## to +call+ that is used to perform a full - ## hijack. - - ## rack.protocol:: An optional +Array+ of +String+, containing - ## the protocols advertised by the client in - ## the +upgrade+ header (HTTP/1) or the - ## +:protocol+ pseudo-header (HTTP/2). - if protocols = @env['rack.protocol'] - unless protocols.is_a?(Array) && protocols.all?{|protocol| protocol.is_a?(String)} - raise LintError, "rack.protocol must be an Array of Strings" - end - end - - ## Additional environment specifications have approved to - ## standardized middleware APIs. None of these are required to - ## be implemented by the server. - - ## rack.session:: A hash-like interface for storing - ## request session data. - ## The store must implement: - if session = env[RACK_SESSION] - ## store(key, value) (aliased as []=); - unless session.respond_to?(:store) && session.respond_to?(:[]=) - raise LintError, "session #{session.inspect} must respond to store and []=" - end - - ## fetch(key, default = nil) (aliased as []); - unless session.respond_to?(:fetch) && session.respond_to?(:[]) - raise LintError, "session #{session.inspect} must respond to fetch and []" - end - - ## delete(key); - unless session.respond_to?(:delete) - raise LintError, "session #{session.inspect} must respond to delete" - end - - ## clear; - unless session.respond_to?(:clear) - raise LintError, "session #{session.inspect} must respond to clear" - end - - ## to_hash (returning unfrozen Hash instance); - unless session.respond_to?(:to_hash) && session.to_hash.kind_of?(Hash) && !session.to_hash.frozen? - raise LintError, "session #{session.inspect} must respond to to_hash and return unfrozen Hash instance" - end - end - - ## rack.logger:: A common object interface for logging messages. - ## The object must implement: - if logger = env[RACK_LOGGER] - ## info(message, &block) - unless logger.respond_to?(:info) - raise LintError, "logger #{logger.inspect} must respond to info" - end - - ## debug(message, &block) - unless logger.respond_to?(:debug) - raise LintError, "logger #{logger.inspect} must respond to debug" - end - - ## warn(message, &block) - unless logger.respond_to?(:warn) - raise LintError, "logger #{logger.inspect} must respond to warn" - end - - ## error(message, &block) - unless logger.respond_to?(:error) - raise LintError, "logger #{logger.inspect} must respond to error" - end - - ## fatal(message, &block) - unless logger.respond_to?(:fatal) - raise LintError, "logger #{logger.inspect} must respond to fatal" - end - end - - ## rack.multipart.buffer_size:: An Integer hint to the multipart parser as to what chunk size to use for reads and writes. - if bufsize = env[RACK_MULTIPART_BUFFER_SIZE] - unless bufsize.is_a?(Integer) && bufsize > 0 - raise LintError, "rack.multipart.buffer_size must be an Integer > 0 if specified" - end - end - - ## rack.multipart.tempfile_factory:: An object responding to #call with two arguments, the filename and content_type given for the multipart form field, and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate the tempfile for each multipart form file upload field, rather than the default class of Tempfile. - if tempfile_factory = env[RACK_MULTIPART_TEMPFILE_FACTORY] - raise LintError, "rack.multipart.tempfile_factory must respond to #call" unless tempfile_factory.respond_to?(:call) - env[RACK_MULTIPART_TEMPFILE_FACTORY] = lambda do |filename, content_type| - io = tempfile_factory.call(filename, content_type) - raise LintError, "rack.multipart.tempfile_factory return value must respond to #<<" unless io.respond_to?(:<<) - io - end - end - - ## The server or the application can store their own data in the - ## environment, too. The keys must contain at least one dot, - ## and should be prefixed uniquely. The prefix rack. - ## is reserved for use with the Rack core distribution and other - ## accepted specifications and must not be used otherwise. - ## - %w[REQUEST_METHOD SERVER_NAME QUERY_STRING SERVER_PROTOCOL rack.errors].each do |header| - raise LintError, "env missing required key #{header}" unless env.include? header - end - - ## The SERVER_PORT must be an Integer if set. - server_port = env["SERVER_PORT"] - unless server_port.nil? || (Integer(server_port) rescue false) - raise LintError, "env[SERVER_PORT] is not an Integer" - end - - ## The SERVER_NAME must be a valid authority as defined by RFC7540. - unless (URI.parse("http://#{env[SERVER_NAME]}/") rescue false) - raise LintError, "#{env[SERVER_NAME]} must be a valid authority" - end - - ## The HTTP_HOST must be a valid authority as defined by RFC7540. - unless (URI.parse("http://#{env[HTTP_HOST]}/") rescue false) - raise LintError, "#{env[HTTP_HOST]} must be a valid authority" - end - - ## The SERVER_PROTOCOL must match the regexp HTTP/\d(\.\d)?. - server_protocol = env['SERVER_PROTOCOL'] - unless %r{HTTP/\d(\.\d)?}.match?(server_protocol) - raise LintError, "env[SERVER_PROTOCOL] does not match HTTP/\\d(\\.\\d)?" - end - - ## The environment must not contain the keys - ## HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH - ## (use the versions without HTTP_). - %w[HTTP_CONTENT_TYPE HTTP_CONTENT_LENGTH].each { |header| - if env.include? header - raise LintError, "env contains #{header}, must use #{header[5..-1]}" - end - } - - ## The CGI keys (named without a period) must have String values. - ## If the string values for CGI keys contain non-ASCII characters, - ## they should use ASCII-8BIT encoding. - env.each { |key, value| - next if key.include? "." # Skip extensions - unless value.kind_of? String - raise LintError, "env variable #{key} has non-string value #{value.inspect}" - end - next if value.encoding == Encoding::ASCII_8BIT - unless value.b !~ /[\x80-\xff]/n - raise LintError, "env variable #{key} has value containing non-ASCII characters and has non-ASCII-8BIT encoding #{value.inspect} encoding: #{value.encoding}" - end - } - - ## There are the following restrictions: - - ## * rack.url_scheme must either be +http+ or +https+. - unless %w[http https].include?(env[RACK_URL_SCHEME]) - raise LintError, "rack.url_scheme unknown: #{env[RACK_URL_SCHEME].inspect}" - end - - ## * There may be a valid input stream in rack.input. - if rack_input = env[RACK_INPUT] - check_input_stream(rack_input) - @env[RACK_INPUT] = InputWrapper.new(rack_input) - end - - ## * There must be a valid error stream in rack.errors. - rack_errors = env[RACK_ERRORS] - check_error_stream(rack_errors) - @env[RACK_ERRORS] = ErrorWrapper.new(rack_errors) - - ## * There may be a valid hijack callback in rack.hijack - check_hijack env - ## * There may be a valid early hints callback in rack.early_hints - check_early_hints env - - ## * The REQUEST_METHOD must be a valid token. - unless env[REQUEST_METHOD] =~ /\A[0-9A-Za-z!\#$%&'*+.^_`|~-]+\z/ - raise LintError, "REQUEST_METHOD unknown: #{env[REQUEST_METHOD].dump}" - end - - ## * The SCRIPT_NAME, if non-empty, must start with / - if env.include?(SCRIPT_NAME) && env[SCRIPT_NAME] != "" && env[SCRIPT_NAME] !~ /\A\// - raise LintError, "SCRIPT_NAME must start with /" - end - - ## * The PATH_INFO, if provided, must be a valid request target or an empty string. - if env.include?(PATH_INFO) - case env[PATH_INFO] - when REQUEST_PATH_ASTERISK_FORM - ## * Only OPTIONS requests may have PATH_INFO set to * (asterisk-form). - unless env[REQUEST_METHOD] == OPTIONS - raise LintError, "Only OPTIONS requests may have PATH_INFO set to '*' (asterisk-form)" - end - when REQUEST_PATH_AUTHORITY_FORM - ## * Only CONNECT requests may have PATH_INFO set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. - unless env[REQUEST_METHOD] == CONNECT - raise LintError, "Only CONNECT requests may have PATH_INFO set to an authority (authority-form)" - end - when REQUEST_PATH_ABSOLUTE_FORM - ## * CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form). - if env[REQUEST_METHOD] == CONNECT || env[REQUEST_METHOD] == OPTIONS - raise LintError, "CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form)" - end - when REQUEST_PATH_ORIGIN_FORM - ## * Otherwise, PATH_INFO must start with a / and must not include a fragment part starting with '#' (origin-form). - when "" - # Empty string is okay. - else - raise LintError, "PATH_INFO must start with a '/' and must not include a fragment part starting with '#' (origin-form)" - end - end - - ## * The CONTENT_LENGTH, if given, must consist of digits only. - if env.include?("CONTENT_LENGTH") && env["CONTENT_LENGTH"] !~ /\A\d+\z/ - raise LintError, "Invalid CONTENT_LENGTH: #{env["CONTENT_LENGTH"]}" - end - - ## * One of SCRIPT_NAME or PATH_INFO must be - ## set. PATH_INFO should be / if - ## SCRIPT_NAME is empty. - unless env[SCRIPT_NAME] || env[PATH_INFO] - raise LintError, "One of SCRIPT_NAME or PATH_INFO must be set (make PATH_INFO '/' if SCRIPT_NAME is empty)" - end - ## SCRIPT_NAME never should be /, but instead be empty. - unless env[SCRIPT_NAME] != "/" - raise LintError, "SCRIPT_NAME cannot be '/', make it '' and PATH_INFO '/'" - end - - ## rack.response_finished:: An array of callables run by the server after the response has been - ## processed. This would typically be invoked after sending the response to the client, but it could also be - ## invoked if an error occurs while generating the response or sending the response; in that case, the error - ## argument will be a subclass of +Exception+. - ## The callables are invoked with +env, status, headers, error+ arguments and should not raise any - ## exceptions. They should be invoked in reverse order of registration. - if callables = env[RACK_RESPONSE_FINISHED] - raise LintError, "rack.response_finished must be an array of callable objects" unless callables.is_a?(Array) - - callables.each do |callable| - raise LintError, "rack.response_finished values must respond to call(env, status, headers, error)" unless callable.respond_to?(:call) - end - end - end - - ## - ## === The Input Stream - ## - ## The input stream is an IO-like object which contains the raw HTTP - ## POST data. - def check_input_stream(input) - ## When applicable, its external encoding must be "ASCII-8BIT" and it - ## must be opened in binary mode. - if input.respond_to?(:external_encoding) && input.external_encoding != Encoding::ASCII_8BIT - raise LintError, "rack.input #{input} does not have ASCII-8BIT as its external encoding" - end - if input.respond_to?(:binmode?) && !input.binmode? - raise LintError, "rack.input #{input} is not opened in binary mode" - end - - ## The input stream must respond to +gets+, +each+, and +read+. - [:gets, :each, :read].each { |method| - unless input.respond_to? method - raise LintError, "rack.input #{input} does not respond to ##{method}" - end - } - end - - class InputWrapper - def initialize(input) - @input = input - end - - ## * +gets+ must be called without arguments and return a string, - ## or +nil+ on EOF. - def gets(*args) - raise LintError, "rack.input#gets called with arguments" unless args.size == 0 - v = @input.gets - unless v.nil? or v.kind_of? String - raise LintError, "rack.input#gets didn't return a String" - end - v - end - - ## * +read+ behaves like IO#read. - ## Its signature is read([length, [buffer]]). - ## - ## If given, +length+ must be a non-negative Integer (>= 0) or +nil+, - ## and +buffer+ must be a String and may not be nil. - ## - ## If +length+ is given and not nil, then this method reads at most - ## +length+ bytes from the input stream. - ## - ## If +length+ is not given or nil, then this method reads - ## all data until EOF. - ## - ## When EOF is reached, this method returns nil if +length+ is given - ## and not nil, or "" if +length+ is not given or is nil. - ## - ## If +buffer+ is given, then the read data will be placed - ## into +buffer+ instead of a newly created String object. - def read(*args) - unless args.size <= 2 - raise LintError, "rack.input#read called with too many arguments" - end - if args.size >= 1 - unless args.first.kind_of?(Integer) || args.first.nil? - raise LintError, "rack.input#read called with non-integer and non-nil length" - end - unless args.first.nil? || args.first >= 0 - raise LintError, "rack.input#read called with a negative length" - end - end - if args.size >= 2 - unless args[1].kind_of?(String) - raise LintError, "rack.input#read called with non-String buffer" - end - end - - v = @input.read(*args) - - unless v.nil? or v.kind_of? String - raise LintError, "rack.input#read didn't return nil or a String" - end - if args[0].nil? - unless !v.nil? - raise LintError, "rack.input#read(nil) returned nil on EOF" - end - end - - v - end - - ## * +each+ must be called without arguments and only yield Strings. - def each(*args) - raise LintError, "rack.input#each called with arguments" unless args.size == 0 - @input.each { |line| - unless line.kind_of? String - raise LintError, "rack.input#each didn't yield a String" - end - yield line - } - end - - ## * +close+ can be called on the input stream to indicate that - ## any remaining input is not needed. - def close(*args) - @input.close(*args) - end - end - - ## - ## === The Error Stream - ## - def check_error_stream(error) - ## The error stream must respond to +puts+, +write+ and +flush+. - [:puts, :write, :flush].each { |method| - unless error.respond_to? method - raise LintError, "rack.error #{error} does not respond to ##{method}" - end - } - end - - class ErrorWrapper - def initialize(error) - @error = error - end - - ## * +puts+ must be called with a single argument that responds to +to_s+. - def puts(str) - @error.puts str - end - - ## * +write+ must be called with a single argument that is a String. - def write(str) - raise LintError, "rack.errors#write not called with a String" unless str.kind_of? String - @error.write str - end - - ## * +flush+ must be called without arguments and must be called - ## in order to make the error appear for sure. - def flush - @error.flush - end - - ## * +close+ must never be called on the error stream. - def close(*args) - raise LintError, "rack.errors#close must not be called" - end - end - - ## - ## === Hijacking - ## - ## The hijacking interfaces provides a means for an application to take - ## control of the HTTP connection. There are two distinct hijack - ## interfaces: full hijacking where the application takes over the raw - ## connection, and partial hijacking where the application takes over - ## just the response body stream. In both cases, the application is - ## responsible for closing the hijacked stream. - ## - ## Full hijacking only works with HTTP/1. Partial hijacking is functionally - ## equivalent to streaming bodies, and is still optionally supported for - ## backwards compatibility with older Rack versions. - ## - ## ==== Full Hijack - ## - ## Full hijack is used to completely take over an HTTP/1 connection. It - ## occurs before any headers are written and causes the request to - ## ignores any response generated by the application. - ## - ## It is intended to be used when applications need access to raw HTTP/1 - ## connection. - ## - def check_hijack(env) - ## If +rack.hijack+ is present in +env+, it must respond to +call+ - if original_hijack = env[RACK_HIJACK] - raise LintError, "rack.hijack must respond to call" unless original_hijack.respond_to?(:call) - - env[RACK_HIJACK] = proc do - io = original_hijack.call - - ## and return an +IO+ instance which can be used to read and write - ## to the underlying connection using HTTP/1 semantics and - ## formatting. - raise LintError, "rack.hijack must return an IO instance" unless io.is_a?(IO) - - io - end - end - end - - ## - ## ==== Partial Hijack - ## - ## Partial hijack is used for bi-directional streaming of the request and - ## response body. It occurs after the status and headers are written by - ## the server and causes the server to ignore the Body of the response. - ## - ## It is intended to be used when applications need bi-directional - ## streaming. - ## - def check_hijack_response(headers, env) - ## If +rack.hijack?+ is present in +env+ and truthy, - if env[RACK_IS_HIJACK] - ## an application may set the special response header +rack.hijack+ - if original_hijack = headers[RACK_HIJACK] - ## to an object that responds to +call+, - unless original_hijack.respond_to?(:call) - raise LintError, 'rack.hijack header must respond to #call' - end - ## accepting a +stream+ argument. - return proc do |io| - original_hijack.call StreamWrapper.new(io) - end - end - ## - ## After the response status and headers have been sent, this hijack - ## callback will be invoked with a +stream+ argument which follows the - ## same interface as outlined in "Streaming Body". Servers must - ## ignore the +body+ part of the response tuple when the - ## +rack.hijack+ response header is present. Using an empty +Array+ - ## instance is recommended. - else - ## - ## The special response header +rack.hijack+ must only be set - ## if the request +env+ has a truthy +rack.hijack?+. - if headers.key?(RACK_HIJACK) - raise LintError, 'rack.hijack header must not be present if server does not support hijacking' - end - end - - nil - end - - ## - ## === Early Hints - ## - ## The application or any middleware may call the rack.early_hints - ## with an object which would be valid as the headers of a Rack response. - def check_early_hints(env) - if env[RACK_EARLY_HINTS] - ## - ## If rack.early_hints is present, it must respond to #call. - unless env[RACK_EARLY_HINTS].respond_to?(:call) - raise LintError, "rack.early_hints must respond to call" - end - - original_callback = env[RACK_EARLY_HINTS] - env[RACK_EARLY_HINTS] = lambda do |headers| - ## If rack.early_hints is called, it must be called with - ## valid Rack response headers. - check_headers(headers) - original_callback.call(headers) - end - end - end - - ## - ## == The Response - ## - ## === The Status - ## - def check_status(status) - ## This is an HTTP status. It must be an Integer greater than or equal to - ## 100. - unless status.is_a?(Integer) && status >= 100 - raise LintError, "Status must be an Integer >=100" - end - end - - ## - ## === The Headers - ## - def check_headers(headers) - ## The headers must be a unfrozen Hash. - unless headers.kind_of?(Hash) - raise LintError, "headers object should be a hash, but isn't (got #{headers.class} as headers)" - end - - if headers.frozen? - raise LintError, "headers object should not be frozen, but is" - end - - headers.each do |key, value| - ## The header keys must be Strings. - unless key.kind_of? String - raise LintError, "header key must be a string, was #{key.class}" - end - - ## Special headers starting "rack." are for communicating with the - ## server, and must not be sent back to the client. - next if key.start_with?("rack.") - - ## The header must not contain a +Status+ key. - raise LintError, "header must not contain status" if key == "status" - ## Header keys must conform to RFC7230 token specification, i.e. cannot - ## contain non-printable ASCII, DQUOTE or "(),/:;<=>?@[\]{}". - raise LintError, "invalid header name: #{key}" if key =~ /[\(\),\/:;<=>\?@\[\\\]{}[:cntrl:]]/ - ## Header keys must not contain uppercase ASCII characters (A-Z). - raise LintError, "uppercase character in header name: #{key}" if key =~ /[A-Z]/ - - ## Header values must be either a String instance, - if value.kind_of?(String) - check_header_value(key, value) - elsif value.kind_of?(Array) - ## or an Array of String instances, - value.each{|value| check_header_value(key, value)} - else - raise LintError, "a header value must be a String or Array of Strings, but the value of '#{key}' is a #{value.class}" - end - end - end - - def check_header_value(key, value) - ## such that each String instance must not contain characters below 037. - if value =~ /[\000-\037]/ - raise LintError, "invalid header value #{key}: #{value.inspect}" - end - end - - ## - ## ==== The +content-type+ Header - ## - def check_content_type_header(status, headers) - headers.each { |key, value| - ## There must not be a content-type header key when the +Status+ is 1xx, - ## 204, or 304. - if key == "content-type" - if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i - raise LintError, "content-type header found in #{status} response, not allowed" - end - return - end - } - end - - ## - ## ==== The +content-length+ Header - ## - def check_content_length_header(status, headers) - headers.each { |key, value| - if key == 'content-length' - ## There must not be a content-length header key when the - ## +Status+ is 1xx, 204, or 304. - if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i - raise LintError, "content-length header found in #{status} response, not allowed" - end - @content_length = value - end - } - end - - def verify_content_length(size) - if @head_request - unless size == 0 - raise LintError, "Response body was given for HEAD request, but should be empty" - end - elsif @content_length - unless @content_length == size.to_s - raise LintError, "content-length header was #{@content_length}, but should be #{size}" - end - end - end - - ## - ## ==== The +rack.protocol+ Header - ## - def check_rack_protocol_header(status, headers) - ## If the +rack.protocol+ header is present, it must be a +String+, and - ## must be one of the values from the +rack.protocol+ array from the - ## environment. - protocol = headers['rack.protocol'] - - if protocol - request_protocols = @env['rack.protocol'] - - if request_protocols.nil? - raise LintError, "rack.protocol header is #{protocol.inspect}, but rack.protocol was not set in request!" - elsif !request_protocols.include?(protocol) - raise LintError, "rack.protocol header is #{protocol.inspect}, but should be one of #{request_protocols.inspect} from the request!" - end - end - end - ## - ## Setting this value informs the server that it should perform a - ## connection upgrade. In HTTP/1, this is done using the +upgrade+ - ## header. In HTTP/2, this is done by accepting the request. - ## - ## === The Body - ## - ## The Body is typically an +Array+ of +String+ instances, an enumerable - ## that yields +String+ instances, a +Proc+ instance, or a File-like - ## object. - ## - ## The Body must respond to +each+ or +call+. It may optionally respond - ## to +to_path+ or +to_ary+. A Body that responds to +each+ is considered - ## to be an Enumerable Body. A Body that responds to +call+ is considered - ## to be a Streaming Body. - ## - ## A Body that responds to both +each+ and +call+ must be treated as an - ## Enumerable Body, not a Streaming Body. If it responds to +each+, you - ## must call +each+ and not +call+. If the Body doesn't respond to - ## +each+, then you can assume it responds to +call+. - ## - ## The Body must either be consumed or returned. The Body is consumed by - ## optionally calling either +each+ or +call+. - ## Then, if the Body responds to +close+, it must be called to release - ## any resources associated with the generation of the body. - ## In other words, +close+ must always be called at least once; typically - ## after the web server has sent the response to the client, but also in - ## cases where the Rack application makes internal/virtual requests and - ## discards the response. - ## - def close - ## - ## After calling +close+, the Body is considered closed and should not - ## be consumed again. - @closed = true - - ## If the original Body is replaced by a new Body, the new Body must - ## also consume the original Body by calling +close+ if possible. - @body.close if @body.respond_to?(:close) - - index = @lint.index(self) - unless @env['rack.lint'][0..index].all? {|lint| lint.instance_variable_get(:@closed)} - raise LintError, "Body has not been closed" - end - end - - def verify_to_path - ## - ## If the Body responds to +to_path+, it must return a +String+ - ## path for the local file system whose contents are identical - ## to that produced by calling +each+; this may be used by the - ## server as an alternative, possibly more efficient way to - ## transport the response. The +to_path+ method does not consume - ## the body. - if @body.respond_to?(:to_path) - unless ::File.exist? @body.to_path - raise LintError, "The file identified by body.to_path does not exist" - end - end - end - - ## - ## ==== Enumerable Body - ## - def each - ## The Enumerable Body must respond to +each+. - raise LintError, "Enumerable Body must respond to each" unless @body.respond_to?(:each) - - ## It must only be called once. - raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? - - ## It must not be called after being closed, - raise LintError, "Response body is already closed" if @closed - - @invoked = :each - - @body.each do |chunk| - ## and must only yield String values. - unless chunk.kind_of? String - raise LintError, "Body yielded non-string value #{chunk.inspect}" - end - - ## - ## Middleware must not call +each+ directly on the Body. - ## Instead, middleware can return a new Body that calls +each+ on the - ## original Body, yielding at least once per iteration. - if @lint[0] == self - @env['rack.lint.body_iteration'] += 1 - else - if (@env['rack.lint.body_iteration'] -= 1) > 0 - raise LintError, "New body must yield at least once per iteration of old body" - end - end - - @size += chunk.bytesize - yield chunk - end - - verify_content_length(@size) - - verify_to_path - end - - BODY_METHODS = {to_ary: true, each: true, call: true, to_path: true} - - def to_path - @body.to_path - end - - def respond_to?(name, *) - if BODY_METHODS.key?(name) - @body.respond_to?(name) - else - super - end - end - - ## - ## If the Body responds to +to_ary+, it must return an +Array+ whose - ## contents are identical to that produced by calling +each+. - ## Middleware may call +to_ary+ directly on the Body and return a new - ## Body in its place. In other words, middleware can only process the - ## Body directly if it responds to +to_ary+. If the Body responds to both - ## +to_ary+ and +close+, its implementation of +to_ary+ must call - ## +close+. - def to_ary - @body.to_ary.tap do |content| - unless content == @body.enum_for.to_a - raise LintError, "#to_ary not identical to contents produced by calling #each" - end - end - ensure - close - end - - ## - ## ==== Streaming Body - ## - def call(stream) - ## The Streaming Body must respond to +call+. - raise LintError, "Streaming Body must respond to call" unless @body.respond_to?(:call) - - ## It must only be called once. - raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? - - ## It must not be called after being closed. - raise LintError, "Response body is already closed" if @closed - - @invoked = :call - - ## It takes a +stream+ argument. - ## - ## The +stream+ argument must implement: - ## read, write, <<, flush, close, close_read, close_write, closed? - ## - @body.call(StreamWrapper.new(stream)) - end - - class StreamWrapper - extend Forwardable - - ## The semantics of these IO methods must be a best effort match to - ## those of a normal Ruby IO or Socket object, using standard arguments - ## and raising standard exceptions. Servers are encouraged to simply - ## pass on real IO objects, although it is recognized that this approach - ## is not directly compatible with HTTP/2. - REQUIRED_METHODS = [ - :read, :write, :<<, :flush, :close, - :close_read, :close_write, :closed? - ] - - def_delegators :@stream, *REQUIRED_METHODS - - def initialize(stream) - @stream = stream - - REQUIRED_METHODS.each do |method_name| - raise LintError, "Stream must respond to #{method_name}" unless stream.respond_to?(method_name) - end - end - end - - # :startdoc: - end - end -end - -## -## == Thanks -## Some parts of this specification are adopted from {PEP 333 – Python Web Server Gateway Interface v1.0}[https://peps.python.org/pep-0333/] -## I'd like to thank everyone involved in that effort. diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lock.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lock.rb deleted file mode 100644 index 342123a..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/lock.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require_relative 'body_proxy' - -module Rack - # Rack::Lock locks every request inside a mutex, so that every request - # will effectively be executed synchronously. - class Lock - def initialize(app, mutex = Mutex.new) - @app, @mutex = app, mutex - end - - def call(env) - @mutex.lock - begin - response = @app.call(env) - returned = response << BodyProxy.new(response.pop) { unlock } - ensure - unlock unless returned - end - end - - private - - def unlock - @mutex.unlock - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/logger.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/logger.rb deleted file mode 100644 index 081212d..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/logger.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'logger' -require_relative 'constants' - -warn "Rack::Logger is deprecated and will be removed in Rack 3.2.", uplevel: 1 - -module Rack - # Sets up rack.logger to write to rack.errors stream - class Logger - def initialize(app, level = ::Logger::INFO) - @app, @level = app, level - end - - def call(env) - logger = ::Logger.new(env[RACK_ERRORS]) - logger.level = @level - - env[RACK_LOGGER] = logger - @app.call(env) - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/media_type.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/media_type.rb deleted file mode 100644 index 7fc1e39..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/media_type.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Rack::MediaType parse media type and parameters out of content_type string - - class MediaType - SPLIT_PATTERN = /[;,]/ - - class << self - # The media type (type/subtype) portion of the CONTENT_TYPE header - # without any media type parameters. e.g., when CONTENT_TYPE is - # "text/plain;charset=utf-8", the media-type is "text/plain". - # - # For more information on the use of media types in HTTP, see: - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 - def type(content_type) - return nil unless content_type - if type = content_type.split(SPLIT_PATTERN, 2).first - type.rstrip! - type.downcase! - type - end - end - - # The media type parameters provided in CONTENT_TYPE as a Hash, or - # an empty Hash if no CONTENT_TYPE or media-type parameters were - # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", - # this method responds with the following Hash: - # { 'charset' => 'utf-8' } - def params(content_type) - return {} if content_type.nil? - - content_type.split(SPLIT_PATTERN)[1..-1].each_with_object({}) do |s, hsh| - s.strip! - k, v = s.split('=', 2) - k.downcase! - hsh[k] = strip_doublequotes(v) - end - end - - private - - def strip_doublequotes(str) - (str.start_with?('"') && str.end_with?('"')) ? str[1..-2] : str - end - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/method_override.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/method_override.rb deleted file mode 100644 index 6125b19..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/method_override.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'request' -require_relative 'utils' - -module Rack - class MethodOverride - HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK] - - METHOD_OVERRIDE_PARAM_KEY = "_method" - HTTP_METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE" - ALLOWED_METHODS = %w[POST] - - def initialize(app) - @app = app - end - - def call(env) - if allowed_methods.include?(env[REQUEST_METHOD]) - method = method_override(env) - if HTTP_METHODS.include?(method) - env[RACK_METHODOVERRIDE_ORIGINAL_METHOD] = env[REQUEST_METHOD] - env[REQUEST_METHOD] = method - end - end - - @app.call(env) - end - - def method_override(env) - req = Request.new(env) - method = method_override_param(req) || - env[HTTP_METHOD_OVERRIDE_HEADER] - begin - method.to_s.upcase - rescue ArgumentError - env[RACK_ERRORS].puts "Invalid string for method" - end - end - - private - - def allowed_methods - ALLOWED_METHODS - end - - def method_override_param(req) - req.POST[METHOD_OVERRIDE_PARAM_KEY] if req.form_data? || req.parseable_data? - rescue Utils::InvalidParameterError, Utils::ParameterTypeError, QueryParser::ParamsTooDeepError - req.get_header(RACK_ERRORS).puts "Invalid or incomplete POST params" - rescue EOFError - req.get_header(RACK_ERRORS).puts "Bad request content body" - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mime.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mime.rb deleted file mode 100644 index 0272968..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mime.rb +++ /dev/null @@ -1,694 +0,0 @@ -# frozen_string_literal: true - -module Rack - module Mime - # Returns String with mime type if found, otherwise use +fallback+. - # +ext+ should be filename extension in the '.ext' format that - # File.extname(file) returns. - # +fallback+ may be any object - # - # Also see the documentation for MIME_TYPES - # - # Usage: - # Rack::Mime.mime_type('.foo') - # - # This is a shortcut for: - # Rack::Mime::MIME_TYPES.fetch('.foo', 'application/octet-stream') - - def mime_type(ext, fallback = 'application/octet-stream') - MIME_TYPES.fetch(ext.to_s.downcase, fallback) - end - module_function :mime_type - - # Returns true if the given value is a mime match for the given mime match - # specification, false otherwise. - # - # Rack::Mime.match?('text/html', 'text/*') => true - # Rack::Mime.match?('text/plain', '*') => true - # Rack::Mime.match?('text/html', 'application/json') => false - - def match?(value, matcher) - v1, v2 = value.split('/', 2) - m1, m2 = matcher.split('/', 2) - - (m1 == '*' || v1 == m1) && (m2.nil? || m2 == '*' || m2 == v2) - end - module_function :match? - - # List of most common mime-types, selected various sources - # according to their usefulness in a webserving scope for Ruby - # users. - # - # To amend this list with your local mime.types list you can use: - # - # require 'webrick/httputils' - # list = WEBrick::HTTPUtils.load_mime_types('/etc/mime.types') - # Rack::Mime::MIME_TYPES.merge!(list) - # - # N.B. On Ubuntu the mime.types file does not include the leading period, so - # users may need to modify the data before merging into the hash. - - MIME_TYPES = { - ".123" => "application/vnd.lotus-1-2-3", - ".3dml" => "text/vnd.in3d.3dml", - ".3g2" => "video/3gpp2", - ".3gp" => "video/3gpp", - ".a" => "application/octet-stream", - ".acc" => "application/vnd.americandynamics.acc", - ".ace" => "application/x-ace-compressed", - ".acu" => "application/vnd.acucobol", - ".aep" => "application/vnd.audiograph", - ".afp" => "application/vnd.ibm.modcap", - ".ai" => "application/postscript", - ".aif" => "audio/x-aiff", - ".aiff" => "audio/x-aiff", - ".ami" => "application/vnd.amiga.ami", - ".apng" => "image/apng", - ".appcache" => "text/cache-manifest", - ".apr" => "application/vnd.lotus-approach", - ".asc" => "application/pgp-signature", - ".asf" => "video/x-ms-asf", - ".asm" => "text/x-asm", - ".aso" => "application/vnd.accpac.simply.aso", - ".asx" => "video/x-ms-asf", - ".atc" => "application/vnd.acucorp", - ".atom" => "application/atom+xml", - ".atomcat" => "application/atomcat+xml", - ".atomsvc" => "application/atomsvc+xml", - ".atx" => "application/vnd.antix.game-component", - ".au" => "audio/basic", - ".avi" => "video/x-msvideo", - ".avif" => "image/avif", - ".bat" => "application/x-msdownload", - ".bcpio" => "application/x-bcpio", - ".bdm" => "application/vnd.syncml.dm+wbxml", - ".bh2" => "application/vnd.fujitsu.oasysprs", - ".bin" => "application/octet-stream", - ".bmi" => "application/vnd.bmi", - ".bmp" => "image/bmp", - ".box" => "application/vnd.previewsystems.box", - ".btif" => "image/prs.btif", - ".bz" => "application/x-bzip", - ".bz2" => "application/x-bzip2", - ".c" => "text/x-c", - ".c4g" => "application/vnd.clonk.c4group", - ".cab" => "application/vnd.ms-cab-compressed", - ".cc" => "text/x-c", - ".ccxml" => "application/ccxml+xml", - ".cdbcmsg" => "application/vnd.contact.cmsg", - ".cdkey" => "application/vnd.mediastation.cdkey", - ".cdx" => "chemical/x-cdx", - ".cdxml" => "application/vnd.chemdraw+xml", - ".cdy" => "application/vnd.cinderella", - ".cer" => "application/pkix-cert", - ".cgm" => "image/cgm", - ".chat" => "application/x-chat", - ".chm" => "application/vnd.ms-htmlhelp", - ".chrt" => "application/vnd.kde.kchart", - ".cif" => "chemical/x-cif", - ".cii" => "application/vnd.anser-web-certificate-issue-initiation", - ".cil" => "application/vnd.ms-artgalry", - ".cla" => "application/vnd.claymore", - ".class" => "application/octet-stream", - ".clkk" => "application/vnd.crick.clicker.keyboard", - ".clkp" => "application/vnd.crick.clicker.palette", - ".clkt" => "application/vnd.crick.clicker.template", - ".clkw" => "application/vnd.crick.clicker.wordbank", - ".clkx" => "application/vnd.crick.clicker", - ".clp" => "application/x-msclip", - ".cmc" => "application/vnd.cosmocaller", - ".cmdf" => "chemical/x-cmdf", - ".cml" => "chemical/x-cml", - ".cmp" => "application/vnd.yellowriver-custom-menu", - ".cmx" => "image/x-cmx", - ".com" => "application/x-msdownload", - ".conf" => "text/plain", - ".cpio" => "application/x-cpio", - ".cpp" => "text/x-c", - ".cpt" => "application/mac-compactpro", - ".crd" => "application/x-mscardfile", - ".crl" => "application/pkix-crl", - ".crt" => "application/x-x509-ca-cert", - ".csh" => "application/x-csh", - ".csml" => "chemical/x-csml", - ".csp" => "application/vnd.commonspace", - ".css" => "text/css", - ".csv" => "text/csv", - ".curl" => "application/vnd.curl", - ".cww" => "application/prs.cww", - ".cxx" => "text/x-c", - ".daf" => "application/vnd.mobius.daf", - ".davmount" => "application/davmount+xml", - ".dcr" => "application/x-director", - ".dd2" => "application/vnd.oma.dd2+xml", - ".ddd" => "application/vnd.fujixerox.ddd", - ".deb" => "application/x-debian-package", - ".der" => "application/x-x509-ca-cert", - ".dfac" => "application/vnd.dreamfactory", - ".diff" => "text/x-diff", - ".dis" => "application/vnd.mobius.dis", - ".djv" => "image/vnd.djvu", - ".djvu" => "image/vnd.djvu", - ".dll" => "application/x-msdownload", - ".dmg" => "application/octet-stream", - ".dna" => "application/vnd.dna", - ".doc" => "application/msword", - ".docm" => "application/vnd.ms-word.document.macroEnabled.12", - ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".dot" => "application/msword", - ".dotm" => "application/vnd.ms-word.template.macroEnabled.12", - ".dotx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.template", - ".dp" => "application/vnd.osgi.dp", - ".dpg" => "application/vnd.dpgraph", - ".dsc" => "text/prs.lines.tag", - ".dtd" => "application/xml-dtd", - ".dts" => "audio/vnd.dts", - ".dtshd" => "audio/vnd.dts.hd", - ".dv" => "video/x-dv", - ".dvi" => "application/x-dvi", - ".dwf" => "model/vnd.dwf", - ".dwg" => "image/vnd.dwg", - ".dxf" => "image/vnd.dxf", - ".dxp" => "application/vnd.spotfire.dxp", - ".ear" => "application/java-archive", - ".ecelp4800" => "audio/vnd.nuera.ecelp4800", - ".ecelp7470" => "audio/vnd.nuera.ecelp7470", - ".ecelp9600" => "audio/vnd.nuera.ecelp9600", - ".ecma" => "application/ecmascript", - ".edm" => "application/vnd.novadigm.edm", - ".edx" => "application/vnd.novadigm.edx", - ".efif" => "application/vnd.picsel", - ".ei6" => "application/vnd.pg.osasli", - ".eml" => "message/rfc822", - ".eol" => "audio/vnd.digital-winds", - ".eot" => "application/vnd.ms-fontobject", - ".eps" => "application/postscript", - ".es3" => "application/vnd.eszigno3+xml", - ".esf" => "application/vnd.epson.esf", - ".etx" => "text/x-setext", - ".exe" => "application/x-msdownload", - ".ext" => "application/vnd.novadigm.ext", - ".ez" => "application/andrew-inset", - ".ez2" => "application/vnd.ezpix-album", - ".ez3" => "application/vnd.ezpix-package", - ".f" => "text/x-fortran", - ".f77" => "text/x-fortran", - ".f90" => "text/x-fortran", - ".fbs" => "image/vnd.fastbidsheet", - ".fdf" => "application/vnd.fdf", - ".fe_launch" => "application/vnd.denovo.fcselayout-link", - ".fg5" => "application/vnd.fujitsu.oasysgp", - ".fli" => "video/x-fli", - ".flif" => "image/flif", - ".flo" => "application/vnd.micrografx.flo", - ".flv" => "video/x-flv", - ".flw" => "application/vnd.kde.kivio", - ".flx" => "text/vnd.fmi.flexstor", - ".fly" => "text/vnd.fly", - ".fm" => "application/vnd.framemaker", - ".fnc" => "application/vnd.frogans.fnc", - ".for" => "text/x-fortran", - ".fpx" => "image/vnd.fpx", - ".fsc" => "application/vnd.fsc.weblaunch", - ".fst" => "image/vnd.fst", - ".ftc" => "application/vnd.fluxtime.clip", - ".fti" => "application/vnd.anser-web-funds-transfer-initiation", - ".fvt" => "video/vnd.fvt", - ".fzs" => "application/vnd.fuzzysheet", - ".g3" => "image/g3fax", - ".gac" => "application/vnd.groove-account", - ".gdl" => "model/vnd.gdl", - ".gem" => "application/octet-stream", - ".gemspec" => "text/x-script.ruby", - ".ghf" => "application/vnd.groove-help", - ".gif" => "image/gif", - ".gim" => "application/vnd.groove-identity-message", - ".gmx" => "application/vnd.gmx", - ".gph" => "application/vnd.flographit", - ".gqf" => "application/vnd.grafeq", - ".gram" => "application/srgs", - ".grv" => "application/vnd.groove-injector", - ".grxml" => "application/srgs+xml", - ".gtar" => "application/x-gtar", - ".gtm" => "application/vnd.groove-tool-message", - ".gtw" => "model/vnd.gtw", - ".gv" => "text/vnd.graphviz", - ".gz" => "application/x-gzip", - ".h" => "text/x-c", - ".h261" => "video/h261", - ".h263" => "video/h263", - ".h264" => "video/h264", - ".hbci" => "application/vnd.hbci", - ".hdf" => "application/x-hdf", - ".heic" => "image/heic", - ".heics" => "image/heic-sequence", - ".heif" => "image/heif", - ".heifs" => "image/heif-sequence", - ".hh" => "text/x-c", - ".hlp" => "application/winhlp", - ".hpgl" => "application/vnd.hp-hpgl", - ".hpid" => "application/vnd.hp-hpid", - ".hps" => "application/vnd.hp-hps", - ".hqx" => "application/mac-binhex40", - ".htc" => "text/x-component", - ".htke" => "application/vnd.kenameaapp", - ".htm" => "text/html", - ".html" => "text/html", - ".hvd" => "application/vnd.yamaha.hv-dic", - ".hvp" => "application/vnd.yamaha.hv-voice", - ".hvs" => "application/vnd.yamaha.hv-script", - ".icc" => "application/vnd.iccprofile", - ".ice" => "x-conference/x-cooltalk", - ".ico" => "image/vnd.microsoft.icon", - ".ics" => "text/calendar", - ".ief" => "image/ief", - ".ifb" => "text/calendar", - ".ifm" => "application/vnd.shana.informed.formdata", - ".igl" => "application/vnd.igloader", - ".igs" => "model/iges", - ".igx" => "application/vnd.micrografx.igx", - ".iif" => "application/vnd.shana.informed.interchange", - ".imp" => "application/vnd.accpac.simply.imp", - ".ims" => "application/vnd.ms-ims", - ".ipk" => "application/vnd.shana.informed.package", - ".irm" => "application/vnd.ibm.rights-management", - ".irp" => "application/vnd.irepository.package+xml", - ".iso" => "application/octet-stream", - ".itp" => "application/vnd.shana.informed.formtemplate", - ".ivp" => "application/vnd.immervision-ivp", - ".ivu" => "application/vnd.immervision-ivu", - ".jad" => "text/vnd.sun.j2me.app-descriptor", - ".jam" => "application/vnd.jam", - ".jar" => "application/java-archive", - ".java" => "text/x-java-source", - ".jisp" => "application/vnd.jisp", - ".jlt" => "application/vnd.hp-jlyt", - ".jnlp" => "application/x-java-jnlp-file", - ".joda" => "application/vnd.joost.joda-archive", - ".jp2" => "image/jp2", - ".jpeg" => "image/jpeg", - ".jpg" => "image/jpeg", - ".jpgv" => "video/jpeg", - ".jpm" => "video/jpm", - ".js" => "text/javascript", - ".json" => "application/json", - ".karbon" => "application/vnd.kde.karbon", - ".kfo" => "application/vnd.kde.kformula", - ".kia" => "application/vnd.kidspiration", - ".kml" => "application/vnd.google-earth.kml+xml", - ".kmz" => "application/vnd.google-earth.kmz", - ".kne" => "application/vnd.kinar", - ".kon" => "application/vnd.kde.kontour", - ".kpr" => "application/vnd.kde.kpresenter", - ".ksp" => "application/vnd.kde.kspread", - ".ktz" => "application/vnd.kahootz", - ".kwd" => "application/vnd.kde.kword", - ".latex" => "application/x-latex", - ".lbd" => "application/vnd.llamagraphics.life-balance.desktop", - ".lbe" => "application/vnd.llamagraphics.life-balance.exchange+xml", - ".les" => "application/vnd.hhe.lesson-player", - ".link66" => "application/vnd.route66.link66+xml", - ".log" => "text/plain", - ".lostxml" => "application/lost+xml", - ".lrm" => "application/vnd.ms-lrm", - ".ltf" => "application/vnd.frogans.ltf", - ".lvp" => "audio/vnd.lucent.voice", - ".lwp" => "application/vnd.lotus-wordpro", - ".m3u" => "audio/x-mpegurl", - ".m3u8" => "application/x-mpegurl", - ".m4a" => "audio/mp4a-latm", - ".m4v" => "video/mp4", - ".ma" => "application/mathematica", - ".mag" => "application/vnd.ecowin.chart", - ".man" => "text/troff", - ".manifest" => "text/cache-manifest", - ".mathml" => "application/mathml+xml", - ".mbk" => "application/vnd.mobius.mbk", - ".mbox" => "application/mbox", - ".mc1" => "application/vnd.medcalcdata", - ".mcd" => "application/vnd.mcd", - ".mdb" => "application/x-msaccess", - ".mdi" => "image/vnd.ms-modi", - ".mdoc" => "text/troff", - ".me" => "text/troff", - ".mfm" => "application/vnd.mfmp", - ".mgz" => "application/vnd.proteus.magazine", - ".mid" => "audio/midi", - ".midi" => "audio/midi", - ".mif" => "application/vnd.mif", - ".mime" => "message/rfc822", - ".mj2" => "video/mj2", - ".mjs" => "text/javascript", - ".mlp" => "application/vnd.dolby.mlp", - ".mmd" => "application/vnd.chipnuts.karaoke-mmd", - ".mmf" => "application/vnd.smaf", - ".mml" => "application/mathml+xml", - ".mmr" => "image/vnd.fujixerox.edmics-mmr", - ".mng" => "video/x-mng", - ".mny" => "application/x-msmoney", - ".mov" => "video/quicktime", - ".movie" => "video/x-sgi-movie", - ".mp3" => "audio/mpeg", - ".mp4" => "video/mp4", - ".mp4a" => "audio/mp4", - ".mp4s" => "application/mp4", - ".mp4v" => "video/mp4", - ".mpc" => "application/vnd.mophun.certificate", - ".mpd" => "application/dash+xml", - ".mpeg" => "video/mpeg", - ".mpg" => "video/mpeg", - ".mpga" => "audio/mpeg", - ".mpkg" => "application/vnd.apple.installer+xml", - ".mpm" => "application/vnd.blueice.multipass", - ".mpn" => "application/vnd.mophun.application", - ".mpp" => "application/vnd.ms-project", - ".mpy" => "application/vnd.ibm.minipay", - ".mqy" => "application/vnd.mobius.mqy", - ".mrc" => "application/marc", - ".ms" => "text/troff", - ".mscml" => "application/mediaservercontrol+xml", - ".mseq" => "application/vnd.mseq", - ".msf" => "application/vnd.epson.msf", - ".msh" => "model/mesh", - ".msi" => "application/x-msdownload", - ".msl" => "application/vnd.mobius.msl", - ".msty" => "application/vnd.muvee.style", - ".mts" => "model/vnd.mts", - ".mus" => "application/vnd.musician", - ".mvb" => "application/x-msmediaview", - ".mwf" => "application/vnd.mfer", - ".mxf" => "application/mxf", - ".mxl" => "application/vnd.recordare.musicxml", - ".mxml" => "application/xv+xml", - ".mxs" => "application/vnd.triscape.mxs", - ".mxu" => "video/vnd.mpegurl", - ".n" => "application/vnd.nokia.n-gage.symbian.install", - ".nc" => "application/x-netcdf", - ".ngdat" => "application/vnd.nokia.n-gage.data", - ".nlu" => "application/vnd.neurolanguage.nlu", - ".nml" => "application/vnd.enliven", - ".nnd" => "application/vnd.noblenet-directory", - ".nns" => "application/vnd.noblenet-sealer", - ".nnw" => "application/vnd.noblenet-web", - ".npx" => "image/vnd.net-fpx", - ".nsf" => "application/vnd.lotus-notes", - ".oa2" => "application/vnd.fujitsu.oasys2", - ".oa3" => "application/vnd.fujitsu.oasys3", - ".oas" => "application/vnd.fujitsu.oasys", - ".obd" => "application/x-msbinder", - ".oda" => "application/oda", - ".odc" => "application/vnd.oasis.opendocument.chart", - ".odf" => "application/vnd.oasis.opendocument.formula", - ".odg" => "application/vnd.oasis.opendocument.graphics", - ".odi" => "application/vnd.oasis.opendocument.image", - ".odp" => "application/vnd.oasis.opendocument.presentation", - ".ods" => "application/vnd.oasis.opendocument.spreadsheet", - ".odt" => "application/vnd.oasis.opendocument.text", - ".oga" => "audio/ogg", - ".ogg" => "application/ogg", - ".ogv" => "video/ogg", - ".ogx" => "application/ogg", - ".org" => "application/vnd.lotus-organizer", - ".otc" => "application/vnd.oasis.opendocument.chart-template", - ".otf" => "font/otf", - ".otg" => "application/vnd.oasis.opendocument.graphics-template", - ".oth" => "application/vnd.oasis.opendocument.text-web", - ".oti" => "application/vnd.oasis.opendocument.image-template", - ".otm" => "application/vnd.oasis.opendocument.text-master", - ".ots" => "application/vnd.oasis.opendocument.spreadsheet-template", - ".ott" => "application/vnd.oasis.opendocument.text-template", - ".oxt" => "application/vnd.openofficeorg.extension", - ".p" => "text/x-pascal", - ".p10" => "application/pkcs10", - ".p12" => "application/x-pkcs12", - ".p7b" => "application/x-pkcs7-certificates", - ".p7m" => "application/pkcs7-mime", - ".p7r" => "application/x-pkcs7-certreqresp", - ".p7s" => "application/pkcs7-signature", - ".pas" => "text/x-pascal", - ".pbd" => "application/vnd.powerbuilder6", - ".pbm" => "image/x-portable-bitmap", - ".pcl" => "application/vnd.hp-pcl", - ".pclxl" => "application/vnd.hp-pclxl", - ".pcx" => "image/x-pcx", - ".pdb" => "chemical/x-pdb", - ".pdf" => "application/pdf", - ".pem" => "application/x-x509-ca-cert", - ".pfr" => "application/font-tdpfr", - ".pgm" => "image/x-portable-graymap", - ".pgn" => "application/x-chess-pgn", - ".pgp" => "application/pgp-encrypted", - ".pic" => "image/x-pict", - ".pict" => "image/pict", - ".pkg" => "application/octet-stream", - ".pki" => "application/pkixcmp", - ".pkipath" => "application/pkix-pkipath", - ".pl" => "text/x-script.perl", - ".plb" => "application/vnd.3gpp.pic-bw-large", - ".plc" => "application/vnd.mobius.plc", - ".plf" => "application/vnd.pocketlearn", - ".pls" => "application/pls+xml", - ".pm" => "text/x-script.perl-module", - ".pml" => "application/vnd.ctc-posml", - ".png" => "image/png", - ".pnm" => "image/x-portable-anymap", - ".pntg" => "image/x-macpaint", - ".portpkg" => "application/vnd.macports.portpkg", - ".pot" => "application/vnd.ms-powerpoint", - ".potm" => "application/vnd.ms-powerpoint.template.macroEnabled.12", - ".potx" => "application/vnd.openxmlformats-officedocument.presentationml.template", - ".ppa" => "application/vnd.ms-powerpoint", - ".ppam" => "application/vnd.ms-powerpoint.addin.macroEnabled.12", - ".ppd" => "application/vnd.cups-ppd", - ".ppm" => "image/x-portable-pixmap", - ".pps" => "application/vnd.ms-powerpoint", - ".ppsm" => "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", - ".ppsx" => "application/vnd.openxmlformats-officedocument.presentationml.slideshow", - ".ppt" => "application/vnd.ms-powerpoint", - ".pptm" => "application/vnd.ms-powerpoint.presentation.macroEnabled.12", - ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ".prc" => "application/vnd.palm", - ".pre" => "application/vnd.lotus-freelance", - ".prf" => "application/pics-rules", - ".ps" => "application/postscript", - ".psb" => "application/vnd.3gpp.pic-bw-small", - ".psd" => "image/vnd.adobe.photoshop", - ".ptid" => "application/vnd.pvi.ptid1", - ".pub" => "application/x-mspublisher", - ".pvb" => "application/vnd.3gpp.pic-bw-var", - ".pwn" => "application/vnd.3m.post-it-notes", - ".py" => "text/x-script.python", - ".pya" => "audio/vnd.ms-playready.media.pya", - ".pyv" => "video/vnd.ms-playready.media.pyv", - ".qam" => "application/vnd.epson.quickanime", - ".qbo" => "application/vnd.intu.qbo", - ".qfx" => "application/vnd.intu.qfx", - ".qps" => "application/vnd.publishare-delta-tree", - ".qt" => "video/quicktime", - ".qtif" => "image/x-quicktime", - ".qxd" => "application/vnd.quark.quarkxpress", - ".ra" => "audio/x-pn-realaudio", - ".rake" => "text/x-script.ruby", - ".ram" => "audio/x-pn-realaudio", - ".rar" => "application/x-rar-compressed", - ".ras" => "image/x-cmu-raster", - ".rb" => "text/x-script.ruby", - ".rcprofile" => "application/vnd.ipunplugged.rcprofile", - ".rdf" => "application/rdf+xml", - ".rdz" => "application/vnd.data-vision.rdz", - ".rep" => "application/vnd.businessobjects", - ".rgb" => "image/x-rgb", - ".rif" => "application/reginfo+xml", - ".rl" => "application/resource-lists+xml", - ".rlc" => "image/vnd.fujixerox.edmics-rlc", - ".rld" => "application/resource-lists-diff+xml", - ".rm" => "application/vnd.rn-realmedia", - ".rmp" => "audio/x-pn-realaudio-plugin", - ".rms" => "application/vnd.jcp.javame.midlet-rms", - ".rnc" => "application/relax-ng-compact-syntax", - ".roff" => "text/troff", - ".rpm" => "application/x-redhat-package-manager", - ".rpss" => "application/vnd.nokia.radio-presets", - ".rpst" => "application/vnd.nokia.radio-preset", - ".rq" => "application/sparql-query", - ".rs" => "application/rls-services+xml", - ".rsd" => "application/rsd+xml", - ".rss" => "application/rss+xml", - ".rtf" => "application/rtf", - ".rtx" => "text/richtext", - ".ru" => "text/x-script.ruby", - ".s" => "text/x-asm", - ".saf" => "application/vnd.yamaha.smaf-audio", - ".sbml" => "application/sbml+xml", - ".sc" => "application/vnd.ibm.secure-container", - ".scd" => "application/x-msschedule", - ".scm" => "application/vnd.lotus-screencam", - ".scq" => "application/scvp-cv-request", - ".scs" => "application/scvp-cv-response", - ".sdkm" => "application/vnd.solent.sdkm+xml", - ".sdp" => "application/sdp", - ".see" => "application/vnd.seemail", - ".sema" => "application/vnd.sema", - ".semd" => "application/vnd.semd", - ".semf" => "application/vnd.semf", - ".setpay" => "application/set-payment-initiation", - ".setreg" => "application/set-registration-initiation", - ".sfd" => "application/vnd.hydrostatix.sof-data", - ".sfs" => "application/vnd.spotfire.sfs", - ".sgm" => "text/sgml", - ".sgml" => "text/sgml", - ".sh" => "application/x-sh", - ".shar" => "application/x-shar", - ".shf" => "application/shf+xml", - ".sig" => "application/pgp-signature", - ".sit" => "application/x-stuffit", - ".sitx" => "application/x-stuffitx", - ".skp" => "application/vnd.koan", - ".slt" => "application/vnd.epson.salt", - ".smi" => "application/smil+xml", - ".snd" => "audio/basic", - ".so" => "application/octet-stream", - ".spf" => "application/vnd.yamaha.smaf-phrase", - ".spl" => "application/x-futuresplash", - ".spot" => "text/vnd.in3d.spot", - ".spp" => "application/scvp-vp-response", - ".spq" => "application/scvp-vp-request", - ".src" => "application/x-wais-source", - ".srt" => "text/srt", - ".srx" => "application/sparql-results+xml", - ".sse" => "application/vnd.kodak-descriptor", - ".ssf" => "application/vnd.epson.ssf", - ".ssml" => "application/ssml+xml", - ".stf" => "application/vnd.wt.stf", - ".stk" => "application/hyperstudio", - ".str" => "application/vnd.pg.format", - ".sus" => "application/vnd.sus-calendar", - ".sv4cpio" => "application/x-sv4cpio", - ".sv4crc" => "application/x-sv4crc", - ".svd" => "application/vnd.svd", - ".svg" => "image/svg+xml", - ".svgz" => "image/svg+xml", - ".swf" => "application/x-shockwave-flash", - ".swi" => "application/vnd.arastra.swi", - ".t" => "text/troff", - ".tao" => "application/vnd.tao.intent-module-archive", - ".tar" => "application/x-tar", - ".tbz" => "application/x-bzip-compressed-tar", - ".tcap" => "application/vnd.3gpp2.tcap", - ".tcl" => "application/x-tcl", - ".tex" => "application/x-tex", - ".texi" => "application/x-texinfo", - ".texinfo" => "application/x-texinfo", - ".text" => "text/plain", - ".tif" => "image/tiff", - ".tiff" => "image/tiff", - ".tmo" => "application/vnd.tmobile-livetv", - ".torrent" => "application/x-bittorrent", - ".tpl" => "application/vnd.groove-tool-template", - ".tpt" => "application/vnd.trid.tpt", - ".tr" => "text/troff", - ".tra" => "application/vnd.trueapp", - ".trm" => "application/x-msterminal", - ".ts" => "video/mp2t", - ".tsv" => "text/tab-separated-values", - ".ttf" => "font/ttf", - ".twd" => "application/vnd.simtech-mindmapper", - ".txd" => "application/vnd.genomatix.tuxedo", - ".txf" => "application/vnd.mobius.txf", - ".txt" => "text/plain", - ".ufd" => "application/vnd.ufdl", - ".umj" => "application/vnd.umajin", - ".unityweb" => "application/vnd.unity", - ".uoml" => "application/vnd.uoml+xml", - ".uri" => "text/uri-list", - ".ustar" => "application/x-ustar", - ".utz" => "application/vnd.uiq.theme", - ".uu" => "text/x-uuencode", - ".vcd" => "application/x-cdlink", - ".vcf" => "text/x-vcard", - ".vcg" => "application/vnd.groove-vcard", - ".vcs" => "text/x-vcalendar", - ".vcx" => "application/vnd.vcx", - ".vis" => "application/vnd.visionary", - ".viv" => "video/vnd.vivo", - ".vrml" => "model/vrml", - ".vsd" => "application/vnd.visio", - ".vsf" => "application/vnd.vsf", - ".vtt" => "text/vtt", - ".vtu" => "model/vnd.vtu", - ".vxml" => "application/voicexml+xml", - ".war" => "application/java-archive", - ".wasm" => "application/wasm", - ".wav" => "audio/x-wav", - ".wax" => "audio/x-ms-wax", - ".wbmp" => "image/vnd.wap.wbmp", - ".wbs" => "application/vnd.criticaltools.wbs+xml", - ".wbxml" => "application/vnd.wap.wbxml", - ".webm" => "video/webm", - ".webp" => "image/webp", - ".wm" => "video/x-ms-wm", - ".wma" => "audio/x-ms-wma", - ".wmd" => "application/x-ms-wmd", - ".wmf" => "application/x-msmetafile", - ".wml" => "text/vnd.wap.wml", - ".wmlc" => "application/vnd.wap.wmlc", - ".wmls" => "text/vnd.wap.wmlscript", - ".wmlsc" => "application/vnd.wap.wmlscriptc", - ".wmv" => "video/x-ms-wmv", - ".wmx" => "video/x-ms-wmx", - ".wmz" => "application/x-ms-wmz", - ".woff" => "font/woff", - ".woff2" => "font/woff2", - ".wpd" => "application/vnd.wordperfect", - ".wpl" => "application/vnd.ms-wpl", - ".wps" => "application/vnd.ms-works", - ".wqd" => "application/vnd.wqd", - ".wri" => "application/x-mswrite", - ".wrl" => "model/vrml", - ".wsdl" => "application/wsdl+xml", - ".wspolicy" => "application/wspolicy+xml", - ".wtb" => "application/vnd.webturbo", - ".wvx" => "video/x-ms-wvx", - ".x3d" => "application/vnd.hzn-3d-crossword", - ".xar" => "application/vnd.xara", - ".xbd" => "application/vnd.fujixerox.docuworks.binder", - ".xbm" => "image/x-xbitmap", - ".xdm" => "application/vnd.syncml.dm+xml", - ".xdp" => "application/vnd.adobe.xdp+xml", - ".xdw" => "application/vnd.fujixerox.docuworks", - ".xenc" => "application/xenc+xml", - ".xer" => "application/patch-ops-error+xml", - ".xfdf" => "application/vnd.adobe.xfdf", - ".xfdl" => "application/vnd.xfdl", - ".xhtml" => "application/xhtml+xml", - ".xif" => "image/vnd.xiff", - ".xla" => "application/vnd.ms-excel", - ".xlam" => "application/vnd.ms-excel.addin.macroEnabled.12", - ".xls" => "application/vnd.ms-excel", - ".xlsb" => "application/vnd.ms-excel.sheet.binary.macroEnabled.12", - ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".xlsm" => "application/vnd.ms-excel.sheet.macroEnabled.12", - ".xlt" => "application/vnd.ms-excel", - ".xltx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.template", - ".xml" => "application/xml", - ".xo" => "application/vnd.olpc-sugar", - ".xop" => "application/xop+xml", - ".xpm" => "image/x-xpixmap", - ".xpr" => "application/vnd.is-xpr", - ".xps" => "application/vnd.ms-xpsdocument", - ".xpw" => "application/vnd.intercon.formnet", - ".xsl" => "application/xml", - ".xslt" => "application/xslt+xml", - ".xsm" => "application/vnd.syncml+xml", - ".xspf" => "application/xspf+xml", - ".xul" => "application/vnd.mozilla.xul+xml", - ".xwd" => "image/x-xwindowdump", - ".xyz" => "chemical/x-xyz", - ".yaml" => "text/yaml", - ".yml" => "text/yaml", - ".zaz" => "application/vnd.zzazz.deck+xml", - ".zip" => "application/zip", - ".zmm" => "application/vnd.handheld-entertainment+xml", - } - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock.rb deleted file mode 100644 index 5e5c457..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock.rb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -require_relative 'mock_request' diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_request.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_request.rb deleted file mode 100644 index 7c87bea..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_request.rb +++ /dev/null @@ -1,161 +0,0 @@ -# frozen_string_literal: true - -require 'uri' -require 'stringio' - -require_relative 'constants' -require_relative 'mock_response' - -module Rack - # Rack::MockRequest helps testing your Rack application without - # actually using HTTP. - # - # After performing a request on a URL with get/post/put/patch/delete, it - # returns a MockResponse with useful helper methods for effective - # testing. - # - # You can pass a hash with additional configuration to the - # get/post/put/patch/delete. - # :input:: A String or IO-like to be used as rack.input. - # :fatal:: Raise a FatalWarning if the app writes to rack.errors. - # :lint:: If true, wrap the application in a Rack::Lint. - - class MockRequest - class FatalWarning < RuntimeError - end - - class FatalWarner - def puts(warning) - raise FatalWarning, warning - end - - def write(warning) - raise FatalWarning, warning - end - - def flush - end - - def string - "" - end - end - - def initialize(app) - @app = app - end - - # Make a GET request and return a MockResponse. See #request. - def get(uri, opts = {}) request(GET, uri, opts) end - # Make a POST request and return a MockResponse. See #request. - def post(uri, opts = {}) request(POST, uri, opts) end - # Make a PUT request and return a MockResponse. See #request. - def put(uri, opts = {}) request(PUT, uri, opts) end - # Make a PATCH request and return a MockResponse. See #request. - def patch(uri, opts = {}) request(PATCH, uri, opts) end - # Make a DELETE request and return a MockResponse. See #request. - def delete(uri, opts = {}) request(DELETE, uri, opts) end - # Make a HEAD request and return a MockResponse. See #request. - def head(uri, opts = {}) request(HEAD, uri, opts) end - # Make an OPTIONS request and return a MockResponse. See #request. - def options(uri, opts = {}) request(OPTIONS, uri, opts) end - - # Make a request using the given request method for the given - # uri to the rack application and return a MockResponse. - # Options given are passed to MockRequest.env_for. - def request(method = GET, uri = "", opts = {}) - env = self.class.env_for(uri, opts.merge(method: method)) - - if opts[:lint] - app = Rack::Lint.new(@app) - else - app = @app - end - - errors = env[RACK_ERRORS] - status, headers, body = app.call(env) - MockResponse.new(status, headers, body, errors) - ensure - body.close if body.respond_to?(:close) - end - - # For historical reasons, we're pinning to RFC 2396. - # URI::Parser = URI::RFC2396_Parser - def self.parse_uri_rfc2396(uri) - @parser ||= URI::Parser.new - @parser.parse(uri) - end - - # Return the Rack environment used for a request to +uri+. - # All options that are strings are added to the returned environment. - # Options: - # :fatal :: Whether to raise an exception if request outputs to rack.errors - # :input :: The rack.input to set - # :http_version :: The SERVER_PROTOCOL to set - # :method :: The HTTP request method to use - # :params :: The params to use - # :script_name :: The SCRIPT_NAME to set - def self.env_for(uri = "", opts = {}) - uri = parse_uri_rfc2396(uri) - uri.path = "/#{uri.path}" unless uri.path[0] == ?/ - - env = {} - - env[REQUEST_METHOD] = (opts[:method] ? opts[:method].to_s.upcase : GET).b - env[SERVER_NAME] = (uri.host || "example.org").b - env[SERVER_PORT] = (uri.port ? uri.port.to_s : "80").b - env[SERVER_PROTOCOL] = opts[:http_version] || 'HTTP/1.1' - env[QUERY_STRING] = (uri.query.to_s).b - env[PATH_INFO] = (uri.path).b - env[RACK_URL_SCHEME] = (uri.scheme || "http").b - env[HTTPS] = (env[RACK_URL_SCHEME] == "https" ? "on" : "off").b - - env[SCRIPT_NAME] = opts[:script_name] || "" - - if opts[:fatal] - env[RACK_ERRORS] = FatalWarner.new - else - env[RACK_ERRORS] = StringIO.new - end - - if params = opts[:params] - if env[REQUEST_METHOD] == GET - params = Utils.parse_nested_query(params) if params.is_a?(String) - params.update(Utils.parse_nested_query(env[QUERY_STRING])) - env[QUERY_STRING] = Utils.build_nested_query(params) - elsif !opts.has_key?(:input) - opts["CONTENT_TYPE"] = "application/x-www-form-urlencoded" - if params.is_a?(Hash) - if data = Rack::Multipart.build_multipart(params) - opts[:input] = data - opts["CONTENT_LENGTH"] ||= data.length.to_s - opts["CONTENT_TYPE"] = "multipart/form-data; boundary=#{Rack::Multipart::MULTIPART_BOUNDARY}" - else - opts[:input] = Utils.build_nested_query(params) - end - else - opts[:input] = params - end - end - end - - rack_input = opts[:input] - if String === rack_input - rack_input = StringIO.new(rack_input) - end - - if rack_input - rack_input.set_encoding(Encoding::BINARY) if rack_input.respond_to?(:set_encoding) - env[RACK_INPUT] = rack_input - - env["CONTENT_LENGTH"] ||= env[RACK_INPUT].size.to_s if env[RACK_INPUT].respond_to?(:size) - end - - opts.each { |field, value| - env[field] = value if String === field - } - - env - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_response.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_response.rb deleted file mode 100644 index 9af8079..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/mock_response.rb +++ /dev/null @@ -1,124 +0,0 @@ -# frozen_string_literal: true - -require 'cgi/cookie' -require 'time' - -require_relative 'response' - -module Rack - # Rack::MockResponse provides useful helpers for testing your apps. - # Usually, you don't create the MockResponse on your own, but use - # MockRequest. - - class MockResponse < Rack::Response - class << self - alias [] new - end - - # Headers - attr_reader :original_headers, :cookies - - # Errors - attr_accessor :errors - - def initialize(status, headers, body, errors = nil) - @original_headers = headers - - if errors - @errors = errors.string if errors.respond_to?(:string) - else - @errors = "" - end - - super(body, status, headers) - - @cookies = parse_cookies_from_header - buffered_body! - end - - def =~(other) - body =~ other - end - - def match(other) - body.match other - end - - def body - return @buffered_body if defined?(@buffered_body) - - # FIXME: apparently users of MockResponse expect the return value of - # MockResponse#body to be a string. However, the real response object - # returns the body as a list. - # - # See spec_showstatus.rb: - # - # should "not replace existing messages" do - # ... - # res.body.should == "foo!" - # end - buffer = @buffered_body = String.new - - @body.each do |chunk| - buffer << chunk - end - - return buffer - end - - def empty? - [201, 204, 304].include? status - end - - def cookie(name) - cookies.fetch(name, nil) - end - - private - - def parse_cookies_from_header - cookies = Hash.new - set_cookie_header = headers['set-cookie'] - if set_cookie_header && !set_cookie_header.empty? - Array(set_cookie_header).each do |cookie| - cookie_name, cookie_filling = cookie.split('=', 2) - cookie_attributes = identify_cookie_attributes cookie_filling - parsed_cookie = CGI::Cookie.new( - 'name' => cookie_name.strip, - 'value' => cookie_attributes.fetch('value'), - 'path' => cookie_attributes.fetch('path', nil), - 'domain' => cookie_attributes.fetch('domain', nil), - 'expires' => cookie_attributes.fetch('expires', nil), - 'secure' => cookie_attributes.fetch('secure', false) - ) - cookies.store(cookie_name, parsed_cookie) - end - end - cookies - end - - def identify_cookie_attributes(cookie_filling) - cookie_bits = cookie_filling.split(';') - cookie_attributes = Hash.new - cookie_attributes.store('value', cookie_bits[0].strip) - cookie_bits.drop(1).each do |bit| - if bit.include? '=' - cookie_attribute, attribute_value = bit.split('=', 2) - cookie_attributes.store(cookie_attribute.strip.downcase, attribute_value.strip) - end - if bit.include? 'secure' - cookie_attributes.store('secure', true) - end - end - - if cookie_attributes.key? 'max-age' - cookie_attributes.store('expires', Time.now + cookie_attributes['max-age'].to_i) - elsif cookie_attributes.key? 'expires' - cookie_attributes.store('expires', Time.httpdate(cookie_attributes['expires'])) - end - - cookie_attributes - end - - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart.rb deleted file mode 100644 index 4b02fb3..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' - -require_relative 'multipart/parser' -require_relative 'multipart/generator' - -require_relative 'bad_request' - -module Rack - # A multipart form data parser, adapted from IOWA. - # - # Usually, Rack::Request#POST takes care of calling this. - module Multipart - MULTIPART_BOUNDARY = "AaB03x" - - class MissingInputError < StandardError - include BadRequest - end - - # Accumulator for multipart form data, conforming to the QueryParser API. - # In future, the Parser could return the pair list directly, but that would - # change its API. - class ParamList # :nodoc: - def self.make_params - new - end - - def self.normalize_params(params, key, value) - params << [key, value] - end - - def initialize - @pairs = [] - end - - def <<(pair) - @pairs << pair - end - - def to_params_hash - @pairs - end - end - - class << self - def parse_multipart(env, params = Rack::Utils.default_query_parser) - unless io = env[RACK_INPUT] - raise MissingInputError, "Missing input stream!" - end - - if content_length = env['CONTENT_LENGTH'] - content_length = content_length.to_i - end - - content_type = env['CONTENT_TYPE'] - - tempfile = env[RACK_MULTIPART_TEMPFILE_FACTORY] || Parser::TEMPFILE_FACTORY - bufsize = env[RACK_MULTIPART_BUFFER_SIZE] || Parser::BUFSIZE - - info = Parser.parse(io, content_length, content_type, tempfile, bufsize, params) - env[RACK_TEMPFILES] = info.tmp_files - - return info.params - end - - def extract_multipart(request, params = Rack::Utils.default_query_parser) - parse_multipart(request.env) - end - - def build_multipart(params, first = true) - Generator.new(params, first).dump - end - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb deleted file mode 100644 index 30d7f51..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -require_relative 'uploaded_file' - -module Rack - module Multipart - class Generator - def initialize(params, first = true) - @params, @first = params, first - - if @first && !@params.is_a?(Hash) - raise ArgumentError, "value must be a Hash" - end - end - - def dump - return nil if @first && !multipart? - return flattened_params unless @first - - flattened_params.map do |name, file| - if file.respond_to?(:original_filename) - if file.path - ::File.open(file.path, 'rb') do |f| - f.set_encoding(Encoding::BINARY) - content_for_tempfile(f, file, name) - end - else - content_for_tempfile(file, file, name) - end - else - content_for_other(file, name) - end - end.join << "--#{MULTIPART_BOUNDARY}--\r" - end - - private - def multipart? - query = lambda { |value| - case value - when Array - value.any?(&query) - when Hash - value.values.any?(&query) - when Rack::Multipart::UploadedFile - true - end - } - - @params.values.any?(&query) - end - - def flattened_params - @flattened_params ||= begin - h = Hash.new - @params.each do |key, value| - k = @first ? key.to_s : "[#{key}]" - - case value - when Array - value.map { |v| - Multipart.build_multipart(v, false).each { |subkey, subvalue| - h["#{k}[]#{subkey}"] = subvalue - } - } - when Hash - Multipart.build_multipart(value, false).each { |subkey, subvalue| - h[k + subkey] = subvalue - } - else - h[k] = value - end - end - h - end - end - - def content_for_tempfile(io, file, name) - length = ::File.stat(file.path).size if file.path - filename = "; filename=\"#{Utils.escape_path(file.original_filename)}\"" -<<-EOF ---#{MULTIPART_BOUNDARY}\r -content-disposition: form-data; name="#{name}"#{filename}\r -content-type: #{file.content_type}\r -#{"content-length: #{length}\r\n" if length}\r -#{io.read}\r -EOF - end - - def content_for_other(file, name) -<<-EOF ---#{MULTIPART_BOUNDARY}\r -content-disposition: form-data; name="#{name}"\r -\r -#{file}\r -EOF - end - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb deleted file mode 100644 index 3960b37..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb +++ /dev/null @@ -1,502 +0,0 @@ -# frozen_string_literal: true - -require 'strscan' - -require_relative '../utils' -require_relative '../bad_request' - -module Rack - module Multipart - class MultipartPartLimitError < Errno::EMFILE - include BadRequest - end - - class MultipartTotalPartLimitError < StandardError - include BadRequest - end - - # Use specific error class when parsing multipart request - # that ends early. - class EmptyContentError < ::EOFError - include BadRequest - end - - # Base class for multipart exceptions that do not subclass from - # other exception classes for backwards compatibility. - class BoundaryTooLongError < StandardError - include BadRequest - end - - # Prefer to use the BoundaryTooLongError class or Rack::BadRequest. - Error = BoundaryTooLongError - - EOL = "\r\n" - MULTIPART = %r|\Amultipart/.*boundary=\"?([^\";,]+)\"?|ni - MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{EOL}/ni - MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:(.*)(?=#{EOL}(\S|\z))/ni - MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{EOL}]*)/ni - - class Parser - BUFSIZE = 1_048_576 - TEXT_PLAIN = "text/plain" - TEMPFILE_FACTORY = lambda { |filename, content_type| - extension = ::File.extname(filename.gsub("\0", '%00'))[0, 129] - - Tempfile.new(["RackMultipart", extension]) - } - - class BoundedIO # :nodoc: - def initialize(io, content_length) - @io = io - @content_length = content_length - @cursor = 0 - end - - def read(size, outbuf = nil) - return if @cursor >= @content_length - - left = @content_length - @cursor - - str = if left < size - @io.read left, outbuf - else - @io.read size, outbuf - end - - if str - @cursor += str.bytesize - else - # Raise an error for mismatching content-length and actual contents - raise EOFError, "bad content body" - end - - str - end - end - - MultipartInfo = Struct.new :params, :tmp_files - EMPTY = MultipartInfo.new(nil, []) - - def self.parse_boundary(content_type) - return unless content_type - data = content_type.match(MULTIPART) - return unless data - data[1] - end - - def self.parse(io, content_length, content_type, tmpfile, bufsize, qp) - return EMPTY if 0 == content_length - - boundary = parse_boundary content_type - return EMPTY unless boundary - - if boundary.length > 70 - # RFC 1521 Section 7.2.1 imposes a 70 character maximum for the boundary. - # Most clients use no more than 55 characters. - raise BoundaryTooLongError, "multipart boundary size too large (#{boundary.length} characters)" - end - - io = BoundedIO.new(io, content_length) if content_length - - parser = new(boundary, tmpfile, bufsize, qp) - parser.parse(io) - - parser.result - end - - class Collector - class MimePart < Struct.new(:body, :head, :filename, :content_type, :name) - def get_data - data = body - if filename == "" - # filename is blank which means no file has been selected - return - elsif filename - body.rewind if body.respond_to?(:rewind) - - # Take the basename of the upload's original filename. - # This handles the full Windows paths given by Internet Explorer - # (and perhaps other broken user agents) without affecting - # those which give the lone filename. - fn = filename.split(/[\/\\]/).last - - data = { filename: fn, type: content_type, - name: name, tempfile: body, head: head } - end - - yield data - end - end - - class BufferPart < MimePart - def file?; false; end - def close; end - end - - class TempfilePart < MimePart - def file?; true; end - def close; body.close; end - end - - include Enumerable - - def initialize(tempfile) - @tempfile = tempfile - @mime_parts = [] - @open_files = 0 - end - - def each - @mime_parts.each { |part| yield part } - end - - def on_mime_head(mime_index, head, filename, content_type, name) - if filename - body = @tempfile.call(filename, content_type) - body.binmode if body.respond_to?(:binmode) - klass = TempfilePart - @open_files += 1 - else - body = String.new - klass = BufferPart - end - - @mime_parts[mime_index] = klass.new(body, head, filename, content_type, name) - - check_part_limits - end - - def on_mime_body(mime_index, content) - @mime_parts[mime_index].body << content - end - - def on_mime_finish(mime_index) - end - - private - - def check_part_limits - file_limit = Utils.multipart_file_limit - part_limit = Utils.multipart_total_part_limit - - if file_limit && file_limit > 0 - if @open_files >= file_limit - @mime_parts.each(&:close) - raise MultipartPartLimitError, 'Maximum file multiparts in content reached' - end - end - - if part_limit && part_limit > 0 - if @mime_parts.size >= part_limit - @mime_parts.each(&:close) - raise MultipartTotalPartLimitError, 'Maximum total multiparts in content reached' - end - end - end - end - - attr_reader :state - - def initialize(boundary, tempfile, bufsize, query_parser) - @query_parser = query_parser - @params = query_parser.make_params - @bufsize = bufsize - - @state = :FAST_FORWARD - @mime_index = 0 - @collector = Collector.new tempfile - - @sbuf = StringScanner.new("".dup) - @body_regex = /(?:#{EOL}|\A)--#{Regexp.quote(boundary)}(?:#{EOL}|--)/m - @body_regex_at_end = /#{@body_regex}\z/m - @end_boundary_size = boundary.bytesize + 4 # (-- at start, -- at finish) - @rx_max_size = boundary.bytesize + 6 # (\r\n-- at start, either \r\n or -- at finish) - @head_regex = /(.*?#{EOL})#{EOL}/m - end - - def parse(io) - outbuf = String.new - read_data(io, outbuf) - - loop do - status = - case @state - when :FAST_FORWARD - handle_fast_forward - when :CONSUME_TOKEN - handle_consume_token - when :MIME_HEAD - handle_mime_head - when :MIME_BODY - handle_mime_body - else # when :DONE - return - end - - read_data(io, outbuf) if status == :want_read - end - end - - def result - @collector.each do |part| - part.get_data do |data| - tag_multipart_encoding(part.filename, part.content_type, part.name, data) - @query_parser.normalize_params(@params, part.name, data) - end - end - MultipartInfo.new @params.to_params_hash, @collector.find_all(&:file?).map(&:body) - end - - private - - def dequote(str) # From WEBrick::HTTPUtils - ret = (/\A"(.*)"\Z/ =~ str) ? $1 : str.dup - ret.gsub!(/\\(.)/, "\\1") - ret - end - - def read_data(io, outbuf) - content = io.read(@bufsize, outbuf) - handle_empty_content!(content) - @sbuf.concat(content) - end - - # This handles the initial parser state. We read until we find the starting - # boundary, then we can transition to the next state. If we find the ending - # boundary, this is an invalid multipart upload, but keep scanning for opening - # boundary in that case. If no boundary found, we need to keep reading data - # and retry. It's highly unlikely the initial read will not consume the - # boundary. The client would have to deliberately craft a response - # with the opening boundary beyond the buffer size for that to happen. - def handle_fast_forward - while true - case consume_boundary - when :BOUNDARY - # found opening boundary, transition to next state - @state = :MIME_HEAD - return - when :END_BOUNDARY - # invalid multipart upload - if @sbuf.pos == @end_boundary_size && @sbuf.rest == EOL - # stop parsing a buffer if a buffer is only an end boundary. - @state = :DONE - return - end - - # retry for opening boundary - else - # no boundary found, keep reading data - return :want_read - end - end - end - - def handle_consume_token - tok = consume_boundary - # break if we're at the end of a buffer, but not if it is the end of a field - @state = if tok == :END_BOUNDARY || (@sbuf.eos? && tok != :BOUNDARY) - :DONE - else - :MIME_HEAD - end - end - - CONTENT_DISPOSITION_MAX_PARAMS = 16 - CONTENT_DISPOSITION_MAX_BYTES = 1536 - def handle_mime_head - if @sbuf.scan_until(@head_regex) - head = @sbuf[1] - content_type = head[MULTIPART_CONTENT_TYPE, 1] - if (disposition = head[MULTIPART_CONTENT_DISPOSITION, 1]) && - disposition.bytesize <= CONTENT_DISPOSITION_MAX_BYTES - - # ignore actual content-disposition value (should always be form-data) - i = disposition.index(';') - disposition.slice!(0, i+1) - param = nil - num_params = 0 - - # Parse parameter list - while i = disposition.index('=') - # Only parse up to max parameters, to avoid potential denial of service - num_params += 1 - break if num_params > CONTENT_DISPOSITION_MAX_PARAMS - - # Found end of parameter name, ensure forward progress in loop - param = disposition.slice!(0, i+1) - - # Remove ending equals and preceding whitespace from parameter name - param.chomp!('=') - param.lstrip! - - if disposition[0] == '"' - # Parameter value is quoted, parse it, handling backslash escapes - disposition.slice!(0, 1) - value = String.new - - while i = disposition.index(/(["\\])/) - c = $1 - - # Append all content until ending quote or escape - value << disposition.slice!(0, i) - - # Remove either backslash or ending quote, - # ensures forward progress in loop - disposition.slice!(0, 1) - - # stop parsing parameter value if found ending quote - break if c == '"' - - escaped_char = disposition.slice!(0, 1) - if param == 'filename' && escaped_char != '"' - # Possible IE uploaded filename, append both escape backslash and value - value << c << escaped_char - else - # Other only append escaped value - value << escaped_char - end - end - else - if i = disposition.index(';') - # Parameter value unquoted (which may be invalid), value ends at semicolon - value = disposition.slice!(0, i) - else - # If no ending semicolon, assume remainder of line is value and stop - # parsing - disposition.strip! - value = disposition - disposition = '' - end - end - - case param - when 'name' - name = value - when 'filename' - filename = value - when 'filename*' - filename_star = value - # else - # ignore other parameters - end - - # skip trailing semicolon, to proceed to next parameter - if i = disposition.index(';') - disposition.slice!(0, i+1) - end - end - else - name = head[MULTIPART_CONTENT_ID, 1] - end - - if filename_star - encoding, _, filename = filename_star.split("'", 3) - filename = normalize_filename(filename || '') - filename.force_encoding(find_encoding(encoding)) - elsif filename - filename = normalize_filename(filename) - end - - if name.nil? || name.empty? - name = filename || "#{content_type || TEXT_PLAIN}[]".dup - end - - @collector.on_mime_head @mime_index, head, filename, content_type, name - @state = :MIME_BODY - else - :want_read - end - end - - def handle_mime_body - if (body_with_boundary = @sbuf.check_until(@body_regex)) # check but do not advance the pointer yet - body = body_with_boundary.sub(@body_regex_at_end, '') # remove the boundary from the string - @collector.on_mime_body @mime_index, body - @sbuf.pos += body.length + 2 # skip \r\n after the content - @state = :CONSUME_TOKEN - @mime_index += 1 - else - # Save what we have so far - if @rx_max_size < @sbuf.rest_size - delta = @sbuf.rest_size - @rx_max_size - @collector.on_mime_body @mime_index, @sbuf.peek(delta) - @sbuf.pos += delta - @sbuf.string = @sbuf.rest - end - :want_read - end - end - - # Scan until the we find the start or end of the boundary. - # If we find it, return the appropriate symbol for the start or - # end of the boundary. If we don't find the start or end of the - # boundary, clear the buffer and return nil. - def consume_boundary - if read_buffer = @sbuf.scan_until(@body_regex) - read_buffer.end_with?(EOL) ? :BOUNDARY : :END_BOUNDARY - else - @sbuf.terminate - nil - end - end - - def normalize_filename(filename) - if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) } - filename = Utils.unescape_path(filename) - end - - filename.scrub! - - filename.split(/[\/\\]/).last || String.new - end - - CHARSET = "charset" - deprecate_constant :CHARSET - - def tag_multipart_encoding(filename, content_type, name, body) - name = name.to_s - encoding = Encoding::UTF_8 - - name.force_encoding(encoding) - - return if filename - - if content_type - list = content_type.split(';') - type_subtype = list.first - type_subtype.strip! - if TEXT_PLAIN == type_subtype - rest = list.drop 1 - rest.each do |param| - k, v = param.split('=', 2) - k.strip! - v.strip! - v = v[1..-2] if v.start_with?('"') && v.end_with?('"') - if k == "charset" - encoding = find_encoding(v) - end - end - end - end - - name.force_encoding(encoding) - body.force_encoding(encoding) - end - - # Return the related Encoding object. However, because - # enc is submitted by the user, it may be invalid, so - # use a binary encoding in that case. - def find_encoding(enc) - Encoding.find enc - rescue ArgumentError - Encoding::BINARY - end - - def handle_empty_content!(content) - if content.nil? || content.empty? - raise EmptyContentError - end - end - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb deleted file mode 100644 index 2782e44..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -require 'tempfile' -require 'fileutils' - -module Rack - module Multipart - class UploadedFile - - # The filename, *not* including the path, of the "uploaded" file - attr_reader :original_filename - - # The content type of the "uploaded" file - attr_accessor :content_type - - def initialize(filepath = nil, ct = "text/plain", bin = false, - path: filepath, content_type: ct, binary: bin, filename: nil, io: nil) - if io - @tempfile = io - @original_filename = filename - else - raise "#{path} file does not exist" unless ::File.exist?(path) - @original_filename = filename || ::File.basename(path) - @tempfile = Tempfile.new([@original_filename, ::File.extname(path)], encoding: Encoding::BINARY) - @tempfile.binmode if binary - FileUtils.copy_file(path, @tempfile.path) - end - @content_type = content_type - end - - def path - @tempfile.path if @tempfile.respond_to?(:path) - end - alias_method :local_path, :path - - def respond_to?(*args) - super or @tempfile.respond_to?(*args) - end - - def method_missing(method_name, *args, &block) #:nodoc: - @tempfile.__send__(method_name, *args, &block) - end - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/null_logger.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/null_logger.rb deleted file mode 100644 index 52fc125..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/null_logger.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' - -module Rack - class NullLogger - def initialize(app) - @app = app - end - - def call(env) - env[RACK_LOGGER] = self - @app.call(env) - end - - def info(progname = nil, &block); end - def debug(progname = nil, &block); end - def warn(progname = nil, &block); end - def error(progname = nil, &block); end - def fatal(progname = nil, &block); end - def unknown(progname = nil, &block); end - def info? ; end - def debug? ; end - def warn? ; end - def error? ; end - def fatal? ; end - def debug! ; end - def error! ; end - def fatal! ; end - def info! ; end - def warn! ; end - def level ; end - def progname ; end - def datetime_format ; end - def formatter ; end - def sev_threshold ; end - def level=(level); end - def progname=(progname); end - def datetime_format=(datetime_format); end - def formatter=(formatter); end - def sev_threshold=(sev_threshold); end - def close ; end - def add(severity, message = nil, progname = nil, &block); end - def log(severity, message = nil, progname = nil, &block); end - def <<(msg); end - def reopen(logdev = nil); end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/query_parser.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/query_parser.rb deleted file mode 100644 index 28cbce1..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/query_parser.rb +++ /dev/null @@ -1,200 +0,0 @@ -# frozen_string_literal: true - -require_relative 'bad_request' -require 'uri' - -module Rack - class QueryParser - DEFAULT_SEP = /& */n - COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n } - - # ParameterTypeError is the error that is raised when incoming structural - # parameters (parsed by parse_nested_query) contain conflicting types. - class ParameterTypeError < TypeError - include BadRequest - end - - # InvalidParameterError is the error that is raised when incoming structural - # parameters (parsed by parse_nested_query) contain invalid format or byte - # sequence. - class InvalidParameterError < ArgumentError - include BadRequest - end - - # ParamsTooDeepError is the error that is raised when params are recursively - # nested over the specified limit. - class ParamsTooDeepError < RangeError - include BadRequest - end - - def self.make_default(param_depth_limit) - new Params, param_depth_limit - end - - attr_reader :param_depth_limit - - def initialize(params_class, param_depth_limit) - @params_class = params_class - @param_depth_limit = param_depth_limit - end - - # Stolen from Mongrel, with some small modifications: - # Parses a query string by breaking it up at the '&'. You can also use this - # to parse cookies by changing the characters used in the second parameter - # (which defaults to '&'). - def parse_query(qs, separator = nil, &unescaper) - unescaper ||= method(:unescape) - - params = make_params - - (qs || '').split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p| - next if p.empty? - k, v = p.split('=', 2).map!(&unescaper) - - if cur = params[k] - if cur.class == Array - params[k] << v - else - params[k] = [cur, v] - end - else - params[k] = v - end - end - - return params.to_h - end - - # parse_nested_query expands a query string into structural types. Supported - # types are Arrays, Hashes and basic value types. It is possible to supply - # query strings with parameters of conflicting types, in this case a - # ParameterTypeError is raised. Users are encouraged to return a 400 in this - # case. - def parse_nested_query(qs, separator = nil) - params = make_params - - unless qs.nil? || qs.empty? - (qs || '').split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p| - k, v = p.split('=', 2).map! { |s| unescape(s) } - - _normalize_params(params, k, v, 0) - end - end - - return params.to_h - rescue ArgumentError => e - raise InvalidParameterError, e.message, e.backtrace - end - - # normalize_params recursively expands parameters into structural types. If - # the structural types represented by two different parameter names are in - # conflict, a ParameterTypeError is raised. The depth argument is deprecated - # and should no longer be used, it is kept for backwards compatibility with - # earlier versions of rack. - def normalize_params(params, name, v, _depth=nil) - _normalize_params(params, name, v, 0) - end - - private def _normalize_params(params, name, v, depth) - raise ParamsTooDeepError if depth >= param_depth_limit - - if !name - # nil name, treat same as empty string (required by tests) - k = after = '' - elsif depth == 0 - # Start of parsing, don't treat [] or [ at start of string specially - if start = name.index('[', 1) - # Start of parameter nesting, use part before brackets as key - k = name[0, start] - after = name[start, name.length] - else - # Plain parameter with no nesting - k = name - after = '' - end - elsif name.start_with?('[]') - # Array nesting - k = '[]' - after = name[2, name.length] - elsif name.start_with?('[') && (start = name.index(']', 1)) - # Hash nesting, use the part inside brackets as the key - k = name[1, start-1] - after = name[start+1, name.length] - else - # Probably malformed input, nested but not starting with [ - # treat full name as key for backwards compatibility. - k = name - after = '' - end - - return if k.empty? - - if after == '' - if k == '[]' && depth != 0 - return [v] - else - params[k] = v - end - elsif after == "[" - params[name] = v - elsif after == "[]" - params[k] ||= [] - raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) - params[k] << v - elsif after.start_with?('[]') - # Recognize x[][y] (hash inside array) parameters - unless after[2] == '[' && after.end_with?(']') && (child_key = after[3, after.length-4]) && !child_key.empty? && !child_key.index('[') && !child_key.index(']') - # Handle other nested array parameters - child_key = after[2, after.length] - end - params[k] ||= [] - raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) - if params_hash_type?(params[k].last) && !params_hash_has_key?(params[k].last, child_key) - _normalize_params(params[k].last, child_key, v, depth + 1) - else - params[k] << _normalize_params(make_params, child_key, v, depth + 1) - end - else - params[k] ||= make_params - raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k]) - params[k] = _normalize_params(params[k], after, v, depth + 1) - end - - params - end - - def make_params - @params_class.new - end - - def new_depth_limit(param_depth_limit) - self.class.new @params_class, param_depth_limit - end - - private - - def params_hash_type?(obj) - obj.kind_of?(@params_class) - end - - def params_hash_has_key?(hash, key) - return false if /\[\]/.match?(key) - - key.split(/[\[\]]+/).inject(hash) do |h, part| - next h if part == '' - return false unless params_hash_type?(h) && h.key?(part) - h[part] - end - - true - end - - def unescape(string, encoding = Encoding::UTF_8) - URI.decode_www_form_component(string, encoding) - end - - class Params < Hash - alias_method :to_params_hash, :to_h - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/recursive.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/recursive.rb deleted file mode 100644 index 0945d32..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/recursive.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -require 'uri' - -require_relative 'constants' - -module Rack - # Rack::ForwardRequest gets caught by Rack::Recursive and redirects - # the current request to the app at +url+. - # - # raise ForwardRequest.new("/not-found") - # - - class ForwardRequest < Exception - attr_reader :url, :env - - def initialize(url, env = {}) - @url = URI(url) - @env = env - - @env[PATH_INFO] = @url.path - @env[QUERY_STRING] = @url.query if @url.query - @env[HTTP_HOST] = @url.host if @url.host - @env[HTTP_PORT] = @url.port if @url.port - @env[RACK_URL_SCHEME] = @url.scheme if @url.scheme - - super "forwarding to #{url}" - end - end - - # Rack::Recursive allows applications called down the chain to - # include data from other applications (by using - # rack['rack.recursive.include'][...] or raise a - # ForwardRequest to redirect internally. - - class Recursive - def initialize(app) - @app = app - end - - def call(env) - dup._call(env) - end - - def _call(env) - @script_name = env[SCRIPT_NAME] - @app.call(env.merge(RACK_RECURSIVE_INCLUDE => method(:include))) - rescue ForwardRequest => req - call(env.merge(req.env)) - end - - def include(env, path) - unless path.index(@script_name) == 0 && (path[@script_name.size] == ?/ || - path[@script_name.size].nil?) - raise ArgumentError, "can only include below #{@script_name}, not #{path}" - end - - env = env.merge(PATH_INFO => path, - SCRIPT_NAME => @script_name, - REQUEST_METHOD => GET, - "CONTENT_LENGTH" => "0", "CONTENT_TYPE" => "", - RACK_INPUT => StringIO.new("")) - @app.call(env) - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/reloader.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/reloader.rb deleted file mode 100644 index a15064a..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/reloader.rb +++ /dev/null @@ -1,112 +0,0 @@ -# frozen_string_literal: true - -# Copyright (C) 2009-2018 Michael Fellinger -# Rack::Reloader is subject to the terms of an MIT-style license. -# See MIT-LICENSE or https://opensource.org/licenses/MIT. - -require 'pathname' - -module Rack - - # High performant source reloader - # - # This class acts as Rack middleware. - # - # What makes it especially suited for use in a production environment is that - # any file will only be checked once and there will only be made one system - # call stat(2). - # - # Please note that this will not reload files in the background, it does so - # only when actively called. - # - # It is performing a check/reload cycle at the start of every request, but - # also respects a cool down time, during which nothing will be done. - class Reloader - def initialize(app, cooldown = 10, backend = Stat) - @app = app - @cooldown = cooldown - @last = (Time.now - cooldown) - @cache = {} - @mtimes = {} - @reload_mutex = Mutex.new - - extend backend - end - - def call(env) - if @cooldown and Time.now > @last + @cooldown - if Thread.list.size > 1 - @reload_mutex.synchronize{ reload! } - else - reload! - end - - @last = Time.now - end - - @app.call(env) - end - - def reload!(stderr = $stderr) - rotation do |file, mtime| - previous_mtime = @mtimes[file] ||= mtime - safe_load(file, mtime, stderr) if mtime > previous_mtime - end - end - - # A safe Kernel::load, issuing the hooks depending on the results - def safe_load(file, mtime, stderr = $stderr) - load(file) - stderr.puts "#{self.class}: reloaded `#{file}'" - file - rescue LoadError, SyntaxError => ex - stderr.puts ex - ensure - @mtimes[file] = mtime - end - - module Stat - def rotation - files = [$0, *$LOADED_FEATURES].uniq - paths = ['./', *$LOAD_PATH].uniq - - files.map{|file| - next if /\.(so|bundle)$/.match?(file) # cannot reload compiled files - - found, stat = figure_path(file, paths) - next unless found && stat && mtime = stat.mtime - - @cache[file] = found - - yield(found, mtime) - }.compact - end - - # Takes a relative or absolute +file+ name, a couple possible +paths+ that - # the +file+ might reside in. Returns the full path and File::Stat for the - # path. - def figure_path(file, paths) - found = @cache[file] - found = file if !found and Pathname.new(file).absolute? - found, stat = safe_stat(found) - return found, stat if found - - paths.find do |possible_path| - path = ::File.join(possible_path, file) - found, stat = safe_stat(path) - return ::File.expand_path(found), stat if found - end - - return false, false - end - - def safe_stat(file) - return unless file - stat = ::File.stat(file) - return file, stat if stat.file? - rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ESRCH - @cache.delete(file) and false - end - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/request.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/request.rb deleted file mode 100644 index 93526a0..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/request.rb +++ /dev/null @@ -1,796 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' -require_relative 'media_type' - -module Rack - # Rack::Request provides a convenient interface to a Rack - # environment. It is stateless, the environment +env+ passed to the - # constructor will be directly modified. - # - # req = Rack::Request.new(env) - # req.post? - # req.params["data"] - - class Request - class << self - attr_accessor :ip_filter - - # The priority when checking forwarded headers. The default - # is [:forwarded, :x_forwarded], which means, check the - # +Forwarded+ header first, followed by the appropriate - # X-Forwarded-* header. You can revert the priority by - # reversing the priority, or remove checking of either - # or both headers by removing elements from the array. - # - # This should be set as appropriate in your environment - # based on what reverse proxies are in use. If you are not - # using reverse proxies, you should probably use an empty - # array. - attr_accessor :forwarded_priority - - # The priority when checking either the X-Forwarded-Proto - # or X-Forwarded-Scheme header for the forwarded protocol. - # The default is [:proto, :scheme], to try the - # X-Forwarded-Proto header before the - # X-Forwarded-Scheme header. Rack 2 had behavior - # similar to [:scheme, :proto]. You can remove either or - # both of the entries in array to ignore that respective header. - attr_accessor :x_forwarded_proto_priority - end - - @forwarded_priority = [:forwarded, :x_forwarded] - @x_forwarded_proto_priority = [:proto, :scheme] - - valid_ipv4_octet = /\.(25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])/ - - trusted_proxies = Regexp.union( - /\A127#{valid_ipv4_octet}{3}\z/, # localhost IPv4 range 127.x.x.x, per RFC-3330 - /\A::1\z/, # localhost IPv6 ::1 - /\Af[cd][0-9a-f]{2}(?::[0-9a-f]{0,4}){0,7}\z/i, # private IPv6 range fc00 .. fdff - /\A10#{valid_ipv4_octet}{3}\z/, # private IPv4 range 10.x.x.x - /\A172\.(1[6-9]|2[0-9]|3[01])#{valid_ipv4_octet}{2}\z/, # private IPv4 range 172.16.0.0 .. 172.31.255.255 - /\A192\.168#{valid_ipv4_octet}{2}\z/, # private IPv4 range 192.168.x.x - /\Alocalhost\z|\Aunix(\z|:)/i, # localhost hostname, and unix domain sockets - ) - - self.ip_filter = lambda { |ip| trusted_proxies.match?(ip) } - - ALLOWED_SCHEMES = %w(https http wss ws).freeze - - def initialize(env) - @env = env - @params = nil - end - - def params - @params ||= super - end - - def update_param(k, v) - super - @params = nil - end - - def delete_param(k) - v = super - @params = nil - v - end - - module Env - # The environment of the request. - attr_reader :env - - def initialize(env) - @env = env - # This module is included at least in `ActionDispatch::Request` - # The call to `super()` allows additional mixed-in initializers are called - super() - end - - # Predicate method to test to see if `name` has been set as request - # specific data - def has_header?(name) - @env.key? name - end - - # Get a request specific value for `name`. - def get_header(name) - @env[name] - end - - # If a block is given, it yields to the block if the value hasn't been set - # on the request. - def fetch_header(name, &block) - @env.fetch(name, &block) - end - - # Loops through each key / value pair in the request specific data. - def each_header(&block) - @env.each(&block) - end - - # Set a request specific value for `name` to `v` - def set_header(name, v) - @env[name] = v - end - - # Add a header that may have multiple values. - # - # Example: - # request.add_header 'Accept', 'image/png' - # request.add_header 'Accept', '*/*' - # - # assert_equal 'image/png,*/*', request.get_header('Accept') - # - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 - def add_header(key, v) - if v.nil? - get_header key - elsif has_header? key - set_header key, "#{get_header key},#{v}" - else - set_header key, v - end - end - - # Delete a request specific value for `name`. - def delete_header(name) - @env.delete name - end - - def initialize_copy(other) - @env = other.env.dup - end - end - - module Helpers - # The set of form-data media-types. Requests that do not indicate - # one of the media types present in this list will not be eligible - # for form-data / param parsing. - FORM_DATA_MEDIA_TYPES = [ - 'application/x-www-form-urlencoded', - 'multipart/form-data' - ] - - # The set of media-types. Requests that do not indicate - # one of the media types present in this list will not be eligible - # for param parsing like soap attachments or generic multiparts - PARSEABLE_DATA_MEDIA_TYPES = [ - 'multipart/related', - 'multipart/mixed' - ] - - # Default ports depending on scheme. Used to decide whether or not - # to include the port in a generated URI. - DEFAULT_PORTS = { 'http' => 80, 'https' => 443, 'coffee' => 80 } - - # The address of the client which connected to the proxy. - HTTP_X_FORWARDED_FOR = 'HTTP_X_FORWARDED_FOR' - - # The contents of the host/:authority header sent to the proxy. - HTTP_X_FORWARDED_HOST = 'HTTP_X_FORWARDED_HOST' - - HTTP_FORWARDED = 'HTTP_FORWARDED' - - # The value of the scheme sent to the proxy. - HTTP_X_FORWARDED_SCHEME = 'HTTP_X_FORWARDED_SCHEME' - - # The protocol used to connect to the proxy. - HTTP_X_FORWARDED_PROTO = 'HTTP_X_FORWARDED_PROTO' - - # The port used to connect to the proxy. - HTTP_X_FORWARDED_PORT = 'HTTP_X_FORWARDED_PORT' - - # Another way for specifying https scheme was used. - HTTP_X_FORWARDED_SSL = 'HTTP_X_FORWARDED_SSL' - - def body; get_header(RACK_INPUT) end - def script_name; get_header(SCRIPT_NAME).to_s end - def script_name=(s); set_header(SCRIPT_NAME, s.to_s) end - - def path_info; get_header(PATH_INFO).to_s end - def path_info=(s); set_header(PATH_INFO, s.to_s) end - - def request_method; get_header(REQUEST_METHOD) end - def query_string; get_header(QUERY_STRING).to_s end - def content_length; get_header('CONTENT_LENGTH') end - def logger; get_header(RACK_LOGGER) end - def user_agent; get_header('HTTP_USER_AGENT') end - - # the referer of the client - def referer; get_header('HTTP_REFERER') end - alias referrer referer - - def session - fetch_header(RACK_SESSION) do |k| - set_header RACK_SESSION, default_session - end - end - - def session_options - fetch_header(RACK_SESSION_OPTIONS) do |k| - set_header RACK_SESSION_OPTIONS, {} - end - end - - # Checks the HTTP request method (or verb) to see if it was of type DELETE - def delete?; request_method == DELETE end - - # Checks the HTTP request method (or verb) to see if it was of type GET - def get?; request_method == GET end - - # Checks the HTTP request method (or verb) to see if it was of type HEAD - def head?; request_method == HEAD end - - # Checks the HTTP request method (or verb) to see if it was of type OPTIONS - def options?; request_method == OPTIONS end - - # Checks the HTTP request method (or verb) to see if it was of type LINK - def link?; request_method == LINK end - - # Checks the HTTP request method (or verb) to see if it was of type PATCH - def patch?; request_method == PATCH end - - # Checks the HTTP request method (or verb) to see if it was of type POST - def post?; request_method == POST end - - # Checks the HTTP request method (or verb) to see if it was of type PUT - def put?; request_method == PUT end - - # Checks the HTTP request method (or verb) to see if it was of type TRACE - def trace?; request_method == TRACE end - - # Checks the HTTP request method (or verb) to see if it was of type UNLINK - def unlink?; request_method == UNLINK end - - def scheme - if get_header(HTTPS) == 'on' - 'https' - elsif get_header(HTTP_X_FORWARDED_SSL) == 'on' - 'https' - elsif forwarded_scheme - forwarded_scheme - else - get_header(RACK_URL_SCHEME) - end - end - - # The authority of the incoming request as defined by RFC3976. - # https://tools.ietf.org/html/rfc3986#section-3.2 - # - # In HTTP/1, this is the `host` header. - # In HTTP/2, this is the `:authority` pseudo-header. - def authority - forwarded_authority || host_authority || server_authority - end - - # The authority as defined by the `SERVER_NAME` and `SERVER_PORT` - # variables. - def server_authority - host = self.server_name - port = self.server_port - - if host - if port - "#{host}:#{port}" - else - host - end - end - end - - def server_name - get_header(SERVER_NAME) - end - - def server_port - get_header(SERVER_PORT) - end - - def cookies - hash = fetch_header(RACK_REQUEST_COOKIE_HASH) do |key| - set_header(key, {}) - end - - string = get_header(HTTP_COOKIE) - - unless string == get_header(RACK_REQUEST_COOKIE_STRING) - hash.replace Utils.parse_cookies_header(string) - set_header(RACK_REQUEST_COOKIE_STRING, string) - end - - hash - end - - def content_type - content_type = get_header('CONTENT_TYPE') - content_type.nil? || content_type.empty? ? nil : content_type - end - - def xhr? - get_header("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" - end - - # The `HTTP_HOST` header. - def host_authority - get_header(HTTP_HOST) - end - - def host_with_port(authority = self.authority) - host, _, port = split_authority(authority) - - if port == DEFAULT_PORTS[self.scheme] - host - else - authority - end - end - - # Returns a formatted host, suitable for being used in a URI. - def host - split_authority(self.authority)[0] - end - - # Returns an address suitable for being to resolve to an address. - # In the case of a domain name or IPv4 address, the result is the same - # as +host+. In the case of IPv6 or future address formats, the square - # brackets are removed. - def hostname - split_authority(self.authority)[1] - end - - def port - if authority = self.authority - _, _, port = split_authority(authority) - end - - port || forwarded_port&.last || DEFAULT_PORTS[scheme] || server_port - end - - def forwarded_for - forwarded_priority.each do |type| - case type - when :forwarded - if forwarded_for = get_http_forwarded(:for) - return(forwarded_for.map! do |authority| - split_authority(authority)[1] - end) - end - when :x_forwarded - if value = get_header(HTTP_X_FORWARDED_FOR) - return(split_header(value).map do |authority| - split_authority(wrap_ipv6(authority))[1] - end) - end - end - end - - nil - end - - def forwarded_port - forwarded_priority.each do |type| - case type - when :forwarded - if forwarded = get_http_forwarded(:for) - return(forwarded.map do |authority| - split_authority(authority)[2] - end.compact) - end - when :x_forwarded - if value = get_header(HTTP_X_FORWARDED_PORT) - return split_header(value).map(&:to_i) - end - end - end - - nil - end - - def forwarded_authority - forwarded_priority.each do |type| - case type - when :forwarded - if forwarded = get_http_forwarded(:host) - return forwarded.last - end - when :x_forwarded - if value = get_header(HTTP_X_FORWARDED_HOST) - return wrap_ipv6(split_header(value).last) - end - end - end - - nil - end - - def ssl? - scheme == 'https' || scheme == 'wss' - end - - def ip - remote_addresses = split_header(get_header('REMOTE_ADDR')) - external_addresses = reject_trusted_ip_addresses(remote_addresses) - - unless external_addresses.empty? - return external_addresses.last - end - - if (forwarded_for = self.forwarded_for) && !forwarded_for.empty? - # The forwarded for addresses are ordered: client, proxy1, proxy2. - # So we reject all the trusted addresses (proxy*) and return the - # last client. Or if we trust everyone, we just return the first - # address. - return reject_trusted_ip_addresses(forwarded_for).last || forwarded_for.first - end - - # If all the addresses are trusted, and we aren't forwarded, just return - # the first remote address, which represents the source of the request. - remote_addresses.first - end - - # The media type (type/subtype) portion of the CONTENT_TYPE header - # without any media type parameters. e.g., when CONTENT_TYPE is - # "text/plain;charset=utf-8", the media-type is "text/plain". - # - # For more information on the use of media types in HTTP, see: - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 - def media_type - MediaType.type(content_type) - end - - # The media type parameters provided in CONTENT_TYPE as a Hash, or - # an empty Hash if no CONTENT_TYPE or media-type parameters were - # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", - # this method responds with the following Hash: - # { 'charset' => 'utf-8' } - def media_type_params - MediaType.params(content_type) - end - - # The character set of the request body if a "charset" media type - # parameter was given, or nil if no "charset" was specified. Note - # that, per RFC2616, text/* media types that specify no explicit - # charset are to be considered ISO-8859-1. - def content_charset - media_type_params['charset'] - end - - # Determine whether the request body contains form-data by checking - # the request content-type for one of the media-types: - # "application/x-www-form-urlencoded" or "multipart/form-data". The - # list of form-data media types can be modified through the - # +FORM_DATA_MEDIA_TYPES+ array. - # - # A request body is also assumed to contain form-data when no - # content-type header is provided and the request_method is POST. - def form_data? - type = media_type - meth = get_header(RACK_METHODOVERRIDE_ORIGINAL_METHOD) || get_header(REQUEST_METHOD) - - (meth == POST && type.nil?) || FORM_DATA_MEDIA_TYPES.include?(type) - end - - # Determine whether the request body contains data by checking - # the request media_type against registered parse-data media-types - def parseable_data? - PARSEABLE_DATA_MEDIA_TYPES.include?(media_type) - end - - # Returns the data received in the query string. - def GET - rr_query_string = get_header(RACK_REQUEST_QUERY_STRING) - query_string = self.query_string - if rr_query_string == query_string - get_header(RACK_REQUEST_QUERY_HASH) - else - if rr_query_string - warn "query string used for GET parsing different from current query string. Starting in Rack 3.2, Rack will used the cached GET value instead of parsing the current query string.", uplevel: 1 - end - query_hash = parse_query(query_string, '&') - set_header(RACK_REQUEST_QUERY_STRING, query_string) - set_header(RACK_REQUEST_QUERY_HASH, query_hash) - end - end - - # Returns the data received in the request body. - # - # This method support both application/x-www-form-urlencoded and - # multipart/form-data. - def POST - if error = get_header(RACK_REQUEST_FORM_ERROR) - raise error.class, error.message, cause: error.cause - end - - begin - rack_input = get_header(RACK_INPUT) - - # If the form hash was already memoized: - if form_hash = get_header(RACK_REQUEST_FORM_HASH) - form_input = get_header(RACK_REQUEST_FORM_INPUT) - # And it was memoized from the same input: - if form_input.equal?(rack_input) - return form_hash - elsif form_input - warn "input stream used for POST parsing different from current input stream. Starting in Rack 3.2, Rack will used the cached POST value instead of parsing the current input stream.", uplevel: 1 - end - end - - # Otherwise, figure out how to parse the input: - if rack_input.nil? - set_header RACK_REQUEST_FORM_INPUT, nil - set_header(RACK_REQUEST_FORM_HASH, {}) - elsif form_data? || parseable_data? - if pairs = Rack::Multipart.parse_multipart(env, Rack::Multipart::ParamList) - set_header RACK_REQUEST_FORM_PAIRS, pairs - set_header RACK_REQUEST_FORM_HASH, expand_param_pairs(pairs) - else - form_vars = get_header(RACK_INPUT).read - - # Fix for Safari Ajax postings that always append \0 - # form_vars.sub!(/\0\z/, '') # performance replacement: - form_vars.slice!(-1) if form_vars.end_with?("\0") - - set_header RACK_REQUEST_FORM_VARS, form_vars - set_header RACK_REQUEST_FORM_HASH, parse_query(form_vars, '&') - end - - set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) - get_header RACK_REQUEST_FORM_HASH - else - set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) - set_header(RACK_REQUEST_FORM_HASH, {}) - end - rescue => error - set_header(RACK_REQUEST_FORM_ERROR, error) - raise - end - end - - # The union of GET and POST data. - # - # Note that modifications will not be persisted in the env. Use update_param or delete_param if you want to destructively modify params. - def params - self.GET.merge(self.POST) - end - - # Destructively update a parameter, whether it's in GET and/or POST. Returns nil. - # - # The parameter is updated wherever it was previous defined, so GET, POST, or both. If it wasn't previously defined, it's inserted into GET. - # - # env['rack.input'] is not touched. - def update_param(k, v) - found = false - if self.GET.has_key?(k) - found = true - self.GET[k] = v - end - if self.POST.has_key?(k) - found = true - self.POST[k] = v - end - unless found - self.GET[k] = v - end - end - - # Destructively delete a parameter, whether it's in GET or POST. Returns the value of the deleted parameter. - # - # If the parameter is in both GET and POST, the POST value takes precedence since that's how #params works. - # - # env['rack.input'] is not touched. - def delete_param(k) - post_value, get_value = self.POST.delete(k), self.GET.delete(k) - post_value || get_value - end - - def base_url - "#{scheme}://#{host_with_port}" - end - - # Tries to return a remake of the original request URL as a string. - def url - base_url + fullpath - end - - def path - script_name + path_info - end - - def fullpath - query_string.empty? ? path : "#{path}?#{query_string}" - end - - def accept_encoding - parse_http_accept_header(get_header("HTTP_ACCEPT_ENCODING")) - end - - def accept_language - parse_http_accept_header(get_header("HTTP_ACCEPT_LANGUAGE")) - end - - def trusted_proxy?(ip) - Rack::Request.ip_filter.call(ip) - end - - # like Hash#values_at - def values_at(*keys) - warn("Request#values_at is deprecated and will be removed in a future version of Rack. Please use request.params.values_at instead", uplevel: 1) - - keys.map { |key| params[key] } - end - - private - - def default_session; {}; end - - # Assist with compatibility when processing `X-Forwarded-For`. - def wrap_ipv6(host) - # Even thought IPv6 addresses should be wrapped in square brackets, - # sometimes this is not done in various legacy/underspecified headers. - # So we try to fix this situation for compatibility reasons. - - # Try to detect IPv6 addresses which aren't escaped yet: - if !host.start_with?('[') && host.count(':') > 1 - "[#{host}]" - else - host - end - end - - def parse_http_accept_header(header) - # It would be nice to use filter_map here, but it's Ruby 2.7+ - parts = header.to_s.split(',') - - parts.map! do |part| - part.strip! - next if part.empty? - - attribute, parameters = part.split(';', 2) - attribute.strip! - parameters&.strip! - quality = 1.0 - if parameters and /\Aq=([\d.]+)/ =~ parameters - quality = $1.to_f - end - [attribute, quality] - end - - parts.compact! - - parts - end - - # Get an array of values set in the RFC 7239 `Forwarded` request header. - def get_http_forwarded(token) - Utils.forwarded_values(get_header(HTTP_FORWARDED))&.[](token) - end - - def query_parser - Utils.default_query_parser - end - - def parse_query(qs, d = '&') - query_parser.parse_nested_query(qs, d) - end - - def parse_multipart - Rack::Multipart.extract_multipart(self, query_parser) - end - - def expand_param_pairs(pairs, query_parser = query_parser()) - params = query_parser.make_params - - pairs.each do |k, v| - query_parser.normalize_params(params, k, v) - end - - params.to_params_hash - end - - def split_header(value) - value ? value.strip.split(/[,\s]+/) : [] - end - - # ipv6 extracted from resolv stdlib, simplified - # to remove numbered match group creation. - ipv6 = Regexp.union( - /(?:[0-9A-Fa-f]{1,4}:){7} - [0-9A-Fa-f]{1,4}/x, - /(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: - (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?/x, - /(?:[0-9A-Fa-f]{1,4}:){6,6} - \d+\.\d+\.\d+\.\d+/x, - /(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: - (?:[0-9A-Fa-f]{1,4}:)* - \d+\.\d+\.\d+\.\d+/x, - /[Ff][Ee]80 - (?::[0-9A-Fa-f]{1,4}){7} - %[-0-9A-Za-z._~]+/x, - /[Ff][Ee]80: - (?: - (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: - (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? - | - :(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? - )? - :[0-9A-Fa-f]{1,4}%[-0-9A-Za-z._~]+/x) - - AUTHORITY = / - \A - (? - # Match IPv6 as a string of hex digits and colons in square brackets - \[(?
#{ipv6})\] - | - # Match any other printable string (except square brackets) as a hostname - (?
[[[:graph:]&&[^\[\]]]]*?) - ) - (:(?\d+))? - \z - /x - - private_constant :AUTHORITY - - def split_authority(authority) - return [] if authority.nil? - return [] unless match = AUTHORITY.match(authority) - return match[:host], match[:address], match[:port]&.to_i - end - - def reject_trusted_ip_addresses(ip_addresses) - ip_addresses.reject { |ip| trusted_proxy?(ip) } - end - - FORWARDED_SCHEME_HEADERS = { - proto: HTTP_X_FORWARDED_PROTO, - scheme: HTTP_X_FORWARDED_SCHEME - }.freeze - private_constant :FORWARDED_SCHEME_HEADERS - def forwarded_scheme - forwarded_priority.each do |type| - case type - when :forwarded - if (forwarded_proto = get_http_forwarded(:proto)) && - (scheme = allowed_scheme(forwarded_proto.last)) - return scheme - end - when :x_forwarded - x_forwarded_proto_priority.each do |x_type| - if header = FORWARDED_SCHEME_HEADERS[x_type] - split_header(get_header(header)).reverse_each do |scheme| - if allowed_scheme(scheme) - return scheme - end - end - end - end - end - end - - nil - end - - def allowed_scheme(header) - header if ALLOWED_SCHEMES.include?(header) - end - - def forwarded_priority - Request.forwarded_priority - end - - def x_forwarded_proto_priority - Request.x_forwarded_proto_priority - end - end - - include Env - include Helpers - end -end - -# :nocov: -require_relative 'multipart' unless defined?(Rack::Multipart) -# :nocov: diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/response.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/response.rb deleted file mode 100644 index ece451d..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/response.rb +++ /dev/null @@ -1,403 +0,0 @@ -# frozen_string_literal: true - -require 'time' - -require_relative 'constants' -require_relative 'utils' -require_relative 'media_type' -require_relative 'headers' - -module Rack - # Rack::Response provides a convenient interface to create a Rack - # response. - # - # It allows setting of headers and cookies, and provides useful - # defaults (an OK response with empty headers and body). - # - # You can use Response#write to iteratively generate your response, - # but note that this is buffered by Rack::Response until you call - # +finish+. +finish+ however can take a block inside which calls to - # +write+ are synchronous with the Rack response. - # - # Your application's +call+ should end returning Response#finish. - class Response - def self.[](status, headers, body) - self.new(body, status, headers) - end - - CHUNKED = 'chunked' - STATUS_WITH_NO_ENTITY_BODY = Utils::STATUS_WITH_NO_ENTITY_BODY - - attr_accessor :length, :status, :body - attr_reader :headers - - # Initialize the response object with the specified +body+, +status+ - # and +headers+. - # - # If the +body+ is +nil+, construct an empty response object with internal - # buffering. - # - # If the +body+ responds to +to_str+, assume it's a string-like object and - # construct a buffered response object containing using that string as the - # initial contents of the buffer. - # - # Otherwise it is expected +body+ conforms to the normal requirements of a - # Rack response body, typically implementing one of +each+ (enumerable - # body) or +call+ (streaming body). - # - # The +status+ defaults to +200+ which is the "OK" HTTP status code. You - # can provide any other valid status code. - # - # The +headers+ must be a +Hash+ of key-value header pairs which conform to - # the Rack specification for response headers. The key must be a +String+ - # instance and the value can be either a +String+ or +Array+ instance. - def initialize(body = nil, status = 200, headers = {}) - @status = status.to_i - - unless headers.is_a?(Hash) - raise ArgumentError, "Headers must be a Hash!" - end - - @headers = Headers.new - # Convert headers input to a plain hash with lowercase keys. - headers.each do |k, v| - @headers[k] = v - end - - @writer = self.method(:append) - - @block = nil - - # Keep track of whether we have expanded the user supplied body. - if body.nil? - @body = [] - @buffered = true - # Body is unspecified - it may be a buffered response, or it may be a HEAD response. - @length = nil - elsif body.respond_to?(:to_str) - @body = [body] - @buffered = true - @length = body.to_str.bytesize - else - @body = body - @buffered = nil # undetermined as of yet. - @length = nil - end - - yield self if block_given? - end - - def redirect(target, status = 302) - self.status = status - self.location = target - end - - def chunked? - CHUNKED == get_header(TRANSFER_ENCODING) - end - - def no_entity_body? - # The response body is an enumerable body and it is not allowed to have an entity body. - @body.respond_to?(:each) && STATUS_WITH_NO_ENTITY_BODY[@status] - end - - # Generate a response array consistent with the requirements of the SPEC. - # @return [Array] a 3-tuple suitable of `[status, headers, body]` - # which is suitable to be returned from the middleware `#call(env)` method. - def finish(&block) - if no_entity_body? - delete_header CONTENT_TYPE - delete_header CONTENT_LENGTH - close - return [@status, @headers, []] - else - if block_given? - # We don't add the content-length here as the user has provided a block that can #write additional chunks to the body. - @block = block - return [@status, @headers, self] - else - # If we know the length of the body, set the content-length header... except if we are chunked? which is a legacy special case where the body might already be encoded and thus the actual encoded body length and the content-length are likely to be different. - if @length && !chunked? - @headers[CONTENT_LENGTH] = @length.to_s - end - return [@status, @headers, @body] - end - end - end - - alias to_a finish # For *response - - def each(&callback) - @body.each(&callback) - @buffered = true - - if @block - @writer = callback - @block.call(self) - end - end - - # Append a chunk to the response body. - # - # Converts the response into a buffered response if it wasn't already. - # - # NOTE: Do not mix #write and direct #body access! - # - def write(chunk) - buffered_body! - - @writer.call(chunk.to_s) - end - - def close - @body.close if @body.respond_to?(:close) - end - - def empty? - @block == nil && @body.empty? - end - - def has_header?(key) - raise ArgumentError unless key.is_a?(String) - @headers.key?(key) - end - def get_header(key) - raise ArgumentError unless key.is_a?(String) - @headers[key] - end - def set_header(key, value) - raise ArgumentError unless key.is_a?(String) - @headers[key] = value - end - def delete_header(key) - raise ArgumentError unless key.is_a?(String) - @headers.delete key - end - - alias :[] :get_header - alias :[]= :set_header - - module Helpers - def invalid?; status < 100 || status >= 600; end - - def informational?; status >= 100 && status < 200; end - def successful?; status >= 200 && status < 300; end - def redirection?; status >= 300 && status < 400; end - def client_error?; status >= 400 && status < 500; end - def server_error?; status >= 500 && status < 600; end - - def ok?; status == 200; end - def created?; status == 201; end - def accepted?; status == 202; end - def no_content?; status == 204; end - def moved_permanently?; status == 301; end - def bad_request?; status == 400; end - def unauthorized?; status == 401; end - def forbidden?; status == 403; end - def not_found?; status == 404; end - def method_not_allowed?; status == 405; end - def not_acceptable?; status == 406; end - def request_timeout?; status == 408; end - def precondition_failed?; status == 412; end - def unprocessable?; status == 422; end - - def redirect?; [301, 302, 303, 307, 308].include? status; end - - def include?(header) - has_header?(header) - end - - # Add a header that may have multiple values. - # - # Example: - # response.add_header 'vary', 'accept-encoding' - # response.add_header 'vary', 'cookie' - # - # assert_equal 'accept-encoding,cookie', response.get_header('vary') - # - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 - def add_header(key, value) - raise ArgumentError unless key.is_a?(String) - - if value.nil? - return get_header(key) - end - - value = value.to_s - - if header = get_header(key) - if header.is_a?(Array) - header << value - else - set_header(key, [header, value]) - end - else - set_header(key, value) - end - end - - # Get the content type of the response. - def content_type - get_header CONTENT_TYPE - end - - # Set the content type of the response. - def content_type=(content_type) - set_header CONTENT_TYPE, content_type - end - - def media_type - MediaType.type(content_type) - end - - def media_type_params - MediaType.params(content_type) - end - - def content_length - cl = get_header CONTENT_LENGTH - cl ? cl.to_i : cl - end - - def location - get_header "location" - end - - def location=(location) - set_header "location", location - end - - def set_cookie(key, value) - add_header SET_COOKIE, Utils.set_cookie_header(key, value) - end - - def delete_cookie(key, value = {}) - set_header(SET_COOKIE, - Utils.delete_set_cookie_header!( - get_header(SET_COOKIE), key, value - ) - ) - end - - def set_cookie_header - get_header SET_COOKIE - end - - def set_cookie_header=(value) - set_header SET_COOKIE, value - end - - def cache_control - get_header CACHE_CONTROL - end - - def cache_control=(value) - set_header CACHE_CONTROL, value - end - - # Specifies that the content shouldn't be cached. Overrides `cache!` if already called. - def do_not_cache! - set_header CACHE_CONTROL, "no-cache, must-revalidate" - set_header EXPIRES, Time.now.httpdate - end - - # Specify that the content should be cached. - # @param duration [Integer] The number of seconds until the cache expires. - # @option directive [String] The cache control directive, one of "public", "private", "no-cache" or "no-store". - def cache!(duration = 3600, directive: "public") - unless headers[CACHE_CONTROL] =~ /no-cache/ - set_header CACHE_CONTROL, "#{directive}, max-age=#{duration}" - set_header EXPIRES, (Time.now + duration).httpdate - end - end - - def etag - get_header ETAG - end - - def etag=(value) - set_header ETAG, value - end - - protected - - # Convert the body of this response into an internally buffered Array if possible. - # - # `@buffered` is a ternary value which indicates whether the body is buffered. It can be: - # * `nil` - The body has not been buffered yet. - # * `true` - The body is buffered as an Array instance. - # * `false` - The body is not buffered and cannot be buffered. - # - # @return [Boolean] whether the body is buffered as an Array instance. - def buffered_body! - if @buffered.nil? - if @body.is_a?(Array) - # The user supplied body was an array: - @body = @body.compact - @length = @body.sum{|part| part.bytesize} - @buffered = true - elsif @body.respond_to?(:each) - # Turn the user supplied body into a buffered array: - body = @body - @body = Array.new - @buffered = true - - body.each do |part| - @writer.call(part.to_s) - end - - body.close if body.respond_to?(:close) - else - # We don't know how to buffer the user-supplied body: - @buffered = false - end - end - - return @buffered - end - - def append(chunk) - chunk = chunk.dup unless chunk.frozen? - @body << chunk - - if @length - @length += chunk.bytesize - elsif @buffered - @length = chunk.bytesize - end - - return chunk - end - end - - include Helpers - - class Raw - include Helpers - - attr_reader :headers - attr_accessor :status - - def initialize(status, headers) - @status = status - @headers = headers - end - - def has_header?(key) - headers.key?(key) - end - - def get_header(key) - headers[key] - end - - def set_header(key, value) - headers[key] = value - end - - def delete_header(key) - headers.delete(key) - end - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb deleted file mode 100644 index 730c6a2..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb +++ /dev/null @@ -1,113 +0,0 @@ -# -*- encoding: binary -*- -# frozen_string_literal: true - -require 'tempfile' - -require_relative 'constants' - -module Rack - # Class which can make any IO object rewindable, including non-rewindable ones. It does - # this by buffering the data into a tempfile, which is rewindable. - # - # Don't forget to call #close when you're done. This frees up temporary resources that - # RewindableInput uses, though it does *not* close the original IO object. - class RewindableInput - # Makes rack.input rewindable, for compatibility with applications and middleware - # designed for earlier versions of Rack (where rack.input was required to be - # rewindable). - class Middleware - def initialize(app) - @app = app - end - - def call(env) - env[RACK_INPUT] = RewindableInput.new(env[RACK_INPUT]) - @app.call(env) - end - end - - def initialize(io) - @io = io - @rewindable_io = nil - @unlinked = false - end - - def gets - make_rewindable unless @rewindable_io - @rewindable_io.gets - end - - def read(*args) - make_rewindable unless @rewindable_io - @rewindable_io.read(*args) - end - - def each(&block) - make_rewindable unless @rewindable_io - @rewindable_io.each(&block) - end - - def rewind - make_rewindable unless @rewindable_io - @rewindable_io.rewind - end - - def size - make_rewindable unless @rewindable_io - @rewindable_io.size - end - - # Closes this RewindableInput object without closing the originally - # wrapped IO object. Cleans up any temporary resources that this RewindableInput - # has created. - # - # This method may be called multiple times. It does nothing on subsequent calls. - def close - if @rewindable_io - if @unlinked - @rewindable_io.close - else - @rewindable_io.close! - end - @rewindable_io = nil - end - end - - private - - def make_rewindable - # Buffer all data into a tempfile. Since this tempfile is private to this - # RewindableInput object, we chmod it so that nobody else can read or write - # it. On POSIX filesystems we also unlink the file so that it doesn't - # even have a file entry on the filesystem anymore, though we can still - # access it because we have the file handle open. - @rewindable_io = Tempfile.new('RackRewindableInput') - @rewindable_io.chmod(0000) - @rewindable_io.set_encoding(Encoding::BINARY) - @rewindable_io.binmode - # :nocov: - if filesystem_has_posix_semantics? - raise 'Unlink failed. IO closed.' if @rewindable_io.closed? - @unlinked = true - end - # :nocov: - - buffer = "".dup - while @io.read(1024 * 4, buffer) - entire_buffer_written_out = false - while !entire_buffer_written_out - written = @rewindable_io.write(buffer) - entire_buffer_written_out = written == buffer.bytesize - if !entire_buffer_written_out - buffer.slice!(0 .. written - 1) - end - end - end - @rewindable_io.rewind - end - - def filesystem_has_posix_semantics? - RUBY_PLATFORM !~ /(mswin|mingw|cygwin|java)/ - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/runtime.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/runtime.rb deleted file mode 100644 index a1bfa69..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/runtime.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require_relative 'utils' - -module Rack - # Sets an "x-runtime" response header, indicating the response - # time of the request, in seconds - # - # You can put it right before the application to see the processing - # time, or before all the other middlewares to include time for them, - # too. - class Runtime - FORMAT_STRING = "%0.6f" # :nodoc: - HEADER_NAME = "x-runtime" # :nodoc: - - def initialize(app, name = nil) - @app = app - @header_name = HEADER_NAME - @header_name += "-#{name.to_s.downcase}" if name - end - - def call(env) - start_time = Utils.clock_time - _, headers, _ = response = @app.call(env) - - request_time = Utils.clock_time - start_time - - unless headers.key?(@header_name) - headers[@header_name] = FORMAT_STRING % request_time - end - - response - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/sendfile.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/sendfile.rb deleted file mode 100644 index 9c6e0c4..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/sendfile.rb +++ /dev/null @@ -1,167 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' -require_relative 'body_proxy' - -module Rack - - # = Sendfile - # - # The Sendfile middleware intercepts responses whose body is being - # served from a file and replaces it with a server specific x-sendfile - # header. The web server is then responsible for writing the file contents - # to the client. This can dramatically reduce the amount of work required - # by the Ruby backend and takes advantage of the web server's optimized file - # delivery code. - # - # In order to take advantage of this middleware, the response body must - # respond to +to_path+ and the request must include an x-sendfile-type - # header. Rack::Files and other components implement +to_path+ so there's - # rarely anything you need to do in your application. The x-sendfile-type - # header is typically set in your web servers configuration. The following - # sections attempt to document - # - # === Nginx - # - # Nginx supports the x-accel-redirect header. This is similar to x-sendfile - # but requires parts of the filesystem to be mapped into a private URL - # hierarchy. - # - # The following example shows the Nginx configuration required to create - # a private "/files/" area, enable x-accel-redirect, and pass the special - # x-sendfile-type and x-accel-mapping headers to the backend: - # - # location ~ /files/(.*) { - # internal; - # alias /var/www/$1; - # } - # - # location / { - # proxy_redirect off; - # - # proxy_set_header Host $host; - # proxy_set_header X-Real-IP $remote_addr; - # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # - # proxy_set_header x-sendfile-type x-accel-redirect; - # proxy_set_header x-accel-mapping /var/www/=/files/; - # - # proxy_pass http://127.0.0.1:8080/; - # } - # - # Note that the x-sendfile-type header must be set exactly as shown above. - # The x-accel-mapping header should specify the location on the file system, - # followed by an equals sign (=), followed name of the private URL pattern - # that it maps to. The middleware performs a simple substitution on the - # resulting path. - # - # See Also: https://www.nginx.com/resources/wiki/start/topics/examples/xsendfile - # - # === lighttpd - # - # Lighttpd has supported some variation of the x-sendfile header for some - # time, although only recent version support x-sendfile in a reverse proxy - # configuration. - # - # $HTTP["host"] == "example.com" { - # proxy-core.protocol = "http" - # proxy-core.balancer = "round-robin" - # proxy-core.backends = ( - # "127.0.0.1:8000", - # "127.0.0.1:8001", - # ... - # ) - # - # proxy-core.allow-x-sendfile = "enable" - # proxy-core.rewrite-request = ( - # "x-sendfile-type" => (".*" => "x-sendfile") - # ) - # } - # - # See Also: http://redmine.lighttpd.net/wiki/lighttpd/Docs:ModProxyCore - # - # === Apache - # - # x-sendfile is supported under Apache 2.x using a separate module: - # - # https://tn123.org/mod_xsendfile/ - # - # Once the module is compiled and installed, you can enable it using - # XSendFile config directive: - # - # RequestHeader Set x-sendfile-type x-sendfile - # ProxyPassReverse / http://localhost:8001/ - # XSendFile on - # - # === Mapping parameter - # - # The third parameter allows for an overriding extension of the - # x-accel-mapping header. Mappings should be provided in tuples of internal to - # external. The internal values may contain regular expression syntax, they - # will be matched with case indifference. - - class Sendfile - def initialize(app, variation = nil, mappings = []) - @app = app - @variation = variation - @mappings = mappings.map do |internal, external| - [/^#{internal}/i, external] - end - end - - def call(env) - _, headers, body = response = @app.call(env) - - if body.respond_to?(:to_path) - case type = variation(env) - when /x-accel-redirect/i - path = ::File.expand_path(body.to_path) - if url = map_accel_path(env, path) - headers[CONTENT_LENGTH] = '0' - # '?' must be percent-encoded because it is not query string but a part of path - headers[type.downcase] = ::Rack::Utils.escape_path(url).gsub('?', '%3F') - obody = body - response[2] = Rack::BodyProxy.new([]) do - obody.close if obody.respond_to?(:close) - end - else - env[RACK_ERRORS].puts "x-accel-mapping header missing" - end - when /x-sendfile|x-lighttpd-send-file/i - path = ::File.expand_path(body.to_path) - headers[CONTENT_LENGTH] = '0' - headers[type.downcase] = path - obody = body - response[2] = Rack::BodyProxy.new([]) do - obody.close if obody.respond_to?(:close) - end - when '', nil - else - env[RACK_ERRORS].puts "Unknown x-sendfile variation: '#{type}'.\n" - end - end - response - end - - private - def variation(env) - @variation || - env['sendfile.type'] || - env['HTTP_X_SENDFILE_TYPE'] - end - - def map_accel_path(env, path) - if mapping = @mappings.find { |internal, _| internal =~ path } - path.sub(*mapping) - elsif mapping = env['HTTP_X_ACCEL_MAPPING'] - mapping.split(',').map(&:strip).each do |m| - internal, external = m.split('=', 2).map(&:strip) - new_path = path.sub(/^#{internal}/i, external) - return new_path unless path == new_path - end - path - end - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb deleted file mode 100644 index 9172a4d..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb +++ /dev/null @@ -1,407 +0,0 @@ -# frozen_string_literal: true - -require 'erb' - -require_relative 'constants' -require_relative 'utils' -require_relative 'request' - -module Rack - # Rack::ShowExceptions catches all exceptions raised from the app it - # wraps. It shows a useful backtrace with the sourcefile and - # clickable context, the whole Rack environment and the request - # data. - # - # Be careful when you use this on public-facing sites as it could - # reveal information helpful to attackers. - - class ShowExceptions - CONTEXT = 7 - - Frame = Struct.new(:filename, :lineno, :function, - :pre_context_lineno, :pre_context, - :context_line, :post_context_lineno, - :post_context) - - def initialize(app) - @app = app - end - - def call(env) - @app.call(env) - rescue StandardError, LoadError, SyntaxError => e - exception_string = dump_exception(e) - - env[RACK_ERRORS].puts(exception_string) - env[RACK_ERRORS].flush - - if accepts_html?(env) - content_type = "text/html" - body = pretty(env, e) - else - content_type = "text/plain" - body = exception_string - end - - [ - 500, - { - CONTENT_TYPE => content_type, - CONTENT_LENGTH => body.bytesize.to_s, - }, - [body], - ] - end - - def prefers_plaintext?(env) - !accepts_html?(env) - end - - def accepts_html?(env) - Rack::Utils.best_q_match(env["HTTP_ACCEPT"], %w[text/html]) - end - private :accepts_html? - - def dump_exception(exception) - if exception.respond_to?(:detailed_message) - message = exception.detailed_message(highlight: false) - else - message = exception.message - end - string = "#{exception.class}: #{message}\n".dup - string << exception.backtrace.map { |l| "\t#{l}" }.join("\n") - string - end - - def pretty(env, exception) - req = Rack::Request.new(env) - - # This double assignment is to prevent an "unused variable" warning. - # Yes, it is dumb, but I don't like Ruby yelling at me. - path = path = (req.script_name + req.path_info).squeeze("/") - - # This double assignment is to prevent an "unused variable" warning. - # Yes, it is dumb, but I don't like Ruby yelling at me. - frames = frames = exception.backtrace.map { |line| - frame = Frame.new - if line =~ /(.*?):(\d+)(:in `(.*)')?/ - frame.filename = $1 - frame.lineno = $2.to_i - frame.function = $4 - - begin - lineno = frame.lineno - 1 - lines = ::File.readlines(frame.filename) - frame.pre_context_lineno = [lineno - CONTEXT, 0].max - frame.pre_context = lines[frame.pre_context_lineno...lineno] - frame.context_line = lines[lineno].chomp - frame.post_context_lineno = [lineno + CONTEXT, lines.size].min - frame.post_context = lines[lineno + 1..frame.post_context_lineno] - rescue - end - - frame - else - nil - end - }.compact - - template.result(binding) - end - - def template - TEMPLATE - end - - def h(obj) # :nodoc: - case obj - when String - Utils.escape_html(obj) - else - Utils.escape_html(obj.inspect) - end - end - - # :stopdoc: - - # adapted from Django - # Copyright (c) Django Software Foundation and individual contributors. - # Used under the modified BSD license: - # http://www.xfree86.org/3.3.6/COPYRIGHT2.html#5 - TEMPLATE = ERB.new(<<-'HTML'.gsub(/^ /, '')) - - - - - - <%=h exception.class %> at <%=h path %> - - - - - -
-

<%=h exception.class %> at <%=h path %>

- <% if exception.respond_to?(:detailed_message) %> -

<%=h exception.detailed_message(highlight: false) %>

- <% else %> -

<%=h exception.message %>

- <% end %> - - - - - - -
Ruby - <% if first = frames.first %> - <%=h first.filename %>: in <%=h first.function %>, line <%=h frames.first.lineno %> - <% else %> - unknown location - <% end %> -
Web<%=h req.request_method %> <%=h(req.host + path)%>
- -

Jump to:

- -
- -
-

Traceback (innermost first)

-
    - <% frames.each { |frame| %> -
  • - <%=h frame.filename %>: in <%=h frame.function %> - - <% if frame.context_line %> -
    - <% if frame.pre_context %> -
      - <% frame.pre_context.each { |line| %> -
    1. <%=h line %>
    2. - <% } %> -
    - <% end %> - -
      -
    1. <%=h frame.context_line %>...
    - - <% if frame.post_context %> -
      - <% frame.post_context.each { |line| %> -
    1. <%=h line %>
    2. - <% } %> -
    - <% end %> -
    - <% end %> -
  • - <% } %> -
-
- -
-

Request information

- -

GET

- <% if req.GET and not req.GET.empty? %> - - - - - - - - - <% req.GET.sort_by { |k, v| k.to_s }.each { |key, val| %> - - - - - <% } %> - -
VariableValue
<%=h key %>
<%=h val.inspect %>
- <% else %> -

No GET data.

- <% end %> - -

POST

- <% if ((req.POST and not req.POST.empty?) rescue (no_post_data = "Invalid POST data"; nil)) %> - - - - - - - - - <% req.POST.sort_by { |k, v| k.to_s }.each { |key, val| %> - - - - - <% } %> - -
VariableValue
<%=h key %>
<%=h val.inspect %>
- <% else %> -

<%= no_post_data || "No POST data" %>.

- <% end %> - - - - <% unless req.cookies.empty? %> - - - - - - - - - <% req.cookies.each { |key, val| %> - - - - - <% } %> - -
VariableValue
<%=h key %>
<%=h val.inspect %>
- <% else %> -

No cookie data.

- <% end %> - -

Rack ENV

- - - - - - - - - <% env.sort_by { |k, v| k.to_s }.each { |key, val| %> - - - - - <% } %> - -
VariableValue
<%=h key %>
<%=h val.inspect %>
- -
- -
-

- You're seeing this error because you use Rack::ShowExceptions. -

-
- - - - HTML - - # :startdoc: - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_status.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_status.rb deleted file mode 100644 index b6f75a0..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/show_status.rb +++ /dev/null @@ -1,123 +0,0 @@ -# frozen_string_literal: true - -require 'erb' - -require_relative 'constants' -require_relative 'utils' -require_relative 'request' -require_relative 'body_proxy' - -module Rack - # Rack::ShowStatus catches all empty responses and replaces them - # with a site explaining the error. - # - # Additional details can be put into rack.showstatus.detail - # and will be shown as HTML. If such details exist, the error page - # is always rendered, even if the reply was not empty. - - class ShowStatus - def initialize(app) - @app = app - @template = ERB.new(TEMPLATE) - end - - def call(env) - status, headers, body = response = @app.call(env) - empty = headers[CONTENT_LENGTH].to_i <= 0 - - # client or server error, or explicit message - if (status.to_i >= 400 && empty) || env[RACK_SHOWSTATUS_DETAIL] - # This double assignment is to prevent an "unused variable" warning. - # Yes, it is dumb, but I don't like Ruby yelling at me. - req = req = Rack::Request.new(env) - - message = Rack::Utils::HTTP_STATUS_CODES[status.to_i] || status.to_s - - # This double assignment is to prevent an "unused variable" warning. - # Yes, it is dumb, but I don't like Ruby yelling at me. - detail = detail = env[RACK_SHOWSTATUS_DETAIL] || message - - html = @template.result(binding) - size = html.bytesize - - response[2] = Rack::BodyProxy.new([html]) do - body.close if body.respond_to?(:close) - end - - headers[CONTENT_TYPE] = "text/html" - headers[CONTENT_LENGTH] = size.to_s - end - - response - end - - def h(obj) # :nodoc: - case obj - when String - Utils.escape_html(obj) - else - Utils.escape_html(obj.inspect) - end - end - - # :stopdoc: - -# adapted from Django -# Copyright (c) Django Software Foundation and individual contributors. -# Used under the modified BSD license: -# http://www.xfree86.org/3.3.6/COPYRIGHT2.html#5 -TEMPLATE = <<'HTML' - - - - - <%=h message %> at <%=h req.script_name + req.path_info %> - - - - -
-

<%=h message %> (<%= status.to_i %>)

- - - - - - - - - -
Request Method:<%=h req.request_method %>
Request URL:<%=h req.url %>
-
-
-

<%=h detail %>

-
- -
-

- You're seeing this error because you use Rack::ShowStatus. -

-
- - -HTML - - # :startdoc: - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/static.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/static.rb deleted file mode 100644 index 5c9b676..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/static.rb +++ /dev/null @@ -1,187 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'files' -require_relative 'mime' - -module Rack - - # The Rack::Static middleware intercepts requests for static files - # (javascript files, images, stylesheets, etc) based on the url prefixes or - # route mappings passed in the options, and serves them using a Rack::Files - # object. This allows a Rack stack to serve both static and dynamic content. - # - # Examples: - # - # Serve all requests beginning with /media from the "media" folder located - # in the current directory (ie media/*): - # - # use Rack::Static, :urls => ["/media"] - # - # Same as previous, but instead of returning 404 for missing files under - # /media, call the next middleware: - # - # use Rack::Static, :urls => ["/media"], :cascade => true - # - # Serve all requests beginning with /css or /images from the folder "public" - # in the current directory (ie public/css/* and public/images/*): - # - # use Rack::Static, :urls => ["/css", "/images"], :root => "public" - # - # Serve all requests to / with "index.html" from the folder "public" in the - # current directory (ie public/index.html): - # - # use Rack::Static, :urls => {"/" => 'index.html'}, :root => 'public' - # - # Serve all requests normally from the folder "public" in the current - # directory but uses index.html as default route for "/" - # - # use Rack::Static, :urls => [""], :root => 'public', :index => - # 'index.html' - # - # Set custom HTTP Headers for based on rules: - # - # use Rack::Static, :root => 'public', - # :header_rules => [ - # [rule, {header_field => content, header_field => content}], - # [rule, {header_field => content}] - # ] - # - # Rules for selecting files: - # - # 1) All files - # Provide the :all symbol - # :all => Matches every file - # - # 2) Folders - # Provide the folder path as a string - # '/folder' or '/folder/subfolder' => Matches files in a certain folder - # - # 3) File Extensions - # Provide the file extensions as an array - # ['css', 'js'] or %w(css js) => Matches files ending in .css or .js - # - # 4) Regular Expressions / Regexp - # Provide a regular expression - # %r{\.(?:css|js)\z} => Matches files ending in .css or .js - # /\.(?:eot|ttf|otf|woff2|woff|svg)\z/ => Matches files ending in - # the most common web font formats (.eot, .ttf, .otf, .woff2, .woff, .svg) - # Note: This Regexp is available as a shortcut, using the :fonts rule - # - # 5) Font Shortcut - # Provide the :fonts symbol - # :fonts => Uses the Regexp rule stated right above to match all common web font endings - # - # Rule Ordering: - # Rules are applied in the order that they are provided. - # List rather general rules above special ones. - # - # Complete example use case including HTTP header rules: - # - # use Rack::Static, :root => 'public', - # :header_rules => [ - # # Cache all static files in public caches (e.g. Rack::Cache) - # # as well as in the browser - # [:all, {'cache-control' => 'public, max-age=31536000'}], - # - # # Provide web fonts with cross-origin access-control-headers - # # Firefox requires this when serving assets using a Content Delivery Network - # [:fonts, {'access-control-allow-origin' => '*'}] - # ] - # - class Static - def initialize(app, options = {}) - @app = app - @urls = options[:urls] || ["/favicon.ico"] - @index = options[:index] - @gzip = options[:gzip] - @cascade = options[:cascade] - root = options[:root] || Dir.pwd - - # HTTP Headers - @header_rules = options[:header_rules] || [] - # Allow for legacy :cache_control option while prioritizing global header_rules setting - @header_rules.unshift([:all, { CACHE_CONTROL => options[:cache_control] }]) if options[:cache_control] - - @file_server = Rack::Files.new(root) - end - - def add_index_root?(path) - @index && route_file(path) && path.end_with?('/') - end - - def overwrite_file_path(path) - @urls.kind_of?(Hash) && @urls.key?(path) || add_index_root?(path) - end - - def route_file(path) - @urls.kind_of?(Array) && @urls.any? { |url| path.index(url) == 0 } - end - - def can_serve(path) - route_file(path) || overwrite_file_path(path) - end - - def call(env) - path = env[PATH_INFO] - - if can_serve(path) - if overwrite_file_path(path) - env[PATH_INFO] = (add_index_root?(path) ? path + @index : @urls[path]) - elsif @gzip && env['HTTP_ACCEPT_ENCODING'] && /\bgzip\b/.match?(env['HTTP_ACCEPT_ENCODING']) - path = env[PATH_INFO] - env[PATH_INFO] += '.gz' - response = @file_server.call(env) - env[PATH_INFO] = path - - if response[0] == 404 - response = nil - elsif response[0] == 304 - # Do nothing, leave headers as is - else - response[1][CONTENT_TYPE] = Mime.mime_type(::File.extname(path), 'text/plain') - response[1]['content-encoding'] = 'gzip' - end - end - - path = env[PATH_INFO] - response ||= @file_server.call(env) - - if @cascade && response[0] == 404 - return @app.call(env) - end - - headers = response[1] - applicable_rules(path).each do |rule, new_headers| - new_headers.each { |field, content| headers[field] = content } - end - - response - else - @app.call(env) - end - end - - # Convert HTTP header rules to HTTP headers - def applicable_rules(path) - @header_rules.find_all do |rule, new_headers| - case rule - when :all - true - when :fonts - /\.(?:ttf|otf|eot|woff2|woff|svg)\z/.match?(path) - when String - path = ::Rack::Utils.unescape(path) - path.start_with?(rule) || path.start_with?('/' + rule) - when Array - /\.(#{rule.join('|')})\z/.match?(path) - when Regexp - rule.match?(path) - else - false - end - end - end - - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb deleted file mode 100644 index 0b94cc7..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'body_proxy' - -module Rack - - # Middleware tracks and cleans Tempfiles created throughout a request (i.e. Rack::Multipart) - # Ideas/strategy based on posts by Eric Wong and Charles Oliver Nutter - # https://groups.google.com/forum/#!searchin/rack-devel/temp/rack-devel/brK8eh-MByw/sw61oJJCGRMJ - class TempfileReaper - def initialize(app) - @app = app - end - - def call(env) - env[RACK_TEMPFILES] ||= [] - - begin - _, _, body = response = @app.call(env) - rescue Exception - env[RACK_TEMPFILES]&.each(&:close!) - raise - end - - response[2] = BodyProxy.new(body) do - env[RACK_TEMPFILES]&.each(&:close!) - end - - response - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/urlmap.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/urlmap.rb deleted file mode 100644 index 99c4d82..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/urlmap.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -require 'set' - -require_relative 'constants' - -module Rack - # Rack::URLMap takes a hash mapping urls or paths to apps, and - # dispatches accordingly. Support for HTTP/1.1 host names exists if - # the URLs start with http:// or https://. - # - # URLMap modifies the SCRIPT_NAME and PATH_INFO such that the part - # relevant for dispatch is in the SCRIPT_NAME, and the rest in the - # PATH_INFO. This should be taken care of when you need to - # reconstruct the URL in order to create links. - # - # URLMap dispatches in such a way that the longest paths are tried - # first, since they are most specific. - - class URLMap - def initialize(map = {}) - remap(map) - end - - def remap(map) - @known_hosts = Set[] - @mapping = map.map { |location, app| - if location =~ %r{\Ahttps?://(.*?)(/.*)} - host, location = $1, $2 - @known_hosts << host - else - host = nil - end - - unless location[0] == ?/ - raise ArgumentError, "paths need to start with /" - end - - location = location.chomp('/') - match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", Regexp::NOENCODING) - - [host, location, match, app] - }.sort_by do |(host, location, _, _)| - [host ? -host.size : Float::INFINITY, -location.size] - end - end - - def call(env) - path = env[PATH_INFO] - script_name = env[SCRIPT_NAME] - http_host = env[HTTP_HOST] - server_name = env[SERVER_NAME] - server_port = env[SERVER_PORT] - - is_same_server = casecmp?(http_host, server_name) || - casecmp?(http_host, "#{server_name}:#{server_port}") - - is_host_known = @known_hosts.include? http_host - - @mapping.each do |host, location, match, app| - unless casecmp?(http_host, host) \ - || casecmp?(server_name, host) \ - || (!host && is_same_server) \ - || (!host && !is_host_known) # If we don't have a matching host, default to the first without a specified host - next - end - - next unless m = match.match(path.to_s) - - rest = m[1] - next unless !rest || rest.empty? || rest[0] == ?/ - - env[SCRIPT_NAME] = (script_name + location) - env[PATH_INFO] = rest - - return app.call(env) - end - - [404, { CONTENT_TYPE => "text/plain", "x-cascade" => "pass" }, ["Not Found: #{path}"]] - - ensure - env[PATH_INFO] = path - env[SCRIPT_NAME] = script_name - end - - private - def casecmp?(v1, v2) - # if both nil, or they're the same string - return true if v1 == v2 - - # if either are nil... (but they're not the same) - return false if v1.nil? - return false if v2.nil? - - # otherwise check they're not case-insensitive the same - v1.casecmp(v2).zero? - end - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/utils.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/utils.rb deleted file mode 100644 index bbf4969..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/utils.rb +++ /dev/null @@ -1,631 +0,0 @@ -# -*- encoding: binary -*- -# frozen_string_literal: true - -require 'uri' -require 'fileutils' -require 'set' -require 'tempfile' -require 'time' -require 'erb' - -require_relative 'query_parser' -require_relative 'mime' -require_relative 'headers' -require_relative 'constants' - -module Rack - # Rack::Utils contains a grab-bag of useful methods for writing web - # applications adopted from all kinds of Ruby libraries. - - module Utils - ParameterTypeError = QueryParser::ParameterTypeError - InvalidParameterError = QueryParser::InvalidParameterError - ParamsTooDeepError = QueryParser::ParamsTooDeepError - DEFAULT_SEP = QueryParser::DEFAULT_SEP - COMMON_SEP = QueryParser::COMMON_SEP - KeySpaceConstrainedParams = QueryParser::Params - URI_PARSER = defined?(::URI::RFC2396_PARSER) ? ::URI::RFC2396_PARSER : ::URI::DEFAULT_PARSER - - class << self - attr_accessor :default_query_parser - end - # The default amount of nesting to allowed by hash parameters. - # This helps prevent a rogue client from triggering a possible stack overflow - # when parsing parameters. - self.default_query_parser = QueryParser.make_default(32) - - module_function - - # URI escapes. (CGI style space to +) - def escape(s) - URI.encode_www_form_component(s) - end - - # Like URI escaping, but with %20 instead of +. Strictly speaking this is - # true URI escaping. - def escape_path(s) - URI_PARSER.escape s - end - - # Unescapes the **path** component of a URI. See Rack::Utils.unescape for - # unescaping query parameters or form components. - def unescape_path(s) - URI_PARSER.unescape s - end - - # Unescapes a URI escaped string with +encoding+. +encoding+ will be the - # target encoding of the string returned, and it defaults to UTF-8 - def unescape(s, encoding = Encoding::UTF_8) - URI.decode_www_form_component(s, encoding) - end - - class << self - attr_accessor :multipart_total_part_limit - - attr_accessor :multipart_file_limit - - # multipart_part_limit is the original name of multipart_file_limit, but - # the limit only counts parts with filenames. - alias multipart_part_limit multipart_file_limit - alias multipart_part_limit= multipart_file_limit= - end - - # The maximum number of file parts a request can contain. Accepting too - # many parts can lead to the server running out of file handles. - # Set to `0` for no limit. - self.multipart_file_limit = (ENV['RACK_MULTIPART_PART_LIMIT'] || ENV['RACK_MULTIPART_FILE_LIMIT'] || 128).to_i - - # The maximum total number of parts a request can contain. Accepting too - # many can lead to excessive memory use and parsing time. - self.multipart_total_part_limit = (ENV['RACK_MULTIPART_TOTAL_PART_LIMIT'] || 4096).to_i - - def self.param_depth_limit - default_query_parser.param_depth_limit - end - - def self.param_depth_limit=(v) - self.default_query_parser = self.default_query_parser.new_depth_limit(v) - end - - if defined?(Process::CLOCK_MONOTONIC) - def clock_time - Process.clock_gettime(Process::CLOCK_MONOTONIC) - end - else - # :nocov: - def clock_time - Time.now.to_f - end - # :nocov: - end - - def parse_query(qs, d = nil, &unescaper) - Rack::Utils.default_query_parser.parse_query(qs, d, &unescaper) - end - - def parse_nested_query(qs, d = nil) - Rack::Utils.default_query_parser.parse_nested_query(qs, d) - end - - def build_query(params) - params.map { |k, v| - if v.class == Array - build_query(v.map { |x| [k, x] }) - else - v.nil? ? escape(k) : "#{escape(k)}=#{escape(v)}" - end - }.join("&") - end - - def build_nested_query(value, prefix = nil) - case value - when Array - value.map { |v| - build_nested_query(v, "#{prefix}[]") - }.join("&") - when Hash - value.map { |k, v| - build_nested_query(v, prefix ? "#{prefix}[#{k}]" : k) - }.delete_if(&:empty?).join('&') - when nil - escape(prefix) - else - raise ArgumentError, "value must be a Hash" if prefix.nil? - "#{escape(prefix)}=#{escape(value)}" - end - end - - def q_values(q_value_header) - q_value_header.to_s.split(',').map do |part| - value, parameters = part.split(';', 2).map(&:strip) - quality = 1.0 - if parameters && (md = /\Aq=([\d.]+)/.match(parameters)) - quality = md[1].to_f - end - [value, quality] - end - end - - def forwarded_values(forwarded_header) - return nil unless forwarded_header - forwarded_header = forwarded_header.to_s.gsub("\n", ";") - - forwarded_header.split(';').each_with_object({}) do |field, values| - field.split(',').each do |pair| - pair = pair.split('=').map(&:strip).join('=') - return nil unless pair =~ /\A(by|for|host|proto)="?([^"]+)"?\Z/i - (values[$1.downcase.to_sym] ||= []) << $2 - end - end - end - module_function :forwarded_values - - # Return best accept value to use, based on the algorithm - # in RFC 2616 Section 14. If there are multiple best - # matches (same specificity and quality), the value returned - # is arbitrary. - def best_q_match(q_value_header, available_mimes) - values = q_values(q_value_header) - - matches = values.map do |req_mime, quality| - match = available_mimes.find { |am| Rack::Mime.match?(am, req_mime) } - next unless match - [match, quality] - end.compact.sort_by do |match, quality| - (match.split('/', 2).count('*') * -10) + quality - end.last - matches&.first - end - - # Introduced in ERB 4.0. ERB::Escape is an alias for ERB::Utils which - # doesn't get monkey-patched by rails - if defined?(ERB::Escape) && ERB::Escape.instance_method(:html_escape) - define_method(:escape_html, ERB::Escape.instance_method(:html_escape)) - else - require 'cgi/escape' - # Escape ampersands, brackets and quotes to their HTML/XML entities. - def escape_html(string) - CGI.escapeHTML(string.to_s) - end - end - - def select_best_encoding(available_encodings, accept_encoding) - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html - - expanded_accept_encoding = [] - - accept_encoding.each do |m, q| - preference = available_encodings.index(m) || available_encodings.size - - if m == "*" - (available_encodings - accept_encoding.map(&:first)).each do |m2| - expanded_accept_encoding << [m2, q, preference] - end - else - expanded_accept_encoding << [m, q, preference] - end - end - - encoding_candidates = expanded_accept_encoding - .sort_by { |_, q, p| [-q, p] } - .map!(&:first) - - unless encoding_candidates.include?("identity") - encoding_candidates.push("identity") - end - - expanded_accept_encoding.each do |m, q| - encoding_candidates.delete(m) if q == 0.0 - end - - (encoding_candidates & available_encodings)[0] - end - - # :call-seq: - # parse_cookies_header(value) -> hash - # - # Parse cookies from the provided header +value+ according to RFC6265. The - # syntax for cookie headers only supports semicolons. Returns a map of - # cookie +key+ to cookie +value+. - # - # parse_cookies_header('myname=myvalue; max-age=0') - # # => {"myname"=>"myvalue", "max-age"=>"0"} - # - def parse_cookies_header(value) - return {} unless value - - value.split(/; */n).each_with_object({}) do |cookie, cookies| - next if cookie.empty? - key, value = cookie.split('=', 2) - cookies[key] = (unescape(value) rescue value) unless cookies.key?(key) - end - end - - # :call-seq: - # parse_cookies(env) -> hash - # - # Parse cookies from the provided request environment using - # parse_cookies_header. Returns a map of cookie +key+ to cookie +value+. - # - # parse_cookies({'HTTP_COOKIE' => 'myname=myvalue'}) - # # => {'myname' => 'myvalue'} - # - def parse_cookies(env) - parse_cookies_header env[HTTP_COOKIE] - end - - # A valid cookie key according to RFC2616. - # A can be any US-ASCII characters, except control characters, spaces, or tabs. It also must not contain a separator character like the following: ( ) < > @ , ; : \ " / [ ] ? = { }. - VALID_COOKIE_KEY = /\A[!#$%&'*+\-\.\^_`|~0-9a-zA-Z]+\z/.freeze - private_constant :VALID_COOKIE_KEY - - private def escape_cookie_key(key) - if key =~ VALID_COOKIE_KEY - key - else - warn "Cookie key #{key.inspect} is not valid according to RFC2616; it will be escaped. This behaviour is deprecated and will be removed in a future version of Rack.", uplevel: 2 - escape(key) - end - end - - # :call-seq: - # set_cookie_header(key, value) -> encoded string - # - # Generate an encoded string using the provided +key+ and +value+ suitable - # for the +set-cookie+ header according to RFC6265. The +value+ may be an - # instance of either +String+ or +Hash+. - # - # If the cookie +value+ is an instance of +Hash+, it considers the following - # cookie attribute keys: +domain+, +max_age+, +expires+ (must be instance - # of +Time+), +secure+, +http_only+, +same_site+ and +value+. For more - # details about the interpretation of these fields, consult - # [RFC6265 Section 5.2](https://datatracker.ietf.org/doc/html/rfc6265#section-5.2). - # - # An extra cookie attribute +escape_key+ can be provided to control whether - # or not the cookie key is URL encoded. If explicitly set to +false+, the - # cookie key name will not be url encoded (escaped). The default is +true+. - # - # set_cookie_header("myname", "myvalue") - # # => "myname=myvalue" - # - # set_cookie_header("myname", {value: "myvalue", max_age: 10}) - # # => "myname=myvalue; max-age=10" - # - def set_cookie_header(key, value) - case value - when Hash - key = escape_cookie_key(key) unless value[:escape_key] == false - domain = "; domain=#{value[:domain]}" if value[:domain] - path = "; path=#{value[:path]}" if value[:path] - max_age = "; max-age=#{value[:max_age]}" if value[:max_age] - expires = "; expires=#{value[:expires].httpdate}" if value[:expires] - secure = "; secure" if value[:secure] - httponly = "; httponly" if (value.key?(:httponly) ? value[:httponly] : value[:http_only]) - same_site = - case value[:same_site] - when false, nil - nil - when :none, 'None', :None - '; samesite=none' - when :lax, 'Lax', :Lax - '; samesite=lax' - when true, :strict, 'Strict', :Strict - '; samesite=strict' - else - raise ArgumentError, "Invalid :same_site value: #{value[:same_site].inspect}" - end - partitioned = "; partitioned" if value[:partitioned] - value = value[:value] - else - key = escape_cookie_key(key) - end - - value = [value] unless Array === value - - return "#{key}=#{value.map { |v| escape v }.join('&')}#{domain}" \ - "#{path}#{max_age}#{expires}#{secure}#{httponly}#{same_site}#{partitioned}" - end - - # :call-seq: - # set_cookie_header!(headers, key, value) -> header value - # - # Append a cookie in the specified headers with the given cookie +key+ and - # +value+ using set_cookie_header. - # - # If the headers already contains a +set-cookie+ key, it will be converted - # to an +Array+ if not already, and appended to. - def set_cookie_header!(headers, key, value) - if header = headers[SET_COOKIE] - if header.is_a?(Array) - header << set_cookie_header(key, value) - else - headers[SET_COOKIE] = [header, set_cookie_header(key, value)] - end - else - headers[SET_COOKIE] = set_cookie_header(key, value) - end - end - - # :call-seq: - # delete_set_cookie_header(key, value = {}) -> encoded string - # - # Generate an encoded string based on the given +key+ and +value+ using - # set_cookie_header for the purpose of causing the specified cookie to be - # deleted. The +value+ may be an instance of +Hash+ and can include - # attributes as outlined by set_cookie_header. The encoded cookie will have - # a +max_age+ of 0 seconds, an +expires+ date in the past and an empty - # +value+. When used with the +set-cookie+ header, it will cause the client - # to *remove* any matching cookie. - # - # delete_set_cookie_header("myname") - # # => "myname=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" - # - def delete_set_cookie_header(key, value = {}) - set_cookie_header(key, value.merge(max_age: '0', expires: Time.at(0), value: '')) - end - - def delete_cookie_header!(headers, key, value = {}) - headers[SET_COOKIE] = delete_set_cookie_header!(headers[SET_COOKIE], key, value) - - return nil - end - - # :call-seq: - # delete_set_cookie_header!(header, key, value = {}) -> header value - # - # Set an expired cookie in the specified headers with the given cookie - # +key+ and +value+ using delete_set_cookie_header. This causes - # the client to immediately delete the specified cookie. - # - # delete_set_cookie_header!(nil, "mycookie") - # # => "mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" - # - # If the header is non-nil, it will be modified in place. - # - # header = [] - # delete_set_cookie_header!(header, "mycookie") - # # => ["mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"] - # header - # # => ["mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"] - # - def delete_set_cookie_header!(header, key, value = {}) - if header - header = Array(header) - header << delete_set_cookie_header(key, value) - else - header = delete_set_cookie_header(key, value) - end - - return header - end - - def rfc2822(time) - time.rfc2822 - end - - # Parses the "Range:" header, if present, into an array of Range objects. - # Returns nil if the header is missing or syntactically invalid. - # Returns an empty array if none of the ranges are satisfiable. - def byte_ranges(env, size) - get_byte_ranges env['HTTP_RANGE'], size - end - - def get_byte_ranges(http_range, size) - # See - # Ignore Range when file size is 0 to avoid a 416 error. - return nil if size.zero? - return nil unless http_range && http_range =~ /bytes=([^;]+)/ - ranges = [] - $1.split(/,\s*/).each do |range_spec| - return nil unless range_spec.include?('-') - range = range_spec.split('-') - r0, r1 = range[0], range[1] - if r0.nil? || r0.empty? - return nil if r1.nil? - # suffix-byte-range-spec, represents trailing suffix of file - r0 = size - r1.to_i - r0 = 0 if r0 < 0 - r1 = size - 1 - else - r0 = r0.to_i - if r1.nil? - r1 = size - 1 - else - r1 = r1.to_i - return nil if r1 < r0 # backwards range is syntactically invalid - r1 = size - 1 if r1 >= size - end - end - ranges << (r0..r1) if r0 <= r1 - end - - return [] if ranges.map(&:size).sum > size - - ranges - end - - # :nocov: - if defined?(OpenSSL.fixed_length_secure_compare) - # Constant time string comparison. - # - # NOTE: the values compared should be of fixed length, such as strings - # that have already been processed by HMAC. This should not be used - # on variable length plaintext strings because it could leak length info - # via timing attacks. - def secure_compare(a, b) - return false unless a.bytesize == b.bytesize - - OpenSSL.fixed_length_secure_compare(a, b) - end - # :nocov: - else - def secure_compare(a, b) - return false unless a.bytesize == b.bytesize - - l = a.unpack("C*") - - r, i = 0, -1 - b.each_byte { |v| r |= v ^ l[i += 1] } - r == 0 - end - end - - # Context allows the use of a compatible middleware at different points - # in a request handling stack. A compatible middleware must define - # #context which should take the arguments env and app. The first of which - # would be the request environment. The second of which would be the rack - # application that the request would be forwarded to. - class Context - attr_reader :for, :app - - def initialize(app_f, app_r) - raise 'running context does not respond to #context' unless app_f.respond_to? :context - @for, @app = app_f, app_r - end - - def call(env) - @for.context(env, @app) - end - - def recontext(app) - self.class.new(@for, app) - end - - def context(env, app = @app) - recontext(app).call(env) - end - end - - # Every standard HTTP code mapped to the appropriate message. - # Generated with: - # curl -s https://www.iana.org/assignments/http-status-codes/http-status-codes-1.csv \ - # | ruby -rcsv -e "puts CSV.parse(STDIN, headers: true) \ - # .reject {|v| v['Description'] == 'Unassigned' or v['Description'].include? '(' } \ - # .map {|v| %Q/#{v['Value']} => '#{v['Description']}'/ }.join(','+?\n)" - HTTP_STATUS_CODES = { - 100 => 'Continue', - 101 => 'Switching Protocols', - 102 => 'Processing', - 103 => 'Early Hints', - 200 => 'OK', - 201 => 'Created', - 202 => 'Accepted', - 203 => 'Non-Authoritative Information', - 204 => 'No Content', - 205 => 'Reset Content', - 206 => 'Partial Content', - 207 => 'Multi-Status', - 208 => 'Already Reported', - 226 => 'IM Used', - 300 => 'Multiple Choices', - 301 => 'Moved Permanently', - 302 => 'Found', - 303 => 'See Other', - 304 => 'Not Modified', - 305 => 'Use Proxy', - 307 => 'Temporary Redirect', - 308 => 'Permanent Redirect', - 400 => 'Bad Request', - 401 => 'Unauthorized', - 402 => 'Payment Required', - 403 => 'Forbidden', - 404 => 'Not Found', - 405 => 'Method Not Allowed', - 406 => 'Not Acceptable', - 407 => 'Proxy Authentication Required', - 408 => 'Request Timeout', - 409 => 'Conflict', - 410 => 'Gone', - 411 => 'Length Required', - 412 => 'Precondition Failed', - 413 => 'Content Too Large', - 414 => 'URI Too Long', - 415 => 'Unsupported Media Type', - 416 => 'Range Not Satisfiable', - 417 => 'Expectation Failed', - 421 => 'Misdirected Request', - 422 => 'Unprocessable Content', - 423 => 'Locked', - 424 => 'Failed Dependency', - 425 => 'Too Early', - 426 => 'Upgrade Required', - 428 => 'Precondition Required', - 429 => 'Too Many Requests', - 431 => 'Request Header Fields Too Large', - 451 => 'Unavailable For Legal Reasons', - 500 => 'Internal Server Error', - 501 => 'Not Implemented', - 502 => 'Bad Gateway', - 503 => 'Service Unavailable', - 504 => 'Gateway Timeout', - 505 => 'HTTP Version Not Supported', - 506 => 'Variant Also Negotiates', - 507 => 'Insufficient Storage', - 508 => 'Loop Detected', - 511 => 'Network Authentication Required' - } - - # Responses with HTTP status codes that should not have an entity body - STATUS_WITH_NO_ENTITY_BODY = Hash[((100..199).to_a << 204 << 304).product([true])] - - SYMBOL_TO_STATUS_CODE = Hash[*HTTP_STATUS_CODES.map { |code, message| - [message.downcase.gsub(/\s|-/, '_').to_sym, code] - }.flatten] - - OBSOLETE_SYMBOLS_TO_STATUS_CODES = { - payload_too_large: 413, - unprocessable_entity: 422, - bandwidth_limit_exceeded: 509, - not_extended: 510 - }.freeze - private_constant :OBSOLETE_SYMBOLS_TO_STATUS_CODES - - OBSOLETE_SYMBOL_MAPPINGS = { - payload_too_large: :content_too_large, - unprocessable_entity: :unprocessable_content - }.freeze - private_constant :OBSOLETE_SYMBOL_MAPPINGS - - def status_code(status) - if status.is_a?(Symbol) - SYMBOL_TO_STATUS_CODE.fetch(status) do - fallback_code = OBSOLETE_SYMBOLS_TO_STATUS_CODES.fetch(status) { raise ArgumentError, "Unrecognized status code #{status.inspect}" } - message = "Status code #{status.inspect} is deprecated and will be removed in a future version of Rack." - if canonical_symbol = OBSOLETE_SYMBOL_MAPPINGS[status] - # message = "#{message} Please use #{canonical_symbol.inspect} instead." - # For now, let's not emit any warning when there is a mapping. - else - warn message, uplevel: 3 - end - fallback_code - end - else - status.to_i - end - end - - PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact) - - def clean_path_info(path_info) - parts = path_info.split PATH_SEPS - - clean = [] - - parts.each do |part| - next if part.empty? || part == '.' - part == '..' ? clean.pop : clean << part - end - - clean_path = clean.join(::File::SEPARATOR) - clean_path.prepend("/") if parts.empty? || parts.first.empty? - clean_path - end - - NULL_BYTE = "\0" - - def valid_path?(path) - path.valid_encoding? && !path.include?(NULL_BYTE) - end - - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/version.rb b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/version.rb deleted file mode 100644 index 5b45e76..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/lib/rack/version.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -# Copyright (C) 2007-2019 Leah Neukirchen -# -# Rack is freely distributable under the terms of an MIT-style license. -# See MIT-LICENSE or https://opensource.org/licenses/MIT. - -# The Rack main module, serving as a namespace for all core Rack -# modules and classes. -# -# All modules meant for use in your application are autoloaded here, -# so it should be enough just to require 'rack' in your code. - -module Rack - RELEASE = "3.1.8" - - # Return the Rack release as a dotted string. - def self.release - RELEASE - end -end diff --git a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/rack.gemspec b/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/rack.gemspec deleted file mode 100644 index ed37415..0000000 --- a/spikes/gem-checksums/path-with-checksums/after/vendored/rack-3.1.8/rack.gemspec +++ /dev/null @@ -1,31 +0,0 @@ -# -*- encoding: utf-8 -*- -# stub: rack 3.1.8 ruby lib - -Gem::Specification.new do |s| - s.name = "rack".freeze - s.version = "3.1.8".freeze - - s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= - s.metadata = { "bug_tracker_uri" => "https://github.com/rack/rack/issues", "changelog_uri" => "https://github.com/rack/rack/blob/main/CHANGELOG.md", "documentation_uri" => "https://rubydoc.info/github/rack/rack", "source_code_uri" => "https://github.com/rack/rack" } if s.respond_to? :metadata= - s.require_paths = ["lib".freeze] - s.authors = ["Leah Neukirchen".freeze] - s.date = "2024-10-14" - s.description = "Rack provides a minimal, modular and adaptable interface for developing\nweb applications in Ruby. By wrapping HTTP requests and responses in\nthe simplest way possible, it unifies and distills the API for web\nservers, web frameworks, and software in between (the so-called\nmiddleware) into a single method call.\n".freeze - s.email = "leah@vuxu.org".freeze - s.extra_rdoc_files = ["README.md".freeze, "CHANGELOG.md".freeze, "CONTRIBUTING.md".freeze] - s.files = ["CHANGELOG.md".freeze, "CONTRIBUTING.md".freeze, "README.md".freeze] - s.homepage = "https://github.com/rack/rack".freeze - s.licenses = ["MIT".freeze] - s.required_ruby_version = Gem::Requirement.new(">= 2.4.0".freeze) - s.rubygems_version = "3.5.11".freeze - s.summary = "A modular Ruby webserver interface.".freeze - - s.installed_by_version = "3.5.22".freeze - - s.specification_version = 4 - - s.add_development_dependency(%q.freeze, ["~> 5.0".freeze]) - s.add_development_dependency(%q.freeze, [">= 0".freeze]) - s.add_development_dependency(%q.freeze, [">= 0".freeze]) - s.add_development_dependency(%q.freeze, [">= 0".freeze]) -end diff --git a/spikes/gem-checksums/path-with-checksums/before/.bundle/config b/spikes/gem-checksums/path-with-checksums/before/.bundle/config deleted file mode 100644 index 6eb400d..0000000 --- a/spikes/gem-checksums/path-with-checksums/before/.bundle/config +++ /dev/null @@ -1,3 +0,0 @@ ---- -BUNDLE_PATH: "vendor/bundle" -BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/path-with-checksums/before/Gemfile b/spikes/gem-checksums/path-with-checksums/before/Gemfile deleted file mode 100644 index 864c947..0000000 --- a/spikes/gem-checksums/path-with-checksums/before/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem "rack", "3.1.8" diff --git a/spikes/gem-checksums/path-with-checksums/before/Gemfile.lock b/spikes/gem-checksums/path-with-checksums/before/Gemfile.lock deleted file mode 100644 index 7898b2f..0000000 --- a/spikes/gem-checksums/path-with-checksums/before/Gemfile.lock +++ /dev/null @@ -1,17 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - rack (3.1.8) - -PLATFORMS - aarch64-linux - ruby - -DEPENDENCIES - rack (= 3.1.8) - -CHECKSUMS - rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 - -BUNDLED WITH - 2.7.2 diff --git a/spikes/gem-checksums/registry-with-checksums/after/.bundle/config b/spikes/gem-checksums/registry-with-checksums/after/.bundle/config deleted file mode 100644 index 6eb400d..0000000 --- a/spikes/gem-checksums/registry-with-checksums/after/.bundle/config +++ /dev/null @@ -1,3 +0,0 @@ ---- -BUNDLE_PATH: "vendor/bundle" -BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/registry-with-checksums/after/Gemfile b/spikes/gem-checksums/registry-with-checksums/after/Gemfile deleted file mode 100644 index 864c947..0000000 --- a/spikes/gem-checksums/registry-with-checksums/after/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem "rack", "3.1.8" diff --git a/spikes/gem-checksums/registry-with-checksums/after/Gemfile.lock b/spikes/gem-checksums/registry-with-checksums/after/Gemfile.lock deleted file mode 100644 index 7898b2f..0000000 --- a/spikes/gem-checksums/registry-with-checksums/after/Gemfile.lock +++ /dev/null @@ -1,17 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - rack (3.1.8) - -PLATFORMS - aarch64-linux - ruby - -DEPENDENCIES - rack (= 3.1.8) - -CHECKSUMS - rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 - -BUNDLED WITH - 2.7.2 diff --git a/spikes/gem-checksums/registry-with-checksums/before/.bundle/config b/spikes/gem-checksums/registry-with-checksums/before/.bundle/config deleted file mode 100644 index 6eb400d..0000000 --- a/spikes/gem-checksums/registry-with-checksums/before/.bundle/config +++ /dev/null @@ -1,3 +0,0 @@ ---- -BUNDLE_PATH: "vendor/bundle" -BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/registry-with-checksums/before/Gemfile b/spikes/gem-checksums/registry-with-checksums/before/Gemfile deleted file mode 100644 index 864c947..0000000 --- a/spikes/gem-checksums/registry-with-checksums/before/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem "rack", "3.1.8" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/.bundle/config b/spikes/gem-checksums/stale-checksum-v1-bug/after/.bundle/config deleted file mode 100644 index 6eb400d..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/.bundle/config +++ /dev/null @@ -1,3 +0,0 @@ ---- -BUNDLE_PATH: "vendor/bundle" -BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile b/spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile deleted file mode 100644 index 6d26ec6..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem "rack", "3.1.8", path: "./vendored/rack-3.1.8" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile.lock b/spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile.lock deleted file mode 100644 index 2cbbe92..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/Gemfile.lock +++ /dev/null @@ -1,21 +0,0 @@ -PATH - remote: vendored/rack-3.1.8 - specs: - rack (3.1.8) - -GEM - remote: https://rubygems.org/ - specs: - -PLATFORMS - aarch64-linux - ruby - -DEPENDENCIES - rack (= 3.1.8)! - -CHECKSUMS - rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 - -BUNDLED WITH - 2.7.2 diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CHANGELOG.md b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CHANGELOG.md deleted file mode 100644 index 18069d3..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CHANGELOG.md +++ /dev/null @@ -1,998 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. For info on how to format all future additions to this file please reference [Keep A Changelog](https://keepachangelog.com/en/1.0.0/). - -## [3.1.8] - 2024-10-14 - -- Resolve deprecation warnings about uri `DEFAULT_PARSER`. ([#2249](https://github.com/rack/rack/pull/2249), [@earlopain]) - -## [3.1.7] - 2024-07-11 - -### Fixed - -- Do not remove escaped opening/closing quotes for content-disposition filenames. ([#2229](https://github.com/rack/rack/pull/2229), [@jeremyevans]) -- Fix encoding setting for non-binary IO-like objects in MockRequest#env_for. ([#2227](https://github.com/rack/rack/pull/2227), [@jeremyevans]) -- `Rack::Response` should not generate invalid `content-length` header. ([#2219](https://github.com/rack/rack/pull/2219), [@ioquatix]) -- Allow empty PATH_INFO. ([#2214](https://github.com/rack/rack/pull/2214), [@ioquatix]) - -## [3.1.6] - 2024-07-03 - -### Fixed - -- Fix several edge cases in `Rack::Request#parse_http_accept_header`'s implementation. ([#2226](https://github.com/rack/rack/pull/2226), [@ioquatix]) - -## [3.1.5] - 2024-07-02 - -### Security - -- Fix potential ReDoS attack in `Rack::Request#parse_http_accept_header`. ([GHSA-cj83-2ww7-mvq7](https://github.com/rack/rack/security/advisories/GHSA-cj83-2ww7-mvq7), [@dwisiswant0](https://github.com/dwisiswant0)) - -## [3.1.4] - 2024-06-22 - -### Fixed - -- Fix `Rack::Lint` matching some paths incorrectly as authority form. ([#2220](https://github.com/rack/rack/pull/2220), [@ioquatix]) - -## [3.1.3] - 2024-06-12 - -### Fixed - -- Fix passing non-strings to `Rack::Utils.escape_html`. ([#2202](https://github.com/rack/rack/pull/2202), [@earlopain]) -- `Rack::MockResponse` gracefully handles empty cookies ([#2203](https://github.com/rack/rack/pull/2203) [@wynksaiddestroy]) - -## [3.1.2] - 2024-06-11 - -- `Rack::Response` will take in to consideration chunked encoding responses ([#2204](https://github.com/rack/rack/pull/2204), [@tenderlove]) - -## [3.1.1] - 2024-06-11 - -- Oops! I shouldn't have shipped that - -## [3.1.0] - 2024-06-11 - -:warning: **This release includes several breaking changes.** Refer to the **Removed** section below for the list of deprecated methods that have been removed in this release. - -Rack v3.1 is primarily a maintenance release that removes features deprecated in Rack v3.0. Alongside these removals, there are several improvements to the Rack SPEC, mainly focused on enhancing input and output handling. These changes aim to make Rack more efficient and align better with the requirements of server implementations and relevant HTTP specifications. - -### SPEC Changes - -- `rack.input` is now optional. ([#1997](https://github.com/rack/rack/pull/1997), [#2018](https://github.com/rack/rack/pull/2018), [@ioquatix]) -- `PATH_INFO` is now validated according to the HTTP/1.1 specification. ([#2117](https://github.com/rack/rack/pull/2117), [#2181](https://github.com/rack/rack/pull/2181), [@ioquatix]) - - `OPTIONS *` is now accepted. ([#2114](https://github.com/rack/rack/pull/2114), [@doriantaylor](https://github.com/doriantaylor)) -- Introduce optional `rack.protocol` request and response header for handling connection upgrades. ([#1954](https://github.com/rack/rack/pull/1954), [@ioquatix]) - -### Added - -- Introduce `Rack::Multipart::MissingInputError` for improved handling of missing input in `#parse_multipart`. ([#2018](https://github.com/rack/rack/pull/2018), [@ioquatix]) -- Introduce `module Rack::BadRequest` which is included in multipart and query parser errors. ([#2019](https://github.com/rack/rack/pull/2019), [@ioquatix]) -- Add `.mjs` MIME type ([#2057](https://github.com/rack/rack/pull/2057), [@axilleas](https://github.com/axilleas)) -- `set_cookie_header` utility now supports the `partitioned` cookie attribute. This is required by Chrome in some embedded contexts. ([#2131](https://github.com/rack/rack/pull/2131), [@flavio-b](https://github.com/flavio-b)) -- Introduce `rack.early_hints` for sending `103 Early Hints` informational responses. ([#1831](https://github.com/rack/rack/pull/1831), [@casperisfine](https://github.com/casperisfine), [@jeremyevans]) - -### Changed - -- MIME type for JavaScript files (`.js`) changed from `application/javascript` to `text/javascript` ([`1bd0f15`](https://github.com/rack/rack/commit/1bd0f1597d8f4a90d47115f3e156a8ce7870c9c8), [@ioquatix]) -- Update MIME types associated to `.ttf`, `.woff`, `.woff2` and `.otf` extensions to use mondern `font/*` types. ([#2065](https://github.com/rack/rack/pull/2065), [@davidstosik]) -- `Rack::Utils.escape_html` is now delegated to `CGI.escapeHTML`. `'` is escaped to `#39;` instead of `#x27;`. (decimal vs hexadecimal) ([#2099](https://github.com/rack/rack/pull/2099), [@JunichiIto](https://github.com/JunichiIto)) -- Clarify use of `@buffered` and only update `content-length` when `Rack::Response#finish` is invoked. ([#2149](https://github.com/rack/rack/pull/2149), [@ioquatix]) - -### Deprecated - -- Deprecate automatic cache invalidation in `Request#{GET,POST}` ([#2073](https://github.com/rack/rack/pull/2073), [@jeremyevans]) -- Only cookie keys that are not valid according to the HTTP specifications are escaped. We are planning to deprecate this behaviour, so now a deprecation message will be emitted in this case. In the future, invalid cookie keys may not be accepted. ([#2191](https://github.com/rack/rack/pull/2191), [@ioquatix]) -- `Rack::Logger` is deprecated. ([#2197](https://github.com/rack/rack/pull/2197), [@ioquatix]) -- Add fallback lookup and deprecation warning for obsolete status symbols. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn](https://github.com/wtn)) -- Deprecate `Rack::Request#values_at`, use `request.params.values_at` instead ([#2183](https://github.com/rack/rack/pull/2183), [@ioquatix]) - -### Removed - -- Remove deprecated `Rack::Auth::Digest` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::Cascade::NotFound` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::Chunked` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::File`, use `Rack::Files` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::QueryParser` `key_space_limit` parameter with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::Response#header`, use `Rack::Response#headers` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated cookie methods from `Rack::Utils`: `add_cookie_to_header`, `make_delete_cookie_header`, `add_remove_cookie_to_header`. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::Utils::HeaderHash`. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::VERSION`, `Rack::VERSION_STRING`, `Rack.version`, use `Rack.release` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove non-standard status codes 306, 509, & 510 and update descriptions for 413, 422, & 451. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn](https://github.com/wtn)) -- Remove any dependency on `transfer-encoding: chunked`. ([#2195](https://github.com/rack/rack/pull/2195), [@ioquatix]) -- Remove deprecated `Rack::Request#[]`, use `request.params[key]` instead ([#2183](https://github.com/rack/rack/pull/2183), [@ioquatix]) - -### Fixed - -- In `Rack::Files`, ignore the `Range` header if served file is 0 bytes. ([#2159](https://github.com/rack/rack/pull/2159), [@zarqman]) - -## [3.0.11] - 2024-05-10 - -- Backport #2062 to 3-0-stable: Do not allow `BodyProxy` to respond to `to_str`, make `to_ary` call close . ([#2062](https://github.com/rack/rack/pull/2062), [@jeremyevans](https://github.com/jeremyevans)) - -## [3.0.10] - 2024-03-21 - -- Backport #2104 to 3-0-stable: Return empty when parsing a multi-part POST with only one end delimiter. ([#2164](https://github.com/rack/rack/pull/2164), [@JoeDupuis](https://github.com/JoeDupuis)) - -## [3.0.9.1] - 2024-02-21 - -### Security - -* [CVE-2024-26146] Fixed ReDoS in Accept header parsing -* [CVE-2024-25126] Fixed ReDoS in Content Type header parsing -* [CVE-2024-26141] Reject Range headers which are too large - -[CVE-2024-26146]: https://github.com/advisories/GHSA-54rr-7fvw-6x8f -[CVE-2024-25126]: https://github.com/advisories/GHSA-22f2-v57c-j9cx -[CVE-2024-26141]: https://github.com/advisories/GHSA-xj5v-6v4g-jfw6 - -## [3.0.9] - 2024-01-31 - -- Fix incorrect content-length header that was emitted when `Rack::Response#write` was used in some situations. ([#2150](https://github.com/rack/rack/pull/2150), [@mattbrictson](https://github.com/mattbrictson)) - -## [3.0.8] - 2023-06-14 - -- Fix some unused variable verbose warnings. ([#2084](https://github.com/rack/rack/pull/2084), [@jeremyevans], [@skipkayhil](https://github.com/skipkayhil)) - -## [3.0.7] - 2023-03-16 - -- Make query parameters without `=` have `nil` values. ([#2059](https://github.com/rack/rack/pull/2059), [@jeremyevans]) - -## [3.0.6.1] - 2023-03-13 - -### Security - -- [CVE-2023-27539] Avoid ReDoS in header parsing - -## [3.0.6] - 2023-03-13 - -- Add `QueryParser#missing_value` for handling missing values + tests. ([#2052](https://github.com/rack/rack/pull/2052), [@ioquatix]) - -## [3.0.5] - 2023-03-13 - -- Split form/query parsing into two steps. ([#2038](https://github.com/rack/rack/pull/2038), [@matthewd](https://github.com/matthewd)) - -## [3.0.4.2] - 2023-03-02 - -### Security - -- [CVE-2023-27530] Introduce multipart_total_part_limit to limit total parts - -## [3.0.4.1] - 2023-01-17 - -### Security - -- [CVE-2022-44571] Fix ReDoS vulnerability in multipart parser -- [CVE-2022-44570] Fix ReDoS in Rack::Utils.get_byte_ranges -- [CVE-2022-44572] Forbid control characters in attributes (also ReDoS) - -## [3.0.4] - 2023-01-17 - -- `Rack::Request#POST` should consistently raise errors. Cache errors that occur when invoking `Rack::Request#POST` so they can be raised again later. ([#2010](https://github.com/rack/rack/pull/2010), [@ioquatix]) -- Fix `Rack::Lint` error message for `HTTP_CONTENT_TYPE` and `HTTP_CONTENT_LENGTH`. ([#2007](https://github.com/rack/rack/pull/2007), [@byroot](https://github.com/byroot)) -- Extend `Rack::MethodOverride` to handle `QueryParser::ParamsTooDeepError` error. ([#2006](https://github.com/rack/rack/pull/2006), [@byroot](https://github.com/byroot)) - -## [3.0.3] - 2022-12-27 - -### Fixed - -- `Rack::URLMap` uses non-deprecated form of `Regexp.new`. ([#1998](https://github.com/rack/rack/pull/1998), [@weizheheng](https://github.com/weizheheng)) - -## [3.0.2] - 2022-12-05 - -### Fixed - -- `Utils.build_nested_query` URL-encodes nested field names including the square brackets. -- Allow `Rack::Response` to pass through streaming bodies. ([#1993](https://github.com/rack/rack/pull/1993), [@ioquatix]) - -## [3.0.1] - 2022-11-18 - -### Fixed - -- `MethodOverride` does not look for an override if a request does not include form/parseable data. -- `Rack::Lint::Wrapper` correctly handles `respond_to?` with `to_ary`, `each`, `call` and `to_path`, forwarding to the body. ([#1981](https://github.com/rack/rack/pull/1981), [@ioquatix]) - -## [3.0.0] - 2022-09-06 - -- No changes - -## [3.0.0.rc1] - 2022-09-04 - -### SPEC Changes - -- Stream argument must implement `<<` https://github.com/rack/rack/pull/1959 -- `close` may be called on `rack.input` https://github.com/rack/rack/pull/1956 -- `rack.response_finished` may be used for executing code after the response has been finished https://github.com/rack/rack/pull/1952 - -## [3.0.0.beta1] - 2022-08-08 - -### Security - -- Do not use semicolon as GET parameter separator. ([#1733](https://github.com/rack/rack/pull/1733), [@jeremyevans]) - -### SPEC Changes - -- Response array must now be non-frozen. -- Response `status` must now be an integer greater than or equal to 100. -- Response `headers` must now be an unfrozen hash. -- Response header keys can no longer include uppercase characters. -- Response header values can be an `Array` to handle multiple values (and no longer supports `\n` encoded headers). -- Response body can now respond to `#call` (streaming body) instead of `#each` (enumerable body), for the equivalent of response hijacking in previous versions. -- Middleware must no longer call `#each` on the body, but they can call `#to_ary` on the body if it responds to `#to_ary`. -- `rack.input` is no longer required to be rewindable. -- `rack.multithread`/`rack.multiprocess`/`rack.run_once`/`rack.version` are no longer required environment keys. -- `SERVER_PROTOCOL` is now a required environment key, matching the HTTP protocol used in the request. -- `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. -- `rack.hijack_io` has been removed completely. -- `rack.response_finished` is an optional environment key which contains an array of callable objects that must accept `#call(env, status, headers, error)` and are invoked after the response is finished (either successfully or unsuccessfully). -- It is okay to call `#close` on `rack.input` to indicate that you no longer need or care about the input. -- The stream argument supplied to the streaming body and hijack must support `#<<` for writing output. - -### Removed - -- Remove `rack.multithread`/`rack.multiprocess`/`rack.run_once`. These variables generally come too late to be useful. ([#1720](https://github.com/rack/rack/pull/1720), [@ioquatix], [@jeremyevans])) -- Remove deprecated Rack::Request::SCHEME_WHITELIST. ([@jeremyevans]) -- Remove internal cookie deletion using pattern matching, there are very few practical cases where it would be useful and browsers handle it correctly without us doing anything special. ([#1844](https://github.com/rack/rack/pull/1844), [@ioquatix]) -- Remove `rack.version` as it comes too late to be useful. ([#1938](https://github.com/rack/rack/pull/1938), [@ioquatix]) -- Extract `rackup` command, `Rack::Server`, `Rack::Handler`, `Rack::Lobster` and related code into a separate gem. ([#1937](https://github.com/rack/rack/pull/1937), [@ioquatix]) - -### Added - -- `Rack::Headers` added to support lower-case header keys. ([@jeremyevans]) -- `Rack::Utils#set_cookie_header` now supports `escape_key: false` to avoid key escaping. ([@jeremyevans]) -- `Rack::RewindableInput` supports size. ([@ahorek](https://github.com/ahorek)) -- `Rack::RewindableInput::Middleware` added for making `rack.input` rewindable. ([@jeremyevans]) -- The RFC 7239 Forwarded header is now supported and considered by default when looking for information on forwarding, falling back to the X-Forwarded-* headers. `Rack::Request.forwarded_priority` accessor has been added for configuring the priority of which header to check. ([#1423](https://github.com/rack/rack/issues/1423), [@jeremyevans]) -- Allow response headers to contain array of values. ([#1598](https://github.com/rack/rack/issues/1598), [@ioquatix]) -- Support callable body for explicit streaming support and clarify streaming response body behaviour. ([#1745](https://github.com/rack/rack/pull/1745), [@ioquatix], [#1748](https://github.com/rack/rack/pull/1748), [@wjordan]) -- Allow `Rack::Builder#run` to take a block instead of an argument. ([#1942](https://github.com/rack/rack/pull/1942), [@ioquatix]) -- Add `rack.response_finished` to `Rack::Lint`. ([#1802](https://github.com/rack/rack/pull/1802), [@BlakeWilliams], [#1952](https://github.com/rack/rack/pull/1952), [@ioquatix]) -- The stream argument must implement `#<<`. ([#1959](https://github.com/rack/rack/pull/1959), [@ioquatix]) - -### Changed - -- BREAKING CHANGE: Require `status` to be an Integer. ([#1662](https://github.com/rack/rack/pull/1662), [@olleolleolle](https://github.com/olleolleolle)) -- BREAKING CHANGE: Query parsing now treats parameters without `=` as having the empty string value instead of nil value, to conform to the URL spec. ([#1696](https://github.com/rack/rack/issues/1696), [@jeremyevans]) -- Relax validations around `Rack::Request#host` and `Rack::Request#hostname`. ([#1606](https://github.com/rack/rack/issues/1606), [@pvande](https://github.com/pvande)) -- Removed antiquated handlers: FCGI, LSWS, SCGI, Thin. ([#1658](https://github.com/rack/rack/pull/1658), [@ioquatix]) -- Removed options from `Rack::Builder.parse_file` and `Rack::Builder.load_file`. ([#1663](https://github.com/rack/rack/pull/1663), [@ioquatix]) -- `Rack::HTTP_VERSION` has been removed and the `HTTP_VERSION` env setting is no longer set in the CGI and Webrick handlers. ([#970](https://github.com/rack/rack/issues/970), [@jeremyevans]) -- `Rack::Request#[]` and `#[]=` now warn even in non-verbose mode. ([#1277](https://github.com/rack/rack/issues/1277), [@jeremyevans]) -- Decrease default allowed parameter recursion level from 100 to 32. ([#1640](https://github.com/rack/rack/issues/1640), [@jeremyevans]) -- Attempting to parse a multipart response with an empty body now raises Rack::Multipart::EmptyContentError. ([#1603](https://github.com/rack/rack/issues/1603), [@jeremyevans]) -- `Rack::Utils.secure_compare` uses OpenSSL's faster implementation if available. ([#1711](https://github.com/rack/rack/pull/1711), [@bdewater](https://github.com/bdewater)) -- `Rack::Request#POST` now caches an empty hash if input content type is not parseable. ([#749](https://github.com/rack/rack/pull/749), [@jeremyevans]) -- BREAKING CHANGE: Updated `trusted_proxy?` to match full 127.0.0.0/8 network. ([#1781](https://github.com/rack/rack/pull/1781), [@snbloch](https://github.com/snbloch)) -- Explicitly deprecate `Rack::File` which was an alias for `Rack::Files`. ([#1811](https://github.com/rack/rack/pull/1720), [@ioquatix]). -- Moved `Rack::Session` into [separate gem](https://github.com/rack/rack-session). ([#1805](https://github.com/rack/rack/pull/1805), [@ioquatix]) -- `rackup -D` option to daemonizes no longer changes the working directory to the root. ([#1813](https://github.com/rack/rack/pull/1813), [@jeremyevans]) -- The `x-forwarded-proto` header is now considered before the `x-forwarded-scheme` header for determining the forwarded protocol. `Rack::Request.x_forwarded_proto_priority` accessor has been added for configuring the priority of which header to check. ([#1809](https://github.com/rack/rack/issues/1809), [@jeremyevans]) -- `Rack::Request.forwarded_authority` (and methods that call it, such as `host`) now returns the last authority in the forwarded header, instead of the first, as earlier forwarded authorities can be forged by clients. This restores the Rack 2.1 behavior. ([#1829](https://github.com/rack/rack/issues/1809), [@jeremyevans]) -- Use lower case cookie attributes when creating cookies, and fold cookie attributes to lower case when reading cookies (specifically impacting `secure` and `httponly` attributes). ([#1849](https://github.com/rack/rack/pull/1849), [@ioquatix]) -- The response array must now be mutable (non-frozen) so middleware can modify it without allocating a new Array,therefore reducing object allocations. ([#1887](https://github.com/rack/rack/pull/1887), [#1927](https://github.com/rack/rack/pull/1927), [@amatsuda], [@ioquatix]) -- `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. `rack.hijack_io` is no longer required/specified. ([#1939](https://github.com/rack/rack/pull/1939), [@ioquatix]) -- Allow calling close on `rack.input`. ([#1956](https://github.com/rack/rack/pull/1956), [@ioquatix]) - -### Fixed - -- Make Rack::MockResponse handle non-hash headers. ([#1629](https://github.com/rack/rack/issues/1629), [@jeremyevans]) -- TempfileReaper now deletes temp files if application raises an exception. ([#1679](https://github.com/rack/rack/issues/1679), [@jeremyevans]) -- Handle cookies with values that end in '=' ([#1645](https://github.com/rack/rack/pull/1645), [@lukaso](https://github.com/lukaso)) -- Make `Rack::NullLogger` respond to `#fatal!` [@jeremyevans]) -- Fix multipart filename generation for filenames that contain spaces. Encode spaces as "%20" instead of "+" which will be decoded properly by the multipart parser. ([#1736](https://github.com/rack/rack/pull/1645), [@muirdm](https://github.com/muirdm)) -- `Rack::Request#scheme` returns `ws` or `wss` when one of the `X-Forwarded-Scheme` / `X-Forwarded-Proto` headers is set to `ws` or `wss`, respectively. ([#1730](https://github.com/rack/rack/issues/1730), [@erwanst](https://github.com/erwanst)) - -## [2.2.4] - 2022-06-30 - -- Better support for lower case headers in `Rack::ETag` middleware. ([#1919](https://github.com/rack/rack/pull/1919), [@ioquatix](https://github.com/ioquatix)) -- Use custom exception on params too deep error. ([#1838](https://github.com/rack/rack/pull/1838), [@simi](https://github.com/simi)) - -## [2.2.3.1] - 2022-05-27 - -### Security - -- [CVE-2022-30123] Fix shell escaping issue in Common Logger -- [CVE-2022-30122] Restrict parsing of broken MIME attachments - -## [2.2.3] - 2020-06-15 - -### Security - -- [[CVE-2020-8184](https://nvd.nist.gov/vuln/detail/CVE-2020-8184)] Do not allow percent-encoded cookie name to override existing cookie names. BREAKING CHANGE: Accessing cookie names that require URL encoding with decoded name no longer works. ([@fletchto99](https://github.com/fletchto99)) - -## [2.2.2] - 2020-02-11 - -### Fixed - -- Fix incorrect `Rack::Request#host` value. ([#1591](https://github.com/rack/rack/pull/1591), [@ioquatix]) -- Revert `Rack::Handler::Thin` implementation. ([#1583](https://github.com/rack/rack/pull/1583), [@jeremyevans]) -- Double assignment is still needed to prevent an "unused variable" warning. ([#1589](https://github.com/rack/rack/pull/1589), [@kamipo](https://github.com/kamipo)) -- Fix to handle same_site option for session pool. ([#1587](https://github.com/rack/rack/pull/1587), [@kamipo](https://github.com/kamipo)) - -## [2.2.1] - 2020-02-09 - -### Fixed - -- Rework `Rack::Request#ip` to handle empty `forwarded_for`. ([#1577](https://github.com/rack/rack/pull/1577), [@ioquatix]) - -## [2.2.0] - 2020-02-08 - -### SPEC Changes - -- `rack.session` request environment entry must respond to `to_hash` and return unfrozen Hash. ([@jeremyevans]) -- Request environment cannot be frozen. ([@jeremyevans]) -- CGI values in the request environment with non-ASCII characters must use ASCII-8BIT encoding. ([@jeremyevans]) -- Improve SPEC/lint relating to SERVER_NAME, SERVER_PORT and HTTP_HOST. ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix]) - -### Added - -- `rackup` supports multiple `-r` options and will require all arguments. ([@jeremyevans]) -- `Server` supports an array of paths to require for the `:require` option. ([@khotta](https://github.com/khotta)) -- `Files` supports multipart range requests. ([@fatkodima](https://github.com/fatkodima)) -- `Multipart::UploadedFile` supports an IO-like object instead of using the filesystem, using `:filename` and `:io` options. ([@jeremyevans]) -- `Multipart::UploadedFile` supports keyword arguments `:path`, `:content_type`, and `:binary` in addition to positional arguments. ([@jeremyevans]) -- `Static` supports a `:cascade` option for calling the app if there is no matching file. ([@jeremyevans]) -- `Session::Abstract::SessionHash#dig`. ([@jeremyevans]) -- `Response.[]` and `MockResponse.[]` for creating instances using status, headers, and body. ([@ioquatix]) -- Convenient cache and content type methods for `Rack::Response`. ([#1555](https://github.com/rack/rack/pull/1555), [@ioquatix]) - -### Changed - -- `Request#params` no longer rescues EOFError. ([@jeremyevans]) -- `Directory` uses a streaming approach, significantly improving time to first byte for large directories. ([@jeremyevans]) -- `Directory` no longer includes a Parent directory link in the root directory index. ([@jeremyevans]) -- `QueryParser#parse_nested_query` uses original backtrace when reraising exception with new class. ([@jeremyevans]) -- `ConditionalGet` follows RFC 7232 precedence if both If-None-Match and If-Modified-Since headers are provided. ([@jeremyevans]) -- `.ru` files supports the `frozen-string-literal` magic comment. ([@eregon](https://github.com/eregon)) -- Rely on autoload to load constants instead of requiring internal files, make sure to require 'rack' and not just 'rack/...'. ([@jeremyevans]) -- BREAKING CHANGE: `Etag` will continue sending ETag even if the response should not be cached. Streaming no longer works without a workaround, see [#1619](https://github.com/rack/rack/issues/1619#issuecomment-848460528). ([@henm](https://github.com/henm)) -- `Request#host_with_port` no longer includes a colon for a missing or empty port. ([@AlexWayfer](https://github.com/AlexWayfer)) -- All handlers uses keywords arguments instead of an options hash argument. ([@ioquatix]) -- `Files` handling of range requests no longer return a body that supports `to_path`, to ensure range requests are handled correctly. ([@jeremyevans]) -- `Multipart::Generator` only includes `Content-Length` for files with paths, and `Content-Disposition` `filename` if the `UploadedFile` instance has one. ([@jeremyevans]) -- `Request#ssl?` is true for the `wss` scheme (secure websockets). ([@jeremyevans]) -- `Rack::HeaderHash` is memoized by default. ([#1549](https://github.com/rack/rack/pull/1549), [@ioquatix]) -- `Rack::Directory` allow directory traversal inside root directory. ([#1417](https://github.com/rack/rack/pull/1417), [@ThomasSevestre](https://github.com/ThomasSevestre)) -- Sort encodings by server preference. ([#1184](https://github.com/rack/rack/pull/1184), [@ioquatix], [@wjordan](https://github.com/wjordan)) -- Rework host/hostname/authority implementation in `Rack::Request`. `#host` and `#host_with_port` have been changed to correctly return IPv6 addresses formatted with square brackets, as defined by [RFC3986](https://tools.ietf.org/html/rfc3986#section-3.2.2). ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix]) -- `Rack::Builder` parsing options on first `#\` line is deprecated. ([#1574](https://github.com/rack/rack/pull/1574), [@ioquatix]) - -### Removed - -- `Directory#path` as it was not used and always returned nil. ([@jeremyevans]) -- `BodyProxy#each` as it was only needed to work around a bug in Ruby <1.9.3. ([@jeremyevans]) -- `URLMap::INFINITY` and `URLMap::NEGATIVE_INFINITY`, in favor of `Float::INFINITY`. ([@ch1c0t](https://github.com/ch1c0t)) -- Deprecation of `Rack::File`. It will be deprecated again in rack 2.2 or 3.0. ([@rafaelfranca](https://github.com/rafaelfranca)) -- Support for Ruby 2.2 as it is well past EOL. ([@ioquatix]) -- Remove `Rack::Files#response_body` as the implementation was broken. ([#1153](https://github.com/rack/rack/pull/1153), [@ioquatix]) -- Remove `SERVER_ADDR` which was never part of the original SPEC. ([#1573](https://github.com/rack/rack/pull/1573), [@ioquatix]) - -### Fixed - -- `Directory` correctly handles root paths containing glob metacharacters. ([@jeremyevans]) -- `Cascade` uses a new response object for each call if initialized with no apps. ([@jeremyevans]) -- `BodyProxy` correctly delegates keyword arguments to the body object on Ruby 2.7+. ([@jeremyevans]) -- `BodyProxy#method` correctly handles methods delegated to the body object. ([@jeremyevans]) -- `Request#host` and `Request#host_with_port` handle IPv6 addresses correctly. ([@AlexWayfer](https://github.com/AlexWayfer)) -- `Lint` checks when response hijacking that `rack.hijack` is called with a valid object. ([@jeremyevans]) -- `Response#write` correctly updates `Content-Length` if initialized with a body. ([@jeremyevans]) -- `CommonLogger` includes `SCRIPT_NAME` when logging. ([@Erol](https://github.com/Erol)) -- `Utils.parse_nested_query` correctly handles empty queries, using an empty instance of the params class instead of a hash. ([@jeremyevans]) -- `Directory` correctly escapes paths in links. ([@yous](https://github.com/yous)) -- `Request#delete_cookie` and related `Utils` methods handle `:domain` and `:path` options in same call. ([@jeremyevans]) -- `Request#delete_cookie` and related `Utils` methods do an exact match on `:domain` and `:path` options. ([@jeremyevans]) -- `Static` no longer adds headers when a gzipped file request has a 304 response. ([@chooh](https://github.com/chooh)) -- `ContentLength` sets `Content-Length` response header even for bodies not responding to `to_ary`. ([@jeremyevans]) -- Thin handler supports options passed directly to `Thin::Controllers::Controller`. ([@jeremyevans]) -- WEBrick handler no longer ignores `:BindAddress` option. ([@jeremyevans]) -- `ShowExceptions` handles invalid POST data. ([@jeremyevans]) -- Basic authentication requires a password, even if the password is empty. ([@jeremyevans]) -- `Lint` checks response is array with 3 elements, per SPEC. ([@jeremyevans]) -- Support for using `:SSLEnable` option when using WEBrick handler. (Gregor Melhorn) -- Close response body after buffering it when buffering. ([@ioquatix]) -- Only accept `;` as delimiter when parsing cookies. ([@mrageh](https://github.com/mrageh)) -- `Utils::HeaderHash#clear` clears the name mapping as well. ([@raxoft](https://github.com/raxoft)) -- Support for passing `nil` `Rack::Files.new`, which notably fixes Rails' current `ActiveStorage::FileServer` implementation. ([@ioquatix]) - -### Documentation - -- CHANGELOG updates. ([@aupajo](https://github.com/aupajo)) -- Added [CONTRIBUTING](CONTRIBUTING.md). ([@dblock](https://github.com/dblock)) - -## [2.0.9] - 2020-02-08 - -- Handle case where session id key is requested but missing ([@jeremyevans]) -- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans]) -- Add support for `SameSite=None` cookie value. ([@hennikul](https://github.com/hennikul)) - -## [2.1.2] - 2020-01-27 - -- Fix multipart parser for some files to prevent denial of service ([@aiomaster](https://github.com/aiomaster)) -- Fix `Rack::Builder#use` with keyword arguments ([@kamipo](https://github.com/kamipo)) -- Skip deflating in Rack::Deflater if Content-Length is 0 ([@jeremyevans]) -- Remove `SessionHash#transform_keys`, no longer needed ([@pavel](https://github.com/pavel)) -- Add to_hash to wrap Hash and Session classes ([@oleh-demyanyuk](https://github.com/oleh-demyanyuk)) -- Handle case where session id key is requested but missing ([@jeremyevans]) - -## [2.1.1] - 2020-01-12 - -- Remove `Rack::Chunked` from `Rack::Server` default middleware. ([#1475](https://github.com/rack/rack/pull/1475), [@ioquatix]) -- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans]) - -## [2.1.0] - 2020-01-10 - -### Added - -- Add support for `SameSite=None` cookie value. ([@hennikul](https://github.com/hennikul)) -- Add trailer headers. ([@eileencodes](https://github.com/eileencodes)) -- Add MIME Types for video streaming. ([@styd](https://github.com/styd)) -- Add MIME Type for WASM. ([@buildrtech](https://github.com/buildrtech)) -- Add `Early Hints(103)` to status codes. ([@egtra](https://github.com/egtra)) -- Add `Too Early(425)` to status codes. ([@y-yagi]((https://github.com/y-yagi))) -- Add `Bandwidth Limit Exceeded(509)` to status codes. ([@CJKinni](https://github.com/CJKinni)) -- Add method for custom `ip_filter`. ([@svcastaneda](https://github.com/svcastaneda)) -- Add boot-time profiling capabilities to `rackup`. ([@tenderlove](https://github.com/tenderlove)) -- Add multi mapping support for `X-Accel-Mappings` header. ([@yoshuki](https://github.com/yoshuki)) -- Add `sync: false` option to `Rack::Deflater`. (Eric Wong) -- Add `Builder#freeze_app` to freeze application and all middleware instances. ([@jeremyevans]) -- Add API to extract cookies from `Rack::MockResponse`. ([@petercline](https://github.com/petercline)) - -### Changed - -- Don't propagate nil values from middleware. ([@ioquatix]) -- Lazily initialize the response body and only buffer it if required. ([@ioquatix]) -- Fix deflater zlib buffer errors on empty body part. ([@felixbuenemann](https://github.com/felixbuenemann)) -- Set `X-Accel-Redirect` to percent-encoded path. ([@diskkid](https://github.com/diskkid)) -- Remove unnecessary buffer growing when parsing multipart. ([@tainoe](https://github.com/tainoe)) -- Expand the root path in `Rack::Static` upon initialization. ([@rosenfeld](https://github.com/rosenfeld)) -- Make `ShowExceptions` work with binary data. ([@axyjo](https://github.com/axyjo)) -- Use buffer string when parsing multipart requests. ([@janko-m](https://github.com/janko-m)) -- Support optional UTF-8 Byte Order Mark (BOM) in config.ru. ([@mikegee](https://github.com/mikegee)) -- Handle `X-Forwarded-For` with optional port. ([@dpritchett](https://github.com/dpritchett)) -- Use `Time#httpdate` format for Expires, as proposed by RFC 7231. ([@nanaya](https://github.com/nanaya)) -- Make `Utils.status_code` raise an error when the status symbol is invalid instead of `500`. ([@adambutler](https://github.com/adambutler)) -- Rename `Request::SCHEME_WHITELIST` to `Request::ALLOWED_SCHEMES`. -- Make `Multipart::Parser.get_filename` accept files with `+` in their name. ([@lucaskanashiro](https://github.com/lucaskanashiro)) -- Add Falcon to the default handler fallbacks. ([@ioquatix]) -- Update codebase to avoid string mutations in preparation for `frozen_string_literals`. ([@pat](https://github.com/pat)) -- Change `MockRequest#env_for` to rely on the input optionally responding to `#size` instead of `#length`. ([@janko](https://github.com/janko)) -- Rename `Rack::File` -> `Rack::Files` and add deprecation notice. ([@postmodern](https://github.com/postmodern)) -- Prefer Base64 “strict encoding” for Base64 cookies. ([@ioquatix]) - -### Removed - -- BREAKING CHANGE: Remove `to_ary` from Response ([@tenderlove](https://github.com/tenderlove)) -- Deprecate `Rack::Session::Memcache` in favor of `Rack::Session::Dalli` from dalli gem ([@fatkodima](https://github.com/fatkodima)) - -### Fixed - -- Eliminate warnings for Ruby 2.7. ([@osamtimizer](https://github.com/osamtimizer])) - -### Documentation - -- Update broken example in `Session::Abstract::ID` documentation. ([tonytonyjan](https://github.com/tonytonyjan)) -- Add Padrino to the list of frameworks implementing Rack. ([@wikimatze](https://github.com/wikimatze)) -- Remove Mongrel from the suggested server options in the help output. ([@tricknotes](https://github.com/tricknotes)) -- Replace `HISTORY.md` and `NEWS.md` with `CHANGELOG.md`. ([@twitnithegirl](https://github.com/twitnithegirl)) -- CHANGELOG updates. ([@drenmi](https://github.com/Drenmi), [@p8](https://github.com/p8)) - -## [2.0.8] - 2019-12-08 - -### Security - -- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) - -## [1.6.12] - 2019-12-08 - -### Security - -- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) - -## [2.0.7] - 2019-04-02 - -### Fixed - -- Remove calls to `#eof?` on Rack input in `Multipart::Parser`, as this breaks the specification. ([@matthewd](https://github.com/matthewd)) -- Preserve forwarded IP addresses for trusted proxy chains. ([@SamSaffron](https://github.com/SamSaffron)) - -## [2.0.6] - 2018-11-05 - -### Fixed - -- [[CVE-2018-16470](https://nvd.nist.gov/vuln/detail/CVE-2018-16470)] Reduce buffer size of `Multipart::Parser` to avoid pathological parsing. ([@tenderlove](https://github.com/tenderlove)) -- Fix a call to a non-existing method `#accepts_html` in the `ShowExceptions` middleware. ([@tomelm](https://github.com/tomelm)) -- [[CVE-2018-16471](https://nvd.nist.gov/vuln/detail/CVE-2018-16471)] Whitelist HTTP and HTTPS schemes in `Request#scheme` to prevent a possible XSS attack. ([@PatrickTulskie](https://github.com/PatrickTulskie)) - -## [2.0.5] - 2018-04-23 - -### Fixed - -- Record errors originating from invalid UTF8 in `MethodOverride` middleware instead of breaking. ([@mclark](https://github.com/mclark)) - -## [2.0.4] - 2018-01-31 - -### Changed - -- Ensure the `Lock` middleware passes the original `env` object. ([@lugray](https://github.com/lugray)) -- Improve performance of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) -- Increase buffer size in `Multipart::Parser` for better performance. ([@jkowens](https://github.com/jkowens)) -- Reduce memory usage of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) -- Replace ConcurrentRuby dependency with native `Queue`. ([@devmchakan](https://github.com/devmchakan)) - -### Fixed - -- Require the correct digest algorithm in the `ETag` middleware. ([@matthewd](https://github.com/matthewd)) - -### Documentation - -- Update homepage links to use SSL. ([@hugoabonizio](https://github.com/hugoabonizio)) - -## [2.0.3] - 2017-05-15 - -### Changed - -- Ensure `env` values are ASCII 8-bit encoded. ([@eileencodes](https://github.com/eileencodes)) - -### Fixed - -- Prevent exceptions when a class with mixins inherits from `Session::Abstract::ID`. ([@jnraine](https://github.com/jnraine)) - -## [2.0.2] - 2017-05-08 - -### Added - -- Allow `Session::Abstract::SessionHash#fetch` to accept a block with a default value. ([@yannvanhalewyn](https://github.com/yannvanhalewyn)) -- Add `Builder#freeze_app` to freeze application and all middleware. ([@jeremyevans]) - -### Changed - -- Freeze default session options to avoid accidental mutation. ([@kirs](https://github.com/kirs)) -- Detect partial hijack without hash headers. ([@devmchakan](https://github.com/devmchakan)) -- Update tests to use MiniTest 6 matchers. ([@tonytonyjan](https://github.com/tonytonyjan)) -- Allow 205 Reset Content responses to set a Content-Length, as RFC 7231 proposes setting this to 0. ([@devmchakan](https://github.com/devmchakan)) - -### Fixed - -- Handle `NULL` bytes in multipart filenames. ([@casperisfine](https://github.com/casperisfine)) -- Remove warnings due to miscapitalized global. ([@ioquatix]) -- Prevent exceptions caused by a race condition on multi-threaded servers. ([@sophiedeziel](https://github.com/sophiedeziel)) -- Add RDoc as an explicit dependency for `doc` group. ([@tonytonyjan](https://github.com/tonytonyjan)) -- Record errors originating from `Multipart::Parser` in the `MethodOverride` middleware instead of letting them bubble up. ([@carlzulauf](https://github.com/carlzulauf)) -- Remove remaining use of removed `Utils#bytesize` method from the `File` middleware. ([@brauliomartinezlm](https://github.com/brauliomartinezlm)) - -### Removed - -- Remove `deflate` encoding support to reduce caching overhead. ([@devmchakan](https://github.com/devmchakan)) - -### Documentation - -- Update broken example in `Deflater` documentation. ([@mwpastore](https://github.com/mwpastore)) - -## [2.0.1] - 2016-06-30 - -### Changed - -- Remove JSON as an explicit dependency. ([@mperham](https://github.com/mperham)) - - -# History/News Archive -Items below this line are from the previously maintained HISTORY.md and NEWS.md files. - -## [2.0.0.rc1] 2016-05-06 -- Rack::Session::Abstract::ID is deprecated. Please change to use Rack::Session::Abstract::Persisted - -## [2.0.0.alpha] 2015-12-04 -- First-party "SameSite" cookies. Browsers omit SameSite cookies from third-party requests, closing the door on many CSRF attacks. -- Pass `same_site: true` (or `:strict`) to enable: response.set_cookie 'foo', value: 'bar', same_site: true or `same_site: :lax` to use Lax enforcement: response.set_cookie 'foo', value: 'bar', same_site: :lax -- Based on version 7 of the Same-site Cookies internet draft: - https://tools.ietf.org/html/draft-west-first-party-cookies-07 -- Thanks to Ben Toews (@mastahyeti) and Bob Long (@bobjflong) for updating to drafts 5 and 7. -- Add `Rack::Events` middleware for adding event based middleware: middleware that does not care about the response body, but only cares about doing work at particular points in the request / response lifecycle. -- Add `Rack::Request#authority` to calculate the authority under which the response is being made (this will be handy for h2 pushes). -- Add `Rack::Response::Helpers#cache_control` and `cache_control=`. Use this for setting cache control headers on your response objects. -- Add `Rack::Response::Helpers#etag` and `etag=`. Use this for setting etag values on the response. -- Introduce `Rack::Response::Helpers#add_header` to add a value to a multi-valued response header. Implemented in terms of other `Response#*_header` methods, so it's available to any response-like class that includes the `Helpers` module. -- Add `Rack::Request#add_header` to match. -- `Rack::Session::Abstract::ID` IS DEPRECATED. Please switch to `Rack::Session::Abstract::Persisted`. `Rack::Session::Abstract::Persisted` uses a request object rather than the `env` hash. -- Pull `ENV` access inside the request object in to a module. This will help with legacy Request objects that are ENV based but don't want to inherit from Rack::Request -- Move most methods on the `Rack::Request` to a module `Rack::Request::Helpers` and use public API to get values from the request object. This enables users to mix `Rack::Request::Helpers` in to their own objects so they can implement `(get|set|fetch|each)_header` as they see fit (for example a proxy object). -- Files and directories with + in the name are served correctly. Rather than unescaping paths like a form, we unescape with a URI parser using `Rack::Utils.unescape_path`. Fixes #265 -- Tempfiles are automatically closed in the case that there were too - many posted. -- Added methods for manipulating response headers that don't assume - they're stored as a Hash. Response-like classes may include the - Rack::Response::Helpers module if they define these methods: - - Rack::Response#has_header? - - Rack::Response#get_header - - Rack::Response#set_header - - Rack::Response#delete_header -- Introduce Util.get_byte_ranges that will parse the value of the HTTP_RANGE string passed to it without depending on the `env` hash. `byte_ranges` is deprecated in favor of this method. -- Change Session internals to use Request objects for looking up session information. This allows us to only allocate one request object when dealing with session objects (rather than doing it every time we need to manipulate cookies, etc). -- Add `Rack::Request#initialize_copy` so that the env is duped when the request gets duped. -- Added methods for manipulating request specific data. This includes - data set as CGI parameters, and just any arbitrary data the user wants - to associate with a particular request. New methods: - - Rack::Request#has_header? - - Rack::Request#get_header - - Rack::Request#fetch_header - - Rack::Request#each_header - - Rack::Request#set_header - - Rack::Request#delete_header -- lib/rack/utils.rb: add a method for constructing "delete" cookie - headers. This allows us to construct cookie headers without depending - on the side effects of mutating a hash. -- Prevent extremely deep parameters from being parsed. CVE-2015-3225 - -## [1.6.1] 2015-05-06 - - Fix CVE-2014-9490, denial of service attack in OkJson - - Use a monotonic time for Rack::Runtime, if available - - RACK_MULTIPART_LIMIT changed to RACK_MULTIPART_PART_LIMIT (RACK_MULTIPART_LIMIT is deprecated and will be removed in 1.7.0) - -## [1.5.3] 2015-05-06 - - Fix CVE-2014-9490, denial of service attack in OkJson - - Backport bug fixes to 1.5 series - -## [1.6.0] 2014-01-18 - - Response#unauthorized? helper - - Deflater now accepts an options hash to control compression on a per-request level - - Builder#warmup method for app preloading - - Request#accept_language method to extract HTTP_ACCEPT_LANGUAGE - - Add quiet mode of rack server, rackup --quiet - - Update HTTP Status Codes to RFC 7231 - - Less strict header name validation according to RFC 2616 - - SPEC updated to specify headers conform to RFC7230 specification - - Etag correctly marks etags as weak - - Request#port supports multiple x-http-forwarded-proto values - - Utils#multipart_part_limit configures the maximum number of parts a request can contain - - Default host to localhost when in development mode - - Various bugfixes and performance improvements - -## [1.5.2] 2013-02-07 - - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie - - Fix CVE-2013-0262, symlink path traversal in Rack::File - - Add various methods to Session for enhanced Rails compatibility - - Request#trusted_proxy? now only matches whole strings - - Add JSON cookie coder, to be default in Rack 1.6+ due to security concerns - - URLMap host matching in environments that don't set the Host header fixed - - Fix a race condition that could result in overwritten pidfiles - - Various documentation additions - -## [1.4.5] 2013-02-07 - - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie - - Fix CVE-2013-0262, symlink path traversal in Rack::File - -## [1.1.6, 1.2.8, 1.3.10] 2013-02-07 - - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie - -## [1.5.1] 2013-01-28 - - Rack::Lint check_hijack now conforms to other parts of SPEC - - Added hash-like methods to Abstract::ID::SessionHash for compatibility - - Various documentation corrections - -## [1.5.0] 2013-01-21 - - Introduced hijack SPEC, for before-response and after-response hijacking - - SessionHash is no longer a Hash subclass - - Rack::File cache_control parameter is removed, in place of headers options - - Rack::Auth::AbstractRequest#scheme now yields strings, not symbols - - Rack::Utils cookie functions now format expires in RFC 2822 format - - Rack::File now has a default mime type - - rackup -b 'run Rack::Files.new(".")', option provides command line configs - - Rack::Deflater will no longer double encode bodies - - Rack::Mime#match? provides convenience for Accept header matching - - Rack::Utils#q_values provides splitting for Accept headers - - Rack::Utils#best_q_match provides a helper for Accept headers - - Rack::Handler.pick provides convenience for finding available servers - - Puma added to the list of default servers (preferred over Webrick) - - Various middleware now correctly close body when replacing it - - Rack::Request#params is no longer persistent with only GET params - - Rack::Request#update_param and #delete_param provide persistent operations - - Rack::Request#trusted_proxy? now returns true for local unix sockets - - Rack::Response no longer forces Content-Types - - Rack::Sendfile provides local mapping configuration options - - Rack::Utils#rfc2109 provides old netscape style time output - - Updated HTTP status codes - - Ruby 1.8.6 likely no longer passes tests, and is no longer fully supported - -## [1.4.4, 1.3.9, 1.2.7, 1.1.5] 2013-01-13 - - [SEC] Rack::Auth::AbstractRequest no longer symbolizes arbitrary strings - - Fixed erroneous test case in the 1.3.x series - -## [1.4.3] 2013-01-07 - - Security: Prevent unbounded reads in large multipart boundaries - -## [1.3.8] 2013-01-07 - - Security: Prevent unbounded reads in large multipart boundaries - -## [1.4.2] 2013-01-06 - - Add warnings when users do not provide a session secret - - Fix parsing performance for unquoted filenames - - Updated URI backports - - Fix URI backport version matching, and silence constant warnings - - Correct parameter parsing with empty values - - Correct rackup '-I' flag, to allow multiple uses - - Correct rackup pidfile handling - - Report rackup line numbers correctly - - Fix request loops caused by non-stale nonces with time limits - - Fix reloader on Windows - - Prevent infinite recursions from Response#to_ary - - Various middleware better conforms to the body close specification - - Updated language for the body close specification - - Additional notes regarding ECMA escape compatibility issues - - Fix the parsing of multiple ranges in range headers - - Prevent errors from empty parameter keys - - Added PATCH verb to Rack::Request - - Various documentation updates - - Fix session merge semantics (fixes rack-test) - - Rack::Static :index can now handle multiple directories - - All tests now utilize Rack::Lint (special thanks to Lars Gierth) - - Rack::File cache_control parameter is now deprecated, and removed by 1.5 - - Correct Rack::Directory script name escaping - - Rack::Static supports header rules for sophisticated configurations - - Multipart parsing now works without a Content-Length header - - New logos courtesy of Zachary Scott! - - Rack::BodyProxy now explicitly defines #each, useful for C extensions - - Cookies that are not URI escaped no longer cause exceptions - -## [1.3.7] 2013-01-06 - - Add warnings when users do not provide a session secret - - Fix parsing performance for unquoted filenames - - Updated URI backports - - Fix URI backport version matching, and silence constant warnings - - Correct parameter parsing with empty values - - Correct rackup '-I' flag, to allow multiple uses - - Correct rackup pidfile handling - - Report rackup line numbers correctly - - Fix request loops caused by non-stale nonces with time limits - - Fix reloader on Windows - - Prevent infinite recursions from Response#to_ary - - Various middleware better conforms to the body close specification - - Updated language for the body close specification - - Additional notes regarding ECMA escape compatibility issues - - Fix the parsing of multiple ranges in range headers - -## [1.2.6] 2013-01-06 - - Add warnings when users do not provide a session secret - - Fix parsing performance for unquoted filenames - -## [1.1.4] 2013-01-06 - - Add warnings when users do not provide a session secret - -## [1.4.1] 2012-01-22 - - Alter the keyspace limit calculations to reduce issues with nested params - - Add a workaround for multipart parsing where files contain unescaped "%" - - Added Rack::Response::Helpers#method_not_allowed? (code 405) - - Rack::File now returns 404 for illegal directory traversals - - Rack::File now returns 405 for illegal methods (non HEAD/GET) - - Rack::Cascade now catches 405 by default, as well as 404 - - Cookies missing '--' no longer cause an exception to be raised - - Various style changes and documentation spelling errors - - Rack::BodyProxy always ensures to execute its block - - Additional test coverage around cookies and secrets - - Rack::Session::Cookie can now be supplied either secret or old_secret - - Tests are no longer dependent on set order - - Rack::Static no longer defaults to serving index files - - Rack.release was fixed - -## [1.4.0] 2011-12-28 - - Ruby 1.8.6 support has officially been dropped. Not all tests pass. - - Raise sane error messages for broken config.ru - - Allow combining run and map in a config.ru - - Rack::ContentType will not set Content-Type for responses without a body - - Status code 205 does not send a response body - - Rack::Response::Helpers will not rely on instance variables - - Rack::Utils.build_query no longer outputs '=' for nil query values - - Various mime types added - - Rack::MockRequest now supports HEAD - - Rack::Directory now supports files that contain RFC3986 reserved chars - - Rack::File now only supports GET and HEAD requests - - Rack::Server#start now passes the block to Rack::Handler::#run - - Rack::Static now supports an index option - - Added the Teapot status code - - rackup now defaults to Thin instead of Mongrel (if installed) - - Support added for HTTP_X_FORWARDED_SCHEME - - Numerous bug fixes, including many fixes for new and alternate rubies - -## [1.1.3] 2011-12-28 - - Security fix. http://www.ocert.org/advisories/ocert-2011-003.html - Further information here: http://jruby.org/2011/12/27/jruby-1-6-5-1 - -## [1.3.5] 2011-10-17 - - Fix annoying warnings caused by the backport in 1.3.4 - -## [1.3.4] 2011-10-01 - - Backport security fix from 1.9.3, also fixes some roundtrip issues in URI - - Small documentation update - - Fix an issue where BodyProxy could cause an infinite recursion - - Add some supporting files for travis-ci - -## [1.2.4] 2011-09-16 - - Fix a bug with MRI regex engine to prevent XSS by malformed unicode - -## [1.3.3] 2011-09-16 - - Fix bug with broken query parameters in Rack::ShowExceptions - - Rack::Request#cookies no longer swallows exceptions on broken input - - Prevents XSS attacks enabled by bug in Ruby 1.8's regexp engine - - Rack::ConditionalGet handles broken If-Modified-Since helpers - -## [1.3.2] 2011-07-16 - - Fix for Rails and rack-test, Rack::Utils#escape calls to_s - -## [1.3.1] 2011-07-13 - - Fix 1.9.1 support - - Fix JRuby support - - Properly handle $KCODE in Rack::Utils.escape - - Make method_missing/respond_to behavior consistent for Rack::Lock, - Rack::Auth::Digest::Request and Rack::Multipart::UploadedFile - - Reenable passing rack.session to session middleware - - Rack::CommonLogger handles streaming responses correctly - - Rack::MockResponse calls close on the body object - - Fix a DOS vector from MRI stdlib backport - -## [1.2.3] 2011-05-22 - - Pulled in relevant bug fixes from 1.3 - - Fixed 1.8.6 support - -## [1.3.0] 2011-05-22 - - Various performance optimizations - - Various multipart fixes - - Various multipart refactors - - Infinite loop fix for multipart - - Test coverage for Rack::Server returns - - Allow files with '..', but not path components that are '..' - - rackup accepts handler-specific options on the command line - - Request#params no longer merges POST into GET (but returns the same) - - Use URI.encode_www_form_component instead. Use core methods for escaping. - - Allow multi-line comments in the config file - - Bug L#94 reported by Nikolai Lugovoi, query parameter unescaping. - - Rack::Response now deletes Content-Length when appropriate - - Rack::Deflater now supports streaming - - Improved Rack::Handler loading and searching - - Support for the PATCH verb - - env['rack.session.options'] now contains session options - - Cookies respect renew - - Session middleware uses SecureRandom.hex - -## [1.2.2, 1.1.2] 2011-03-13 - - Security fix in Rack::Auth::Digest::MD5: when authenticator - returned nil, permission was granted on empty password. - -## [1.2.1] 2010-06-15 - - Make CGI handler rewindable - - Rename spec/ to test/ to not conflict with SPEC on lesser - operating systems - -## [1.2.0] 2010-06-13 - - Removed Camping adapter: Camping 2.0 supports Rack as-is - - Removed parsing of quoted values - - Add Request.trace? and Request.options? - - Add mime-type for .webm and .htc - - Fix HTTP_X_FORWARDED_FOR - - Various multipart fixes - - Switch test suite to bacon - -## [1.1.0] 2010-01-03 - - Moved Auth::OpenID to rack-contrib. - - SPEC change that relaxes Lint slightly to allow subclasses of the - required types - - SPEC change to document rack.input binary mode in greater detail - - SPEC define optional rack.logger specification - - File servers support X-Cascade header - - Imported Config middleware - - Imported ETag middleware - - Imported Runtime middleware - - Imported Sendfile middleware - - New Logger and NullLogger middlewares - - Added mime type for .ogv and .manifest. - - Don't squeeze PATH_INFO slashes - - Use Content-Type to determine POST params parsing - - Update Rack::Utils::HTTP_STATUS_CODES hash - - Add status code lookup utility - - Response should call #to_i on the status - - Add Request#user_agent - - Request#host knows about forwarded host - - Return an empty string for Request#host if HTTP_HOST and - SERVER_NAME are both missing - - Allow MockRequest to accept hash params - - Optimizations to HeaderHash - - Refactored rackup into Rack::Server - - Added Utils.build_nested_query to complement Utils.parse_nested_query - - Added Utils::Multipart.build_multipart to complement - Utils::Multipart.parse_multipart - - Extracted set and delete cookie helpers into Utils so they can be - used outside Response - - Extract parse_query and parse_multipart in Request so subclasses - can change their behavior - - Enforce binary encoding in RewindableInput - - Set correct external_encoding for handlers that don't use RewindableInput - -## [1.0.1] 2009-10-18 - - Bump remainder of rack.versions. - - Support the pure Ruby FCGI implementation. - - Fix for form names containing "=": split first then unescape components - - Fixes the handling of the filename parameter with semicolons in names. - - Add anchor to nested params parsing regexp to prevent stack overflows - - Use more compatible gzip write api instead of "<<". - - Make sure that Reloader doesn't break when executed via ruby -e - - Make sure WEBrick respects the :Host option - - Many Ruby 1.9 fixes. - -## [1.0.0] 2009-04-25 - - SPEC change: Rack::VERSION has been pushed to [1,0]. - - SPEC change: header values must be Strings now, split on "\n". - - SPEC change: Content-Length can be missing, in this case chunked transfer - encoding is used. - - SPEC change: rack.input must be rewindable and support reading into - a buffer, wrap with Rack::RewindableInput if it isn't. - - SPEC change: rack.session is now specified. - - SPEC change: Bodies can now additionally respond to #to_path with - a filename to be served. - - NOTE: String bodies break in 1.9, use an Array consisting of a - single String instead. - - New middleware Rack::Lock. - - New middleware Rack::ContentType. - - Rack::Reloader has been rewritten. - - Major update to Rack::Auth::OpenID. - - Support for nested parameter parsing in Rack::Response. - - Support for redirects in Rack::Response. - - HttpOnly cookie support in Rack::Response. - - The Rakefile has been rewritten. - - Many bugfixes and small improvements. - -## [0.9.1] 2009-01-09 - - Fix directory traversal exploits in Rack::File and Rack::Directory. - -## [0.9] 2009-01-06 - - Rack is now managed by the Rack Core Team. - - Rack::Lint is stricter and follows the HTTP RFCs more closely. - - Added ConditionalGet middleware. - - Added ContentLength middleware. - - Added Deflater middleware. - - Added Head middleware. - - Added MethodOverride middleware. - - Rack::Mime now provides popular MIME-types and their extension. - - Mongrel Header now streams. - - Added Thin handler. - - Official support for swiftiplied Mongrel. - - Secure cookies. - - Made HeaderHash case-preserving. - - Many bugfixes and small improvements. - -## [0.4] 2008-08-21 - - New middleware, Rack::Deflater, by Christoffer Sawicki. - - OpenID authentication now needs ruby-openid 2. - - New Memcache sessions, by blink. - - Explicit EventedMongrel handler, by Joshua Peek - - Rack::Reloader is not loaded in rackup development mode. - - rackup can daemonize with -D. - - Many bugfixes, especially for pool sessions, URLMap, thread safety - and tempfile handling. - - Improved tests. - - Rack moved to Git. - -## [0.3] 2008-02-26 - - LiteSpeed handler, by Adrian Madrid. - - SCGI handler, by Jeremy Evans. - - Pool sessions, by blink. - - OpenID authentication, by blink. - - :Port and :File options for opening FastCGI sockets, by blink. - - Last-Modified HTTP header for Rack::File, by blink. - - Rack::Builder#use now accepts blocks, by Corey Jewett. - (See example/protectedlobster.ru) - - HTTP status 201 can contain a Content-Type and a body now. - - Many bugfixes, especially related to Cookie handling. - -## [0.2] 2007-05-16 - - HTTP Basic authentication. - - Cookie Sessions. - - Static file handler. - - Improved Rack::Request. - - Improved Rack::Response. - - Added Rack::ShowStatus, for better default error messages. - - Bug fixes in the Camping adapter. - - Removed Rails adapter, was too alpha. - -## [0.1] 2007-03-03 - -[@ioquatix]: https://github.com/ioquatix "Samuel Williams" -[@jeremyevans]: https://github.com/jeremyevans "Jeremy Evans" -[@amatsuda]: https://github.com/amatsuda "Akira Matsuda" -[@wjordan]: https://github.com/wjordan "Will Jordan" -[@BlakeWilliams]: https://github.com/BlakeWilliams "Blake Williams" -[@davidstosik]: https://github.com/davidstosik "David Stosik" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CONTRIBUTING.md b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CONTRIBUTING.md deleted file mode 100644 index a95263d..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/CONTRIBUTING.md +++ /dev/null @@ -1,144 +0,0 @@ -# Contributing to Rack - -Rack is work of [hundreds of -contributors](https://github.com/rack/rack/graphs/contributors). You're -encouraged to submit [pull requests](https://github.com/rack/rack/pulls) and -[propose features and discuss issues](https://github.com/rack/rack/issues). - -## Backports - -Only security patches are ideal for backporting to non-main release versions. If -you're not sure if your bug fix is backportable, you should open a discussion to -discuss it first. - -The [Security Policy] documents which release versions will receive security -backports. - -## Fork the Project - -Fork the [project on GitHub](https://github.com/rack/rack) and check out your -copy. - -``` -git clone https://github.com/(your-github-username)/rack.git -cd rack -git remote add upstream https://github.com/rack/rack.git -``` - -## Create a Topic Branch - -Make sure your fork is up-to-date and create a topic branch for your feature or -bug fix. - -``` -git checkout main -git pull upstream main -git checkout -b my-feature-branch -``` - -## Running All Tests - -Install all dependencies. - -``` -bundle install -``` - -Run all tests. - -``` -rake test -``` - -## Write Tests - -Try to write a test that reproduces the problem you're trying to fix or -describes a feature that you want to build. - -We definitely appreciate pull requests that highlight or reproduce a problem, -even without a fix. - -## Write Code - -Implement your feature or bug fix. - -Make sure that all tests pass: - -``` -bundle exec rake test -``` - -## Write Documentation - -Document any external behavior in the [README](README.md). - -## Update Changelog - -Add a line to [CHANGELOG](CHANGELOG.md). - -## Commit Changes - -Make sure git knows your name and email address: - -``` -git config --global user.name "Your Name" -git config --global user.email "contributor@example.com" -``` - -Writing good commit logs is important. A commit log should describe what changed -and why. - -``` -git add ... -git commit -``` - -## Push - -``` -git push origin my-feature-branch -``` - -## Make a Pull Request - -Go to your fork of rack on GitHub and select your feature branch. Click the -'Pull Request' button and fill out the form. Pull requests are usually -reviewed within a few days. - -## Rebase - -If you've been working on a change for a while, rebase with upstream/main. - -``` -git fetch upstream -git rebase upstream/main -git push origin my-feature-branch -f -``` - -## Make Required Changes - -Amend your previous commit and force push the changes. - -``` -git commit --amend -git push origin my-feature-branch -f -``` - -## Check on Your Pull Request - -Go back to your pull request after a few minutes and see whether it passed -tests with GitHub Actions. Everything should look green, otherwise fix issues and -amend your commit as described above. - -## Be Patient - -It's likely that your change will not be merged and that the nitpicky -maintainers will ask you to do more, or fix seemingly benign problems. Hang in -there! - -## Thank You - -Please do know that we really appreciate and value your time and work. We love -you, really. - -[Security Policy]: SECURITY.md diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/MIT-LICENSE b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/MIT-LICENSE deleted file mode 100644 index fb33b7f..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/MIT-LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -The MIT License (MIT) - -Copyright (C) 2007-2021 Leah Neukirchen - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL -THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/README.md b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/README.md deleted file mode 100644 index 3a197b1..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/README.md +++ /dev/null @@ -1,328 +0,0 @@ -# ![Rack](contrib/logo.webp) - -Rack provides a minimal, modular, and adaptable interface for developing web -applications in Ruby. By wrapping HTTP requests and responses in the simplest -way possible, it unifies and distills the bridge between web servers, web -frameworks, and web application into a single method call. - -The exact details of this are described in the [Rack Specification], which all -Rack applications should conform to. - -## Version support - -| Version | Support | -|----------|------------------------------------| -| 3.0.x | Bug fixes and security patches. | -| 2.2.x | Security patches only. | -| <= 2.1.x | End of support. | - -Please see the [Security Policy] for more information. - -## Rack 3.0 - -This is the latest version of Rack. It contains API improvements but also some -breaking changes. Please check the [Upgrade Guide](UPGRADE-GUIDE.md) for more -details about migrating servers, middlewares and applications designed for Rack 2 -to Rack 3. For detailed information on specific changes, check the [Change Log](CHANGELOG.md). - -## Rack 2.2 - -This version of Rack is receiving security patches only, and effort should be -made to move to Rack 3. - -Starting in Ruby 3.4 the `base64` dependency will no longer be a default gem, -and may cause a warning or error about `base64` being missing. To correct this, -add `base64` as a dependency to your project. - -## Installation - -Add the rack gem to your application bundle, or follow the instructions provided -by a [supported web framework](#supported-web-frameworks): - -```bash -# Install it generally: -$ gem install rack - -# or, add it to your current application gemfile: -$ bundle add rack -``` - -If you need features from `Rack::Session` or `bin/rackup` please add those gems separately. - -```bash -$ gem install rack-session rackup -``` - -## Usage - -Create a file called `config.ru` with the following contents: - -```ruby -run do |env| - [200, {}, ["Hello World"]] -end -``` - -Run this using the rackup gem or another [supported web -server](#supported-web-servers). - -```bash -$ gem install rackup -$ rackup -$ curl http://localhost:9292 -Hello World -``` - -## Supported web servers - -Rack is supported by a wide range of servers, including: - -* [Agoo](https://github.com/ohler55/agoo) -* [Falcon](https://github.com/socketry/falcon) -* [Iodine](https://github.com/boazsegev/iodine) -* [NGINX Unit](https://unit.nginx.org/) -* [Phusion Passenger](https://www.phusionpassenger.com/) (which is mod_rack for - Apache and for nginx) -* [Puma](https://puma.io/) -* [Thin](https://github.com/macournoyer/thin) -* [Unicorn](https://yhbt.net/unicorn/) -* [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) -* [Lamby](https://lamby.custominktech.com) (for AWS Lambda) - -You will need to consult the server documentation to find out what features and -limitations they may have. In general, any valid Rack app will run the same on -all these servers, without changing anything. - -### Rackup - -Rack provides a separate gem, [rackup](https://github.com/rack/rackup) which is -a generic interface for running a Rack application on supported servers, which -include `WEBRick`, `Puma`, `Falcon` and others. - -## Supported web frameworks - -These frameworks and many others support the [Rack Specification]: - -* [Camping](https://github.com/camping/camping) -* [Hanami](https://hanamirb.org/) -* [Ramaze](https://github.com/ramaze/ramaze) -* [Padrino](https://padrinorb.com/) -* [Roda](https://github.com/jeremyevans/roda) -* [Ruby on Rails](https://rubyonrails.org/) -* [Rum](https://github.com/leahneukirchen/rum) -* [Sinatra](https://sinatrarb.com/) -* [Utopia](https://github.com/socketry/utopia) -* [WABuR](https://github.com/ohler55/wabur) - -## Available middleware shipped with Rack - -Between the server and the framework, Rack can be customized to your -applications needs using middleware. Rack itself ships with the following -middleware: - -* `Rack::CommonLogger` for creating Apache-style logfiles. -* `Rack::ConditionalGet` for returning [Not - Modified](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) - responses when the response has not changed. -* `Rack::Config` for modifying the environment before processing the request. -* `Rack::ContentLength` for setting a `content-length` header based on body - size. -* `Rack::ContentType` for setting a default `content-type` header for responses. -* `Rack::Deflater` for compressing responses with gzip. -* `Rack::ETag` for setting `etag` header on bodies that can be buffered. -* `Rack::Events` for providing easy hooks when a request is received and when - the response is sent. -* `Rack::Files` for serving static files. -* `Rack::Head` for returning an empty body for HEAD requests. -* `Rack::Lint` for checking conformance to the [Rack Specification]. -* `Rack::Lock` for serializing requests using a mutex. -* `Rack::Logger` for setting a logger to handle logging errors. -* `Rack::MethodOverride` for modifying the request method based on a submitted - parameter. -* `Rack::Recursive` for including data from other paths in the application, and - for performing internal redirects. -* `Rack::Reloader` for reloading files if they have been modified. -* `Rack::Runtime` for including a response header with the time taken to process - the request. -* `Rack::Sendfile` for working with web servers that can use optimized file - serving for file system paths. -* `Rack::ShowException` for catching unhandled exceptions and presenting them in - a nice and helpful way with clickable backtrace. -* `Rack::ShowStatus` for using nice error pages for empty client error - responses. -* `Rack::Static` for more configurable serving of static files. -* `Rack::TempfileReaper` for removing temporary files creating during a request. - -All these components use the same interface, which is described in detail in the -[Rack Specification]. These optional components can be used in any way you wish. - -### Convenience interfaces - -If you want to develop outside of existing frameworks, implement your own ones, -or develop middleware, Rack provides many helpers to create Rack applications -quickly and without doing the same web stuff all over: - -* `Rack::Request` which also provides query string parsing and multipart - handling. -* `Rack::Response` for convenient generation of HTTP replies and cookie - handling. -* `Rack::MockRequest` and `Rack::MockResponse` for efficient and quick testing - of Rack application without real HTTP round-trips. -* `Rack::Cascade` for trying additional Rack applications if an application - returns a not found or method not supported response. -* `Rack::Directory` for serving files under a given directory, with directory - indexes. -* `Rack::MediaType` for parsing content-type headers. -* `Rack::Mime` for determining content-type based on file extension. -* `Rack::RewindableInput` for making any IO object rewindable, using a temporary - file buffer. -* `Rack::URLMap` to route to multiple applications inside the same process. - -## Configuration - -Rack exposes several configuration parameters to control various features of the -implementation. - -### `param_depth_limit` - -```ruby -Rack::Utils.param_depth_limit = 32 # default -``` - -The maximum amount of nesting allowed in parameters. For example, if set to 3, -this query string would be allowed: - -``` -?a[b][c]=d -``` - -but this query string would not be allowed: - -``` -?a[b][c][d]=e -``` - -Limiting the depth prevents a possible stack overflow when parsing parameters. - -### `multipart_file_limit` - -```ruby -Rack::Utils.multipart_file_limit = 128 # default -``` - -The maximum number of parts with a filename a request can contain. Accepting -too many parts can lead to the server running out of file handles. - -The default is 128, which means that a single request can't upload more than 128 -files at once. Set to 0 for no limit. - -Can also be set via the `RACK_MULTIPART_FILE_LIMIT` environment variable. - -(This is also aliased as `multipart_part_limit` and `RACK_MULTIPART_PART_LIMIT` for compatibility) - - -### `multipart_total_part_limit` - -The maximum total number of parts a request can contain of any type, including -both file and non-file form fields. - -The default is 4096, which means that a single request can't contain more than -4096 parts. - -Set to 0 for no limit. - -Can also be set via the `RACK_MULTIPART_TOTAL_PART_LIMIT` environment variable. - - -## Changelog - -See [CHANGELOG.md](CHANGELOG.md). - -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md) for specific details about how to make a -contribution to Rack. - -Please post bugs, suggestions and patches to [GitHub -Issues](https://github.com/rack/rack/issues). - -Please check our [Security Policy](https://github.com/rack/rack/security/policy) -for responsible disclosure and security bug reporting process. Due to wide usage -of the library, it is strongly preferred that we manage timing in order to -provide viable patches at the time of disclosure. Your assistance in this matter -is greatly appreciated. - -## See Also - -### `rack-contrib` - -The plethora of useful middleware created the need for a project that collects -fresh Rack middleware. `rack-contrib` includes a variety of add-on components -for Rack and it is easy to contribute new modules. - -* https://github.com/rack/rack-contrib - -### `rack-session` - -Provides convenient session management for Rack. - -* https://github.com/rack/rack-session - -## Thanks - -The Rack Core Team, consisting of - -* Aaron Patterson [tenderlove](https://github.com/tenderlove) -* Samuel Williams [ioquatix](https://github.com/ioquatix) -* Jeremy Evans [jeremyevans](https://github.com/jeremyevans) -* Eileen Uchitelle [eileencodes](https://github.com/eileencodes) -* Matthew Draper [matthewd](https://github.com/matthewd) -* Rafael França [rafaelfranca](https://github.com/rafaelfranca) - -and the Rack Alumni - -* Ryan Tomayko [rtomayko](https://github.com/rtomayko) -* Scytrin dai Kinthra [scytrin](https://github.com/scytrin) -* Leah Neukirchen [leahneukirchen](https://github.com/leahneukirchen) -* James Tucker [raggi](https://github.com/raggi) -* Josh Peek [josh](https://github.com/josh) -* José Valim [josevalim](https://github.com/josevalim) -* Michael Fellinger [manveru](https://github.com/manveru) -* Santiago Pastorino [spastorino](https://github.com/spastorino) -* Konstantin Haase [rkh](https://github.com/rkh) - -would like to thank: - -* Adrian Madrid, for the LiteSpeed handler. -* Christoffer Sawicki, for the first Rails adapter and `Rack::Deflater`. -* Tim Fletcher, for the HTTP authentication code. -* Luc Heinrich for the Cookie sessions, the static file handler and bugfixes. -* Armin Ronacher, for the logo and racktools. -* Alex Beregszaszi, Alexander Kahn, Anil Wadghule, Aredridel, Ben Alpert, Dan - Kubb, Daniel Roethlisberger, Matt Todd, Tom Robinson, Phil Hagelberg, S. Brent - Faulkner, Bosko Milekic, Daniel Rodríguez Troitiño, Genki Takiuchi, Geoffrey - Grosenbach, Julien Sanchez, Kamal Fariz Mahyuddin, Masayoshi Takahashi, - Patrick Aljordm, Mig, Kazuhiro Nishiyama, Jon Bardin, Konstantin Haase, Larry - Siden, Matias Korhonen, Sam Ruby, Simon Chiang, Tim Connor, Timur Batyrshin, - and Zach Brock for bug fixing and other improvements. -* Eric Wong, Hongli Lai, Jeremy Kemper for their continuous support and API - improvements. -* Yehuda Katz and Carl Lerche for refactoring rackup. -* Brian Candler, for `Rack::ContentType`. -* Graham Batty, for improved handler loading. -* Stephen Bannasch, for bug reports and documentation. -* Gary Wright, for proposing a better `Rack::Response` interface. -* Jonathan Buch, for improvements regarding `Rack::Response`. -* Armin Röhrl, for tracking down bugs in the Cookie generator. -* Alexander Kellett for testing the Gem and reviewing the announcement. -* Marcus Rückert, for help with configuring and debugging lighttpd. -* The WSGI team for the well-done and documented work they've done and Rack - builds up on. -* All bug reporters and patch contributors not mentioned above. - -## License - -Rack is released under the [MIT License](MIT-LICENSE). - -[Rack Specification]: SPEC.rdoc -[Security Policy]: SECURITY.md diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/SPEC.rdoc b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/SPEC.rdoc deleted file mode 100644 index ed5d982..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/SPEC.rdoc +++ /dev/null @@ -1,365 +0,0 @@ -This specification aims to formalize the Rack protocol. You -can (and should) use Rack::Lint to enforce it. - -When you develop middleware, be sure to add a Lint before and -after to catch all mistakes. - -= Rack applications - -A Rack application is a Ruby object (not a class) that -responds to +call+. -It takes exactly one argument, the *environment* -and returns a non-frozen Array of exactly three values: -The *status*, -the *headers*, -and the *body*. - -== The Environment - -The environment must be an unfrozen instance of Hash that includes -CGI-like headers. The Rack application is free to modify the -environment. - -The environment is required to include these variables -(adopted from {PEP 333}[https://peps.python.org/pep-0333/]), except when they'd be empty, but see -below. -REQUEST_METHOD:: The HTTP request method, such as - "GET" or "POST". This cannot ever - be an empty string, and so is - always required. -SCRIPT_NAME:: The initial portion of the request - URL's "path" that corresponds to the - application object, so that the - application knows its virtual - "location". This may be an empty - string, if the application corresponds - to the "root" of the server. -PATH_INFO:: The remainder of the request URL's - "path", designating the virtual - "location" of the request's target - within the application. This may be an - empty string, if the request URL targets - the application root and does not have a - trailing slash. This value may be - percent-encoded when originating from - a URL. -QUERY_STRING:: The portion of the request URL that - follows the ?, if any. May be - empty, but is always required! -SERVER_NAME:: When combined with SCRIPT_NAME and - PATH_INFO, these variables can be - used to complete the URL. Note, however, - that HTTP_HOST, if present, - should be used in preference to - SERVER_NAME for reconstructing - the request URL. - SERVER_NAME can never be an empty - string, and so is always required. -SERVER_PORT:: An optional +Integer+ which is the port the - server is running on. Should be specified if - the server is running on a non-standard port. -SERVER_PROTOCOL:: A string representing the HTTP version used - for the request. -HTTP_ Variables:: Variables corresponding to the - client-supplied HTTP request - headers (i.e., variables whose - names begin with HTTP_). The - presence or absence of these - variables should correspond with - the presence or absence of the - appropriate HTTP header in the - request. See - {RFC3875 section 4.1.18}[https://tools.ietf.org/html/rfc3875#section-4.1.18] - for specific behavior. -In addition to this, the Rack environment must include these -Rack-specific variables: -rack.url_scheme:: +http+ or +https+, depending on the - request URL. -rack.input:: See below, the input stream. -rack.errors:: See below, the error stream. -rack.hijack?:: See below, if present and true, indicates - that the server supports partial hijacking. -rack.hijack:: See below, if present, an object responding - to +call+ that is used to perform a full - hijack. -rack.protocol:: An optional +Array+ of +String+, containing - the protocols advertised by the client in - the +upgrade+ header (HTTP/1) or the - +:protocol+ pseudo-header (HTTP/2). -Additional environment specifications have approved to -standardized middleware APIs. None of these are required to -be implemented by the server. -rack.session:: A hash-like interface for storing - request session data. - The store must implement: - store(key, value) (aliased as []=); - fetch(key, default = nil) (aliased as []); - delete(key); - clear; - to_hash (returning unfrozen Hash instance); -rack.logger:: A common object interface for logging messages. - The object must implement: - info(message, &block) - debug(message, &block) - warn(message, &block) - error(message, &block) - fatal(message, &block) -rack.multipart.buffer_size:: An Integer hint to the multipart parser as to what chunk size to use for reads and writes. -rack.multipart.tempfile_factory:: An object responding to #call with two arguments, the filename and content_type given for the multipart form field, and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate the tempfile for each multipart form file upload field, rather than the default class of Tempfile. -The server or the application can store their own data in the -environment, too. The keys must contain at least one dot, -and should be prefixed uniquely. The prefix rack. -is reserved for use with the Rack core distribution and other -accepted specifications and must not be used otherwise. - -The SERVER_PORT must be an Integer if set. -The SERVER_NAME must be a valid authority as defined by RFC7540. -The HTTP_HOST must be a valid authority as defined by RFC7540. -The SERVER_PROTOCOL must match the regexp HTTP/\d(\.\d)?. -The environment must not contain the keys -HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH -(use the versions without HTTP_). -The CGI keys (named without a period) must have String values. -If the string values for CGI keys contain non-ASCII characters, -they should use ASCII-8BIT encoding. -There are the following restrictions: -* rack.url_scheme must either be +http+ or +https+. -* There may be a valid input stream in rack.input. -* There must be a valid error stream in rack.errors. -* There may be a valid hijack callback in rack.hijack -* There may be a valid early hints callback in rack.early_hints -* The REQUEST_METHOD must be a valid token. -* The SCRIPT_NAME, if non-empty, must start with / -* The PATH_INFO, if provided, must be a valid request target or an empty string. - * Only OPTIONS requests may have PATH_INFO set to * (asterisk-form). - * Only CONNECT requests may have PATH_INFO set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. - * CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form). - * Otherwise, PATH_INFO must start with a / and must not include a fragment part starting with '#' (origin-form). -* The CONTENT_LENGTH, if given, must consist of digits only. -* One of SCRIPT_NAME or PATH_INFO must be - set. PATH_INFO should be / if - SCRIPT_NAME is empty. - SCRIPT_NAME never should be /, but instead be empty. -rack.response_finished:: An array of callables run by the server after the response has been -processed. This would typically be invoked after sending the response to the client, but it could also be -invoked if an error occurs while generating the response or sending the response; in that case, the error -argument will be a subclass of +Exception+. -The callables are invoked with +env, status, headers, error+ arguments and should not raise any -exceptions. They should be invoked in reverse order of registration. - -=== The Input Stream - -The input stream is an IO-like object which contains the raw HTTP -POST data. -When applicable, its external encoding must be "ASCII-8BIT" and it -must be opened in binary mode. -The input stream must respond to +gets+, +each+, and +read+. -* +gets+ must be called without arguments and return a string, - or +nil+ on EOF. -* +read+ behaves like IO#read. - Its signature is read([length, [buffer]]). - - If given, +length+ must be a non-negative Integer (>= 0) or +nil+, - and +buffer+ must be a String and may not be nil. - - If +length+ is given and not nil, then this method reads at most - +length+ bytes from the input stream. - - If +length+ is not given or nil, then this method reads - all data until EOF. - - When EOF is reached, this method returns nil if +length+ is given - and not nil, or "" if +length+ is not given or is nil. - - If +buffer+ is given, then the read data will be placed - into +buffer+ instead of a newly created String object. -* +each+ must be called without arguments and only yield Strings. -* +close+ can be called on the input stream to indicate that - any remaining input is not needed. - -=== The Error Stream - -The error stream must respond to +puts+, +write+ and +flush+. -* +puts+ must be called with a single argument that responds to +to_s+. -* +write+ must be called with a single argument that is a String. -* +flush+ must be called without arguments and must be called - in order to make the error appear for sure. -* +close+ must never be called on the error stream. - -=== Hijacking - -The hijacking interfaces provides a means for an application to take -control of the HTTP connection. There are two distinct hijack -interfaces: full hijacking where the application takes over the raw -connection, and partial hijacking where the application takes over -just the response body stream. In both cases, the application is -responsible for closing the hijacked stream. - -Full hijacking only works with HTTP/1. Partial hijacking is functionally -equivalent to streaming bodies, and is still optionally supported for -backwards compatibility with older Rack versions. - -==== Full Hijack - -Full hijack is used to completely take over an HTTP/1 connection. It -occurs before any headers are written and causes the request to -ignores any response generated by the application. - -It is intended to be used when applications need access to raw HTTP/1 -connection. - -If +rack.hijack+ is present in +env+, it must respond to +call+ -and return an +IO+ instance which can be used to read and write -to the underlying connection using HTTP/1 semantics and -formatting. - -==== Partial Hijack - -Partial hijack is used for bi-directional streaming of the request and -response body. It occurs after the status and headers are written by -the server and causes the server to ignore the Body of the response. - -It is intended to be used when applications need bi-directional -streaming. - -If +rack.hijack?+ is present in +env+ and truthy, -an application may set the special response header +rack.hijack+ -to an object that responds to +call+, -accepting a +stream+ argument. - -After the response status and headers have been sent, this hijack -callback will be invoked with a +stream+ argument which follows the -same interface as outlined in "Streaming Body". Servers must -ignore the +body+ part of the response tuple when the -+rack.hijack+ response header is present. Using an empty +Array+ -instance is recommended. - -The special response header +rack.hijack+ must only be set -if the request +env+ has a truthy +rack.hijack?+. - -=== Early Hints - -The application or any middleware may call the rack.early_hints -with an object which would be valid as the headers of a Rack response. - -If rack.early_hints is present, it must respond to #call. -If rack.early_hints is called, it must be called with -valid Rack response headers. - -== The Response - -=== The Status - -This is an HTTP status. It must be an Integer greater than or equal to -100. - -=== The Headers - -The headers must be a unfrozen Hash. -The header keys must be Strings. -Special headers starting "rack." are for communicating with the -server, and must not be sent back to the client. -The header must not contain a +Status+ key. -Header keys must conform to RFC7230 token specification, i.e. cannot -contain non-printable ASCII, DQUOTE or "(),/:;<=>?@[\]{}". -Header keys must not contain uppercase ASCII characters (A-Z). -Header values must be either a String instance, -or an Array of String instances, -such that each String instance must not contain characters below 037. - -==== The +content-type+ Header - -There must not be a content-type header key when the +Status+ is 1xx, -204, or 304. - -==== The +content-length+ Header - -There must not be a content-length header key when the -+Status+ is 1xx, 204, or 304. - -==== The +rack.protocol+ Header - -If the +rack.protocol+ header is present, it must be a +String+, and -must be one of the values from the +rack.protocol+ array from the -environment. - -Setting this value informs the server that it should perform a -connection upgrade. In HTTP/1, this is done using the +upgrade+ -header. In HTTP/2, this is done by accepting the request. - -=== The Body - -The Body is typically an +Array+ of +String+ instances, an enumerable -that yields +String+ instances, a +Proc+ instance, or a File-like -object. - -The Body must respond to +each+ or +call+. It may optionally respond -to +to_path+ or +to_ary+. A Body that responds to +each+ is considered -to be an Enumerable Body. A Body that responds to +call+ is considered -to be a Streaming Body. - -A Body that responds to both +each+ and +call+ must be treated as an -Enumerable Body, not a Streaming Body. If it responds to +each+, you -must call +each+ and not +call+. If the Body doesn't respond to -+each+, then you can assume it responds to +call+. - -The Body must either be consumed or returned. The Body is consumed by -optionally calling either +each+ or +call+. -Then, if the Body responds to +close+, it must be called to release -any resources associated with the generation of the body. -In other words, +close+ must always be called at least once; typically -after the web server has sent the response to the client, but also in -cases where the Rack application makes internal/virtual requests and -discards the response. - - -After calling +close+, the Body is considered closed and should not -be consumed again. -If the original Body is replaced by a new Body, the new Body must -also consume the original Body by calling +close+ if possible. - -If the Body responds to +to_path+, it must return a +String+ -path for the local file system whose contents are identical -to that produced by calling +each+; this may be used by the -server as an alternative, possibly more efficient way to -transport the response. The +to_path+ method does not consume -the body. - -==== Enumerable Body - -The Enumerable Body must respond to +each+. -It must only be called once. -It must not be called after being closed, -and must only yield String values. - -Middleware must not call +each+ directly on the Body. -Instead, middleware can return a new Body that calls +each+ on the -original Body, yielding at least once per iteration. - -If the Body responds to +to_ary+, it must return an +Array+ whose -contents are identical to that produced by calling +each+. -Middleware may call +to_ary+ directly on the Body and return a new -Body in its place. In other words, middleware can only process the -Body directly if it responds to +to_ary+. If the Body responds to both -+to_ary+ and +close+, its implementation of +to_ary+ must call -+close+. - -==== Streaming Body - -The Streaming Body must respond to +call+. -It must only be called once. -It must not be called after being closed. -It takes a +stream+ argument. - -The +stream+ argument must implement: -read, write, <<, flush, close, close_read, close_write, closed? - -The semantics of these IO methods must be a best effort match to -those of a normal Ruby IO or Socket object, using standard arguments -and raising standard exceptions. Servers are encouraged to simply -pass on real IO objects, although it is recognized that this approach -is not directly compatible with HTTP/2. - -== Thanks -Some parts of this specification are adopted from {PEP 333 – Python Web Server Gateway Interface v1.0}[https://peps.python.org/pep-0333/] -I'd like to thank everyone involved in that effort. diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack.rb deleted file mode 100644 index 6021248..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack.rb +++ /dev/null @@ -1,66 +0,0 @@ -# socket-patch: patched rack-3.1.8 (spike marker) -# frozen_string_literal: true - -# Copyright (C) 2007-2019 Leah Neukirchen -# -# Rack is freely distributable under the terms of an MIT-style license. -# See MIT-LICENSE or https://opensource.org/licenses/MIT. - -# The Rack main module, serving as a namespace for all core Rack -# modules and classes. -# -# All modules meant for use in your application are autoloaded here, -# so it should be enough just to require 'rack' in your code. - -require_relative 'rack/version' -require_relative 'rack/constants' - -module Rack - autoload :BadRequest, "rack/bad_request" - autoload :BodyProxy, "rack/body_proxy" - autoload :Builder, "rack/builder" - autoload :Cascade, "rack/cascade" - autoload :CommonLogger, "rack/common_logger" - autoload :ConditionalGet, "rack/conditional_get" - autoload :Config, "rack/config" - autoload :ContentLength, "rack/content_length" - autoload :ContentType, "rack/content_type" - autoload :Deflater, "rack/deflater" - autoload :Directory, "rack/directory" - autoload :ETag, "rack/etag" - autoload :Events, "rack/events" - autoload :Files, "rack/files" - autoload :ForwardRequest, "rack/recursive" - autoload :Head, "rack/head" - autoload :Headers, "rack/headers" - autoload :Lint, "rack/lint" - autoload :Lock, "rack/lock" - autoload :Logger, "rack/logger" - autoload :MediaType, "rack/media_type" - autoload :MethodOverride, "rack/method_override" - autoload :Mime, "rack/mime" - autoload :MockRequest, "rack/mock_request" - autoload :MockResponse, "rack/mock_response" - autoload :Multipart, "rack/multipart" - autoload :NullLogger, "rack/null_logger" - autoload :QueryParser, "rack/query_parser" - autoload :Recursive, "rack/recursive" - autoload :Reloader, "rack/reloader" - autoload :Request, "rack/request" - autoload :Response, "rack/response" - autoload :RewindableInput, "rack/rewindable_input" - autoload :Runtime, "rack/runtime" - autoload :Sendfile, "rack/sendfile" - autoload :ShowExceptions, "rack/show_exceptions" - autoload :ShowStatus, "rack/show_status" - autoload :Static, "rack/static" - autoload :TempfileReaper, "rack/tempfile_reaper" - autoload :URLMap, "rack/urlmap" - autoload :Utils, "rack/utils" - - module Auth - autoload :Basic, "rack/auth/basic" - autoload :AbstractHandler, "rack/auth/abstract/handler" - autoload :AbstractRequest, "rack/auth/abstract/request" - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb deleted file mode 100644 index 4731ee8..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../constants' - -module Rack - module Auth - # Rack::Auth::AbstractHandler implements common authentication functionality. - # - # +realm+ should be set for all handlers. - - class AbstractHandler - - attr_accessor :realm - - def initialize(app, realm = nil, &authenticator) - @app, @realm, @authenticator = app, realm, authenticator - end - - - private - - def unauthorized(www_authenticate = challenge) - return [ 401, - { CONTENT_TYPE => 'text/plain', - CONTENT_LENGTH => '0', - 'www-authenticate' => www_authenticate.to_s }, - [] - ] - end - - def bad_request - return [ 400, - { CONTENT_TYPE => 'text/plain', - CONTENT_LENGTH => '0' }, - [] - ] - end - - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb deleted file mode 100644 index f872331..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../request' - -module Rack - module Auth - class AbstractRequest - - def initialize(env) - @env = env - end - - def request - @request ||= Request.new(@env) - end - - def provided? - !authorization_key.nil? && valid? - end - - def valid? - !@env[authorization_key].nil? - end - - def parts - @parts ||= @env[authorization_key].split(' ', 2) - end - - def scheme - @scheme ||= parts.first&.downcase - end - - def params - @params ||= parts.last - end - - - private - - AUTHORIZATION_KEYS = ['HTTP_AUTHORIZATION', 'X-HTTP_AUTHORIZATION', 'X_HTTP_AUTHORIZATION'] - - def authorization_key - @authorization_key ||= AUTHORIZATION_KEYS.detect { |key| @env.has_key?(key) } - end - - end - - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb deleted file mode 100644 index 67ffc49..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/auth/basic.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require_relative 'abstract/handler' -require_relative 'abstract/request' - -module Rack - module Auth - # Rack::Auth::Basic implements HTTP Basic Authentication, as per RFC 2617. - # - # Initialize with the Rack application that you want protecting, - # and a block that checks if a username and password pair are valid. - - class Basic < AbstractHandler - - def call(env) - auth = Basic::Request.new(env) - - return unauthorized unless auth.provided? - - return bad_request unless auth.basic? - - if valid?(auth) - env['REMOTE_USER'] = auth.username - - return @app.call(env) - end - - unauthorized - end - - - private - - def challenge - 'Basic realm="%s"' % realm - end - - def valid?(auth) - @authenticator.call(*auth.credentials) - end - - class Request < Auth::AbstractRequest - def basic? - "basic" == scheme && credentials.length == 2 - end - - def credentials - @credentials ||= params.unpack1('m').split(':', 2) - end - - def username - credentials.first - end - end - - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/bad_request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/bad_request.rb deleted file mode 100644 index 8eaa94e..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/bad_request.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Represents a 400 Bad Request error when input data fails to meet the - # requirements. - module BadRequest - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb deleted file mode 100644 index 7291579..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/body_proxy.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Proxy for response bodies allowing calling a block when - # the response body is closed (after the response has been fully - # sent to the client). - class BodyProxy - # Set the response body to wrap, and the block to call when the - # response has been fully sent. - def initialize(body, &block) - @body = body - @block = block - @closed = false - end - - # Return whether the wrapped body responds to the method. - def respond_to_missing?(method_name, include_all = false) - case method_name - when :to_str - false - else - super or @body.respond_to?(method_name, include_all) - end - end - - # If not already closed, close the wrapped body and - # then call the block the proxy was initialized with. - def close - return if @closed - @closed = true - begin - @body.close if @body.respond_to?(:close) - ensure - @block.call - end - end - - # Whether the proxy is closed. The proxy starts as not closed, - # and becomes closed on the first call to close. - def closed? - @closed - end - - # Delegate missing methods to the wrapped body. - def method_missing(method_name, *args, &block) - case method_name - when :to_str - super - when :to_ary - begin - @body.__send__(method_name, *args, &block) - ensure - close - end - else - @body.__send__(method_name, *args, &block) - end - end - # :nocov: - ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) - # :nocov: - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/builder.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/builder.rb deleted file mode 100644 index 9faeffb..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/builder.rb +++ /dev/null @@ -1,290 +0,0 @@ -# frozen_string_literal: true - -require_relative 'urlmap' - -module Rack; end -Rack::BUILDER_TOPLEVEL_BINDING = ->(builder){builder.instance_eval{binding}} - -module Rack - # Rack::Builder provides a domain-specific language (DSL) to construct Rack - # applications. It is primarily used to parse +config.ru+ files which - # instantiate several middleware and a final application which are hosted - # by a Rack-compatible web server. - # - # Example: - # - # app = Rack::Builder.new do - # use Rack::CommonLogger - # map "/ok" do - # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } - # end - # end - # - # run app - # - # Or - # - # app = Rack::Builder.app do - # use Rack::CommonLogger - # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } - # end - # - # run app - # - # +use+ adds middleware to the stack, +run+ dispatches to an application. - # You can use +map+ to construct a Rack::URLMap in a convenient way. - class Builder - - # https://stackoverflow.com/questions/2223882/whats-the-difference-between-utf-8-and-utf-8-without-bom - UTF_8_BOM = '\xef\xbb\xbf' - - # Parse the given config file to get a Rack application. - # - # If the config file ends in +.ru+, it is treated as a - # rackup file and the contents will be treated as if - # specified inside a Rack::Builder block. - # - # If the config file does not end in +.ru+, it is - # required and Rack will use the basename of the file - # to guess which constant will be the Rack application to run. - # - # Examples: - # - # Rack::Builder.parse_file('config.ru') - # # Rack application built using Rack::Builder.new - # - # Rack::Builder.parse_file('app.rb') - # # requires app.rb, which can be anywhere in Ruby's - # # load path. After requiring, assumes App constant - # # is a Rack application - # - # Rack::Builder.parse_file('./my_app.rb') - # # requires ./my_app.rb, which should be in the - # # process's current directory. After requiring, - # # assumes MyApp constant is a Rack application - def self.parse_file(path, **options) - if path.end_with?('.ru') - return self.load_file(path, **options) - else - require path - return Object.const_get(::File.basename(path, '.rb').split('_').map(&:capitalize).join('')) - end - end - - # Load the given file as a rackup file, treating the - # contents as if specified inside a Rack::Builder block. - # - # Ignores content in the file after +__END__+, so that - # use of +__END__+ will not result in a syntax error. - # - # Example config.ru file: - # - # $ cat config.ru - # - # use Rack::ContentLength - # require './app.rb' - # run App - def self.load_file(path, **options) - config = ::File.read(path) - config.slice!(/\A#{UTF_8_BOM}/) if config.encoding == Encoding::UTF_8 - - if config[/^#\\(.*)/] - fail "Parsing options from the first comment line is no longer supported: #{path}" - end - - config.sub!(/^__END__\n.*\Z/m, '') - - return new_from_string(config, path, **options) - end - - # Evaluate the given +builder_script+ string in the context of - # a Rack::Builder block, returning a Rack application. - def self.new_from_string(builder_script, path = "(rackup)", **options) - builder = self.new(**options) - - # We want to build a variant of TOPLEVEL_BINDING with self as a Rack::Builder instance. - # We cannot use instance_eval(String) as that would resolve constants differently. - binding = BUILDER_TOPLEVEL_BINDING.call(builder) - eval(builder_script, binding, path) - - return builder.to_app - end - - # Initialize a new Rack::Builder instance. +default_app+ specifies the - # default application if +run+ is not called later. If a block - # is given, it is evaluated in the context of the instance. - def initialize(default_app = nil, **options, &block) - @use = [] - @map = nil - @run = default_app - @warmup = nil - @freeze_app = false - @options = options - - instance_eval(&block) if block_given? - end - - # Any options provided to the Rack::Builder instance at initialization. - # These options can be server-specific. Some general options are: - # - # * +:isolation+: One of +process+, +thread+ or +fiber+. The execution - # isolation model to use. - attr :options - - # Create a new Rack::Builder instance and return the Rack application - # generated from it. - def self.app(default_app = nil, &block) - self.new(default_app, &block).to_app - end - - # Specifies middleware to use in a stack. - # - # class Middleware - # def initialize(app) - # @app = app - # end - # - # def call(env) - # env["rack.some_header"] = "setting an example" - # @app.call(env) - # end - # end - # - # use Middleware - # run lambda { |env| [200, { "content-type" => "text/plain" }, ["OK"]] } - # - # All requests through to this application will first be processed by the middleware class. - # The +call+ method in this example sets an additional environment key which then can be - # referenced in the application if required. - def use(middleware, *args, &block) - if @map - mapping, @map = @map, nil - @use << proc { |app| generate_map(app, mapping) } - end - @use << proc { |app| middleware.new(app, *args, &block) } - end - # :nocov: - ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true) - # :nocov: - - # Takes a block or argument that is an object that responds to #call and - # returns a Rack response. - # - # You can use a block: - # - # run do |env| - # [200, { "content-type" => "text/plain" }, ["Hello World!"]] - # end - # - # You can also provide a lambda: - # - # run lambda { |env| [200, { "content-type" => "text/plain" }, ["OK"]] } - # - # You can also provide a class instance: - # - # class Heartbeat - # def call(env) - # [200, { "content-type" => "text/plain" }, ["OK"]] - # end - # end - # - # run Heartbeat.new - # - def run(app = nil, &block) - raise ArgumentError, "Both app and block given!" if app && block_given? - - @run = app || block - end - - # Takes a lambda or block that is used to warm-up the application. This block is called - # before the Rack application is returned by to_app. - # - # warmup do |app| - # client = Rack::MockRequest.new(app) - # client.get('/') - # end - # - # use SomeMiddleware - # run MyApp - def warmup(prc = nil, &block) - @warmup = prc || block - end - - # Creates a route within the application. Routes under the mapped path will be sent to - # the Rack application specified by run inside the block. Other requests will be sent to the - # default application specified by run outside the block. - # - # class App - # def call(env) - # [200, {'content-type' => 'text/plain'}, ["Hello World"]] - # end - # end - # - # class Heartbeat - # def call(env) - # [200, { "content-type" => "text/plain" }, ["OK"]] - # end - # end - # - # app = Rack::Builder.app do - # map '/heartbeat' do - # run Heartbeat.new - # end - # run App.new - # end - # - # run app - # - # The +use+ method can also be used inside the block to specify middleware to run under a specific path: - # - # app = Rack::Builder.app do - # map '/heartbeat' do - # use Middleware - # run Heartbeat.new - # end - # run App.new - # end - # - # This example includes a piece of middleware which will run before +/heartbeat+ requests hit +Heartbeat+. - # - # Note that providing a +path+ of +/+ will ignore any default application given in a +run+ statement - # outside the block. - def map(path, &block) - @map ||= {} - @map[path] = block - end - - # Freeze the app (set using run) and all middleware instances when building the application - # in to_app. - def freeze_app - @freeze_app = true - end - - # Return the Rack application generated by this instance. - def to_app - app = @map ? generate_map(@run, @map) : @run - fail "missing run or map statement" unless app - app.freeze if @freeze_app - app = @use.reverse.inject(app) { |a, e| e[a].tap { |x| x.freeze if @freeze_app } } - @warmup.call(app) if @warmup - app - end - - # Call the Rack application generated by this builder instance. Note that - # this rebuilds the Rack application and runs the warmup code (if any) - # every time it is called, so it should not be used if performance is important. - def call(env) - to_app.call(env) - end - - private - - # Generate a URLMap instance by generating new Rack applications for each - # map block in this instance. - def generate_map(default_app, mapping) - mapped = default_app ? { '/' => default_app } : {} - mapping.each { |r, b| mapped[r] = self.class.new(default_app, &b).to_app } - URLMap.new(mapped) - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/cascade.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/cascade.rb deleted file mode 100644 index 9c952fd..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/cascade.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' - -module Rack - # Rack::Cascade tries a request on several apps, and returns the - # first response that is not 404 or 405 (or in a list of configured - # status codes). If all applications tried return one of the configured - # status codes, return the last response. - - class Cascade - # An array of applications to try in order. - attr_reader :apps - - # Set the apps to send requests to, and what statuses result in - # cascading. Arguments: - # - # apps: An enumerable of rack applications. - # cascade_for: The statuses to use cascading for. If a response is received - # from an app, the next app is tried. - def initialize(apps, cascade_for = [404, 405]) - @apps = [] - apps.each { |app| add app } - - @cascade_for = {} - [*cascade_for].each { |status| @cascade_for[status] = true } - end - - # Call each app in order. If the responses uses a status that requires - # cascading, try the next app. If all responses require cascading, - # return the response from the last app. - def call(env) - return [404, { CONTENT_TYPE => "text/plain" }, []] if @apps.empty? - result = nil - last_body = nil - - @apps.each do |app| - # The SPEC says that the body must be closed after it has been iterated - # by the server, or if it is replaced by a middleware action. Cascade - # replaces the body each time a cascade happens. It is assumed that nil - # does not respond to close, otherwise the previous application body - # will be closed. The final application body will not be closed, as it - # will be passed to the server as a result. - last_body.close if last_body.respond_to? :close - - result = app.call(env) - return result unless @cascade_for.include?(result[0].to_i) - last_body = result[2] - end - - result - end - - # Append an app to the list of apps to cascade. This app will - # be tried last. - def add(app) - @apps << app - end - - # Whether the given app is one of the apps to cascade to. - def include?(app) - @apps.include?(app) - end - - alias_method :<<, :add - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/common_logger.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/common_logger.rb deleted file mode 100644 index 2feb067..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/common_logger.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' -require_relative 'body_proxy' -require_relative 'request' - -module Rack - # Rack::CommonLogger forwards every request to the given +app+, and - # logs a line in the - # {Apache common log format}[http://httpd.apache.org/docs/1.3/logs.html#common] - # to the configured logger. - class CommonLogger - # Common Log Format: http://httpd.apache.org/docs/1.3/logs.html#common - # - # lilith.local - - [07/Aug/2006 23:58:02 -0400] "GET / HTTP/1.1" 500 - - # - # %{%s - %s [%s] "%s %s%s %s" %d %s\n} % - # - # The actual format is slightly different than the above due to the - # separation of SCRIPT_NAME and PATH_INFO, and because the elapsed - # time in seconds is included at the end. - FORMAT = %{%s - %s [%s] "%s %s%s%s %s" %d %s %0.4f\n} - - # +logger+ can be any object that supports the +write+ or +<<+ methods, - # which includes the standard library Logger. These methods are called - # with a single string argument, the log message. - # If +logger+ is nil, CommonLogger will fall back env['rack.errors']. - def initialize(app, logger = nil) - @app = app - @logger = logger - end - - # Log all requests in common_log format after a response has been - # returned. Note that if the app raises an exception, the request - # will not be logged, so if exception handling middleware are used, - # they should be loaded after this middleware. Additionally, because - # the logging happens after the request body has been fully sent, any - # exceptions raised during the sending of the response body will - # cause the request not to be logged. - def call(env) - began_at = Utils.clock_time - status, headers, body = response = @app.call(env) - - response[2] = BodyProxy.new(body) { log(env, status, headers, began_at) } - response - end - - private - - # Log the request to the configured logger. - def log(env, status, response_headers, began_at) - request = Rack::Request.new(env) - length = extract_content_length(response_headers) - - msg = sprintf(FORMAT, - request.ip || "-", - request.get_header("REMOTE_USER") || "-", - Time.now.strftime("%d/%b/%Y:%H:%M:%S %z"), - request.request_method, - request.script_name, - request.path_info, - request.query_string.empty? ? "" : "?#{request.query_string}", - request.get_header(SERVER_PROTOCOL), - status.to_s[0..3], - length, - Utils.clock_time - began_at) - - msg.gsub!(/[^[:print:]\n]/) { |c| sprintf("\\x%x", c.ord) } - - logger = @logger || request.get_header(RACK_ERRORS) - # Standard library logger doesn't support write but it supports << which actually - # calls to write on the log device without formatting - if logger.respond_to?(:write) - logger.write(msg) - else - logger << msg - end - end - - # Attempt to determine the content length for the response to - # include it in the logged data. - def extract_content_length(headers) - value = headers[CONTENT_LENGTH] - !value || value.to_s == '0' ? '-' : value - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb deleted file mode 100644 index c3b334a..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/conditional_get.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' -require_relative 'body_proxy' - -module Rack - - # Middleware that enables conditional GET using if-none-match and - # if-modified-since. The application should set either or both of the - # last-modified or etag response headers according to RFC 2616. When - # either of the conditions is met, the response body is set to be zero - # length and the response status is set to 304 Not Modified. - # - # Applications that defer response body generation until the body's each - # message is received will avoid response body generation completely when - # a conditional GET matches. - # - # Adapted from Michael Klishin's Merb implementation: - # https://github.com/wycats/merb/blob/master/merb-core/lib/merb-core/rack/middleware/conditional_get.rb - class ConditionalGet - def initialize(app) - @app = app - end - - # Return empty 304 response if the response has not been - # modified since the last request. - def call(env) - case env[REQUEST_METHOD] - when "GET", "HEAD" - status, headers, body = response = @app.call(env) - - if status == 200 && fresh?(env, headers) - response[0] = 304 - headers.delete(CONTENT_TYPE) - headers.delete(CONTENT_LENGTH) - response[2] = Rack::BodyProxy.new([]) do - body.close if body.respond_to?(:close) - end - end - response - else - @app.call(env) - end - end - - private - - # Return whether the response has not been modified since the - # last request. - def fresh?(env, headers) - # if-none-match has priority over if-modified-since per RFC 7232 - if none_match = env['HTTP_IF_NONE_MATCH'] - etag_matches?(none_match, headers) - elsif (modified_since = env['HTTP_IF_MODIFIED_SINCE']) && (modified_since = to_rfc2822(modified_since)) - modified_since?(modified_since, headers) - end - end - - # Whether the etag response header matches the if-none-match request header. - # If so, the request has not been modified. - def etag_matches?(none_match, headers) - headers[ETAG] == none_match - end - - # Whether the last-modified response header matches the if-modified-since - # request header. If so, the request has not been modified. - def modified_since?(modified_since, headers) - last_modified = to_rfc2822(headers['last-modified']) and - modified_since >= last_modified - end - - # Return a Time object for the given string (which should be in RFC2822 - # format), or nil if the string cannot be parsed. - def to_rfc2822(since) - # shortest possible valid date is the obsolete: 1 Nov 97 09:55 A - # anything shorter is invalid, this avoids exceptions for common cases - # most common being the empty string - if since && since.length >= 16 - # NOTE: there is no trivial way to write this in a non exception way - # _rfc2822 returns a hash but is not that usable - Time.rfc2822(since) rescue nil - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/config.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/config.rb deleted file mode 100644 index 41f6f7d..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/config.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Rack::Config modifies the environment using the block given during - # initialization. - # - # Example: - # use Rack::Config do |env| - # env['my-key'] = 'some-value' - # end - class Config - def initialize(app, &block) - @app = app - @block = block - end - - def call(env) - @block.call(env) - @app.call(env) - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/constants.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/constants.rb deleted file mode 100644 index e9b6e10..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/constants.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Request env keys - HTTP_HOST = 'HTTP_HOST' - HTTP_PORT = 'HTTP_PORT' - HTTPS = 'HTTPS' - PATH_INFO = 'PATH_INFO' - REQUEST_METHOD = 'REQUEST_METHOD' - REQUEST_PATH = 'REQUEST_PATH' - SCRIPT_NAME = 'SCRIPT_NAME' - QUERY_STRING = 'QUERY_STRING' - SERVER_PROTOCOL = 'SERVER_PROTOCOL' - SERVER_NAME = 'SERVER_NAME' - SERVER_PORT = 'SERVER_PORT' - HTTP_COOKIE = 'HTTP_COOKIE' - - # Response Header Keys - CACHE_CONTROL = 'cache-control' - CONTENT_LENGTH = 'content-length' - CONTENT_TYPE = 'content-type' - ETAG = 'etag' - EXPIRES = 'expires' - SET_COOKIE = 'set-cookie' - TRANSFER_ENCODING = 'transfer-encoding' - - # HTTP method verbs - GET = 'GET' - POST = 'POST' - PUT = 'PUT' - PATCH = 'PATCH' - DELETE = 'DELETE' - HEAD = 'HEAD' - OPTIONS = 'OPTIONS' - CONNECT = 'CONNECT' - LINK = 'LINK' - UNLINK = 'UNLINK' - TRACE = 'TRACE' - - # Rack environment variables - RACK_VERSION = 'rack.version' - RACK_TEMPFILES = 'rack.tempfiles' - RACK_EARLY_HINTS = 'rack.early_hints' - RACK_ERRORS = 'rack.errors' - RACK_LOGGER = 'rack.logger' - RACK_INPUT = 'rack.input' - RACK_SESSION = 'rack.session' - RACK_SESSION_OPTIONS = 'rack.session.options' - RACK_SHOWSTATUS_DETAIL = 'rack.showstatus.detail' - RACK_URL_SCHEME = 'rack.url_scheme' - RACK_HIJACK = 'rack.hijack' - RACK_IS_HIJACK = 'rack.hijack?' - RACK_RECURSIVE_INCLUDE = 'rack.recursive.include' - RACK_MULTIPART_BUFFER_SIZE = 'rack.multipart.buffer_size' - RACK_MULTIPART_TEMPFILE_FACTORY = 'rack.multipart.tempfile_factory' - RACK_RESPONSE_FINISHED = 'rack.response_finished' - RACK_REQUEST_FORM_INPUT = 'rack.request.form_input' - RACK_REQUEST_FORM_HASH = 'rack.request.form_hash' - RACK_REQUEST_FORM_PAIRS = 'rack.request.form_pairs' - RACK_REQUEST_FORM_VARS = 'rack.request.form_vars' - RACK_REQUEST_FORM_ERROR = 'rack.request.form_error' - RACK_REQUEST_COOKIE_HASH = 'rack.request.cookie_hash' - RACK_REQUEST_COOKIE_STRING = 'rack.request.cookie_string' - RACK_REQUEST_QUERY_HASH = 'rack.request.query_hash' - RACK_REQUEST_QUERY_STRING = 'rack.request.query_string' - RACK_METHODOVERRIDE_ORIGINAL_METHOD = 'rack.methodoverride.original_method' -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_length.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_length.rb deleted file mode 100644 index cbac93a..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_length.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' - -module Rack - - # Sets the content-length header on responses that do not specify - # a content-length or transfer-encoding header. Note that this - # does not fix responses that have an invalid content-length - # header specified. - class ContentLength - include Rack::Utils - - def initialize(app) - @app = app - end - - def call(env) - status, headers, body = response = @app.call(env) - - if !STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) && - !headers[CONTENT_LENGTH] && - !headers[TRANSFER_ENCODING] && - body.respond_to?(:to_ary) - - response[2] = body = body.to_ary - headers[CONTENT_LENGTH] = body.sum(&:bytesize).to_s - end - - response - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_type.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_type.rb deleted file mode 100644 index 19f0782..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/content_type.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' - -module Rack - - # Sets the content-type header on responses which don't have one. - # - # Builder Usage: - # use Rack::ContentType, "text/plain" - # - # When no content type argument is provided, "text/html" is the - # default. - class ContentType - include Rack::Utils - - def initialize(app, content_type = "text/html") - @app = app - @content_type = content_type - end - - def call(env) - status, headers, _ = response = @app.call(env) - - unless STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) - headers[CONTENT_TYPE] ||= @content_type - end - - response - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/deflater.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/deflater.rb deleted file mode 100644 index cc01c32..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/deflater.rb +++ /dev/null @@ -1,158 +0,0 @@ -# frozen_string_literal: true - -require "zlib" -require "time" # for Time.httpdate - -require_relative 'constants' -require_relative 'utils' -require_relative 'request' -require_relative 'body_proxy' - -module Rack - # This middleware enables content encoding of http responses, - # usually for purposes of compression. - # - # Currently supported encodings: - # - # * gzip - # * identity (no transformation) - # - # This middleware automatically detects when encoding is supported - # and allowed. For example no encoding is made when a cache - # directive of 'no-transform' is present, when the response status - # code is one that doesn't allow an entity body, or when the body - # is empty. - # - # Note that despite the name, Deflater does not support the +deflate+ - # encoding. - class Deflater - # Creates Rack::Deflater middleware. Options: - # - # :if :: a lambda enabling / disabling deflation based on returned boolean value - # (e.g use Rack::Deflater, :if => lambda { |*, body| sum=0; body.each { |i| sum += i.length }; sum > 512 }). - # However, be aware that calling `body.each` inside the block will break cases where `body.each` is not idempotent, - # such as when it is an +IO+ instance. - # :include :: a list of content types that should be compressed. By default, all content types are compressed. - # :sync :: determines if the stream is going to be flushed after every chunk. Flushing after every chunk reduces - # latency for time-sensitive streaming applications, but hurts compression and throughput. - # Defaults to +true+. - def initialize(app, options = {}) - @app = app - @condition = options[:if] - @compressible_types = options[:include] - @sync = options.fetch(:sync, true) - end - - def call(env) - status, headers, body = response = @app.call(env) - - unless should_deflate?(env, status, headers, body) - return response - end - - request = Request.new(env) - - encoding = Utils.select_best_encoding(%w(gzip identity), - request.accept_encoding) - - # Set the Vary HTTP header. - vary = headers["vary"].to_s.split(",").map(&:strip) - unless vary.include?("*") || vary.any?{|v| v.downcase == 'accept-encoding'} - headers["vary"] = vary.push("Accept-Encoding").join(",") - end - - case encoding - when "gzip" - headers['content-encoding'] = "gzip" - headers.delete(CONTENT_LENGTH) - mtime = headers["last-modified"] - mtime = Time.httpdate(mtime).to_i if mtime - response[2] = GzipStream.new(body, mtime, @sync) - response - when "identity" - response - else # when nil - # Only possible encoding values here are 'gzip', 'identity', and nil - message = "An acceptable encoding for the requested resource #{request.fullpath} could not be found." - bp = Rack::BodyProxy.new([message]) { body.close if body.respond_to?(:close) } - [406, { CONTENT_TYPE => "text/plain", CONTENT_LENGTH => message.length.to_s }, bp] - end - end - - # Body class used for gzip encoded responses. - class GzipStream - - BUFFER_LENGTH = 128 * 1_024 - - # Initialize the gzip stream. Arguments: - # body :: Response body to compress with gzip - # mtime :: The modification time of the body, used to set the - # modification time in the gzip header. - # sync :: Whether to flush each gzip chunk as soon as it is ready. - def initialize(body, mtime, sync) - @body = body - @mtime = mtime - @sync = sync - end - - # Yield gzip compressed strings to the given block. - def each(&block) - @writer = block - gzip = ::Zlib::GzipWriter.new(self) - gzip.mtime = @mtime if @mtime - # @body.each is equivalent to @body.gets (slow) - if @body.is_a? ::File # XXX: Should probably be ::IO - while part = @body.read(BUFFER_LENGTH) - gzip.write(part) - gzip.flush if @sync - end - else - @body.each { |part| - # Skip empty strings, as they would result in no output, - # and flushing empty parts would raise Zlib::BufError. - next if part.empty? - gzip.write(part) - gzip.flush if @sync - } - end - ensure - gzip.finish - end - - # Call the block passed to #each with the gzipped data. - def write(data) - @writer.call(data) - end - - # Close the original body if possible. - def close - @body.close if @body.respond_to?(:close) - end - end - - private - - # Whether the body should be compressed. - def should_deflate?(env, status, headers, body) - # Skip compressing empty entity body responses and responses with - # no-transform set. - if Utils::STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) || - /\bno-transform\b/.match?(headers[CACHE_CONTROL].to_s) || - headers['content-encoding']&.!~(/\bidentity\b/) - return false - end - - # Skip if @compressible_types are given and does not include request's content type - return false if @compressible_types && !(headers.has_key?(CONTENT_TYPE) && @compressible_types.include?(headers[CONTENT_TYPE][/[^;]*/])) - - # Skip if @condition lambda is given and evaluates to false - return false if @condition && !@condition.call(env, status, headers, body) - - # No point in compressing empty body, also handles usage with - # Rack::Sendfile. - return false if headers[CONTENT_LENGTH] == '0' - - true - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/directory.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/directory.rb deleted file mode 100644 index 089623f..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/directory.rb +++ /dev/null @@ -1,205 +0,0 @@ -# frozen_string_literal: true - -require 'time' - -require_relative 'constants' -require_relative 'utils' -require_relative 'head' -require_relative 'mime' -require_relative 'files' - -module Rack - # Rack::Directory serves entries below the +root+ given, according to the - # path info of the Rack request. If a directory is found, the file's contents - # will be presented in an html based index. If a file is found, the env will - # be passed to the specified +app+. - # - # If +app+ is not specified, a Rack::Files of the same +root+ will be used. - - class Directory - DIR_FILE = "%s%s%s%s\n" - DIR_PAGE_HEADER = <<-PAGE - - %s - - - -

%s

-
- - - - - - - - PAGE - DIR_PAGE_FOOTER = <<-PAGE -
NameSizeTypeLast Modified
-
- - PAGE - - # Body class for directory entries, showing an index page with links - # to each file. - class DirectoryBody < Struct.new(:root, :path, :files) - # Yield strings for each part of the directory entry - def each - show_path = Utils.escape_html(path.sub(/^#{root}/, '')) - yield(DIR_PAGE_HEADER % [ show_path, show_path ]) - - unless path.chomp('/') == root - yield(DIR_FILE % DIR_FILE_escape(files.call('..'))) - end - - Dir.foreach(path) do |basename| - next if basename.start_with?('.') - next unless f = files.call(basename) - yield(DIR_FILE % DIR_FILE_escape(f)) - end - - yield(DIR_PAGE_FOOTER) - end - - private - - # Escape each element in the array of html strings. - def DIR_FILE_escape(htmls) - htmls.map { |e| Utils.escape_html(e) } - end - end - - # The root of the directory hierarchy. Only requests for files and - # directories inside of the root directory are supported. - attr_reader :root - - # Set the root directory and application for serving files. - def initialize(root, app = nil) - @root = ::File.expand_path(root) - @app = app || Files.new(@root) - @head = Head.new(method(:get)) - end - - def call(env) - # strip body if this is a HEAD call - @head.call env - end - - # Internals of request handling. Similar to call but does - # not remove body for HEAD requests. - def get(env) - script_name = env[SCRIPT_NAME] - path_info = Utils.unescape_path(env[PATH_INFO]) - - if client_error_response = check_bad_request(path_info) || check_forbidden(path_info) - client_error_response - else - path = ::File.join(@root, path_info) - list_path(env, path, path_info, script_name) - end - end - - # Rack response to use for requests with invalid paths, or nil if path is valid. - def check_bad_request(path_info) - return if Utils.valid_path?(path_info) - - body = "Bad Request\n" - [400, { CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => body.bytesize.to_s, - "x-cascade" => "pass" }, [body]] - end - - # Rack response to use for requests with paths outside the root, or nil if path is inside the root. - def check_forbidden(path_info) - return unless path_info.include? ".." - return if ::File.expand_path(::File.join(@root, path_info)).start_with?(@root) - - body = "Forbidden\n" - [403, { CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => body.bytesize.to_s, - "x-cascade" => "pass" }, [body]] - end - - # Rack response to use for directories under the root. - def list_directory(path_info, path, script_name) - url_head = (script_name.split('/') + path_info.split('/')).map do |part| - Utils.escape_path part - end - - # Globbing not safe as path could contain glob metacharacters - body = DirectoryBody.new(@root, path, ->(basename) do - stat = stat(::File.join(path, basename)) - next unless stat - - url = ::File.join(*url_head + [Utils.escape_path(basename)]) - mtime = stat.mtime.httpdate - if stat.directory? - type = 'directory' - size = '-' - url << '/' - if basename == '..' - basename = 'Parent Directory' - else - basename << '/' - end - else - type = Mime.mime_type(::File.extname(basename)) - size = filesize_format(stat.size) - end - - [ url, basename, size, type, mtime ] - end) - - [ 200, { CONTENT_TYPE => 'text/html; charset=utf-8' }, body ] - end - - # File::Stat for the given path, but return nil for missing/bad entries. - def stat(path) - ::File.stat(path) - rescue Errno::ENOENT, Errno::ELOOP - return nil - end - - # Rack response to use for files and directories under the root. - # Unreadable and non-file, non-directory entries will get a 404 response. - def list_path(env, path, path_info, script_name) - if (stat = stat(path)) && stat.readable? - return @app.call(env) if stat.file? - return list_directory(path_info, path, script_name) if stat.directory? - end - - entity_not_found(path_info) - end - - # Rack response to use for unreadable and non-file, non-directory entries. - def entity_not_found(path_info) - body = "Entity not found: #{path_info}\n" - [404, { CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => body.bytesize.to_s, - "x-cascade" => "pass" }, [body]] - end - - # Stolen from Ramaze - FILESIZE_FORMAT = [ - ['%.1fT', 1 << 40], - ['%.1fG', 1 << 30], - ['%.1fM', 1 << 20], - ['%.1fK', 1 << 10], - ] - - # Provide human readable file sizes - def filesize_format(int) - FILESIZE_FORMAT.each do |format, size| - return format % (int.to_f / size) if int >= size - end - - "#{int}B" - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/etag.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/etag.rb deleted file mode 100644 index fa78b47..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/etag.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -require 'digest/sha2' - -require_relative 'constants' -require_relative 'utils' - -module Rack - # Automatically sets the etag header on all String bodies. - # - # The etag header is skipped if etag or last-modified headers are sent or if - # a sendfile body (body.responds_to :to_path) is given (since such cases - # should be handled by apache/nginx). - # - # On initialization, you can pass two parameters: a cache-control directive - # used when etag is absent and a directive when it is present. The first - # defaults to nil, while the second defaults to "max-age=0, private, must-revalidate" - class ETag - ETAG_STRING = Rack::ETAG - DEFAULT_CACHE_CONTROL = "max-age=0, private, must-revalidate" - - def initialize(app, no_cache_control = nil, cache_control = DEFAULT_CACHE_CONTROL) - @app = app - @cache_control = cache_control - @no_cache_control = no_cache_control - end - - def call(env) - status, headers, body = response = @app.call(env) - - if etag_status?(status) && body.respond_to?(:to_ary) && !skip_caching?(headers) - body = body.to_ary - digest = digest_body(body) - headers[ETAG_STRING] = %(W/"#{digest}") if digest - end - - unless headers[CACHE_CONTROL] - if digest - headers[CACHE_CONTROL] = @cache_control if @cache_control - else - headers[CACHE_CONTROL] = @no_cache_control if @no_cache_control - end - end - - response - end - - private - - def etag_status?(status) - status == 200 || status == 201 - end - - def skip_caching?(headers) - headers.key?(ETAG_STRING) || headers.key?('last-modified') - end - - def digest_body(body) - digest = nil - - body.each do |part| - (digest ||= Digest::SHA256.new) << part unless part.empty? - end - - digest && digest.hexdigest.byteslice(0,32) - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/events.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/events.rb deleted file mode 100644 index c7bb201..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/events.rb +++ /dev/null @@ -1,157 +0,0 @@ -# frozen_string_literal: true - -require_relative 'body_proxy' -require_relative 'request' -require_relative 'response' - -module Rack - ### This middleware provides hooks to certain places in the request / - # response lifecycle. This is so that middleware that don't need to filter - # the response data can safely leave it alone and not have to send messages - # down the traditional "rack stack". - # - # The events are: - # - # * on_start(request, response) - # - # This event is sent at the start of the request, before the next - # middleware in the chain is called. This method is called with a request - # object, and a response object. Right now, the response object is always - # nil, but in the future it may actually be a real response object. - # - # * on_commit(request, response) - # - # The response has been committed. The application has returned, but the - # response has not been sent to the webserver yet. This method is always - # called with a request object and the response object. The response - # object is constructed from the rack triple that the application returned. - # Changes may still be made to the response object at this point. - # - # * on_send(request, response) - # - # The webserver has started iterating over the response body and presumably - # has started sending data over the wire. This method is always called with - # a request object and the response object. The response object is - # constructed from the rack triple that the application returned. Changes - # SHOULD NOT be made to the response object as the webserver has already - # started sending data. Any mutations will likely result in an exception. - # - # * on_finish(request, response) - # - # The webserver has closed the response, and all data has been written to - # the response socket. The request and response object should both be - # read-only at this point. The body MAY NOT be available on the response - # object as it may have been flushed to the socket. - # - # * on_error(request, response, error) - # - # An exception has occurred in the application or an `on_commit` event. - # This method will get the request, the response (if available) and the - # exception that was raised. - # - # ## Order - # - # `on_start` is called on the handlers in the order that they were passed to - # the constructor. `on_commit`, on_send`, `on_finish`, and `on_error` are - # called in the reverse order. `on_finish` handlers are called inside an - # `ensure` block, so they are guaranteed to be called even if something - # raises an exception. If something raises an exception in a `on_finish` - # method, then nothing is guaranteed. - - class Events - module Abstract - def on_start(req, res) - end - - def on_commit(req, res) - end - - def on_send(req, res) - end - - def on_finish(req, res) - end - - def on_error(req, res, e) - end - end - - class EventedBodyProxy < Rack::BodyProxy # :nodoc: - attr_reader :request, :response - - def initialize(body, request, response, handlers, &block) - super(body, &block) - @request = request - @response = response - @handlers = handlers - end - - def each - @handlers.reverse_each { |handler| handler.on_send request, response } - super - end - end - - class BufferedResponse < Rack::Response::Raw # :nodoc: - attr_reader :body - - def initialize(status, headers, body) - super(status, headers) - @body = body - end - - def to_a; [status, headers, body]; end - end - - def initialize(app, handlers) - @app = app - @handlers = handlers - end - - def call(env) - request = make_request env - on_start request, nil - - begin - status, headers, body = @app.call request.env - response = make_response status, headers, body - on_commit request, response - rescue StandardError => e - on_error request, response, e - on_finish request, response - raise - end - - body = EventedBodyProxy.new(body, request, response, @handlers) do - on_finish request, response - end - [response.status, response.headers, body] - end - - private - - def on_error(request, response, e) - @handlers.reverse_each { |handler| handler.on_error request, response, e } - end - - def on_commit(request, response) - @handlers.reverse_each { |handler| handler.on_commit request, response } - end - - def on_start(request, response) - @handlers.each { |handler| handler.on_start request, nil } - end - - def on_finish(request, response) - @handlers.reverse_each { |handler| handler.on_finish request, response } - end - - def make_request(env) - Rack::Request.new env - end - - def make_response(status, headers, body) - BufferedResponse.new status, headers, body - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/files.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/files.rb deleted file mode 100644 index 5b8353f..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/files.rb +++ /dev/null @@ -1,216 +0,0 @@ -# frozen_string_literal: true - -require 'time' - -require_relative 'constants' -require_relative 'head' -require_relative 'utils' -require_relative 'request' -require_relative 'mime' - -module Rack - # Rack::Files serves files below the +root+ directory given, according to the - # path info of the Rack request. - # e.g. when Rack::Files.new("/etc") is used, you can access 'passwd' file - # as http://localhost:9292/passwd - # - # Handlers can detect if bodies are a Rack::Files, and use mechanisms - # like sendfile on the +path+. - - class Files - ALLOWED_VERBS = %w[GET HEAD OPTIONS] - ALLOW_HEADER = ALLOWED_VERBS.join(', ') - MULTIPART_BOUNDARY = 'AaB03x' - - attr_reader :root - - def initialize(root, headers = {}, default_mime = 'text/plain') - @root = (::File.expand_path(root) if root) - @headers = headers - @default_mime = default_mime - @head = Rack::Head.new(lambda { |env| get env }) - end - - def call(env) - # HEAD requests drop the response body, including 4xx error messages. - @head.call env - end - - def get(env) - request = Rack::Request.new env - unless ALLOWED_VERBS.include? request.request_method - return fail(405, "Method Not Allowed", { 'allow' => ALLOW_HEADER }) - end - - path_info = Utils.unescape_path request.path_info - return fail(400, "Bad Request") unless Utils.valid_path?(path_info) - - clean_path_info = Utils.clean_path_info(path_info) - path = ::File.join(@root, clean_path_info) - - available = begin - ::File.file?(path) && ::File.readable?(path) - rescue SystemCallError - # Not sure in what conditions this exception can occur, but this - # is a safe way to handle such an error. - # :nocov: - false - # :nocov: - end - - if available - serving(request, path) - else - fail(404, "File not found: #{path_info}") - end - end - - def serving(request, path) - if request.options? - return [200, { 'allow' => ALLOW_HEADER, CONTENT_LENGTH => '0' }, []] - end - last_modified = ::File.mtime(path).httpdate - return [304, {}, []] if request.get_header('HTTP_IF_MODIFIED_SINCE') == last_modified - - headers = { "last-modified" => last_modified } - mime_type = mime_type path, @default_mime - headers[CONTENT_TYPE] = mime_type if mime_type - - # Set custom headers - headers.merge!(@headers) if @headers - - status = 200 - size = filesize path - - ranges = Rack::Utils.get_byte_ranges(request.get_header('HTTP_RANGE'), size) - if ranges.nil? - # No ranges: - ranges = [0..size - 1] - elsif ranges.empty? - # Unsatisfiable. Return error, and file size: - response = fail(416, "Byte range unsatisfiable") - response[1]["content-range"] = "bytes */#{size}" - return response - else - # Partial content - partial_content = true - - if ranges.size == 1 - range = ranges[0] - headers["content-range"] = "bytes #{range.begin}-#{range.end}/#{size}" - else - headers[CONTENT_TYPE] = "multipart/byteranges; boundary=#{MULTIPART_BOUNDARY}" - end - - status = 206 - body = BaseIterator.new(path, ranges, mime_type: mime_type, size: size) - size = body.bytesize - end - - headers[CONTENT_LENGTH] = size.to_s - - if request.head? - body = [] - elsif !partial_content - body = Iterator.new(path, ranges, mime_type: mime_type, size: size) - end - - [status, headers, body] - end - - class BaseIterator - attr_reader :path, :ranges, :options - - def initialize(path, ranges, options) - @path = path - @ranges = ranges - @options = options - end - - def each - ::File.open(path, "rb") do |file| - ranges.each do |range| - yield multipart_heading(range) if multipart? - - each_range_part(file, range) do |part| - yield part - end - end - - yield "\r\n--#{MULTIPART_BOUNDARY}--\r\n" if multipart? - end - end - - def bytesize - size = ranges.inject(0) do |sum, range| - sum += multipart_heading(range).bytesize if multipart? - sum += range.size - end - size += "\r\n--#{MULTIPART_BOUNDARY}--\r\n".bytesize if multipart? - size - end - - def close; end - - private - - def multipart? - ranges.size > 1 - end - - def multipart_heading(range) -<<-EOF -\r ---#{MULTIPART_BOUNDARY}\r -content-type: #{options[:mime_type]}\r -content-range: bytes #{range.begin}-#{range.end}/#{options[:size]}\r -\r -EOF - end - - def each_range_part(file, range) - file.seek(range.begin) - remaining_len = range.end - range.begin + 1 - while remaining_len > 0 - part = file.read([8192, remaining_len].min) - break unless part - remaining_len -= part.length - - yield part - end - end - end - - class Iterator < BaseIterator - alias :to_path :path - end - - private - - def fail(status, body, headers = {}) - body += "\n" - - [ - status, - { - CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => body.size.to_s, - "x-cascade" => "pass" - }.merge!(headers), - [body] - ] - end - - # The MIME type for the contents of the file located at @path - def mime_type(path, default_mime) - Mime.mime_type(::File.extname(path), default_mime) - end - - def filesize(path) - # We check via File::size? whether this file provides size info - # via stat (e.g. /proc files often don't), otherwise we have to - # figure it out by reading the whole file into memory. - ::File.size?(path) || ::File.read(path).bytesize - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/head.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/head.rb deleted file mode 100644 index c1c430f..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/head.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'body_proxy' - -module Rack - # Rack::Head returns an empty body for all HEAD requests. It leaves - # all other requests unchanged. - class Head - def initialize(app) - @app = app - end - - def call(env) - _, _, body = response = @app.call(env) - - if env[REQUEST_METHOD] == HEAD - response[2] = Rack::BodyProxy.new([]) do - body.close if body.respond_to? :close - end - end - - response - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/headers.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/headers.rb deleted file mode 100644 index cedf3a8..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/headers.rb +++ /dev/null @@ -1,238 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Rack::Headers is a Hash subclass that downcases all keys. It's designed - # to be used by rack applications that don't implement the Rack 3 SPEC - # (by using non-lowercase response header keys), automatically handling - # the downcasing of keys. - class Headers < Hash - KNOWN_HEADERS = {} - %w( - Accept-CH - Accept-Patch - Accept-Ranges - Access-Control-Allow-Credentials - Access-Control-Allow-Headers - Access-Control-Allow-Methods - Access-Control-Allow-Origin - Access-Control-Expose-Headers - Access-Control-Max-Age - Age - Allow - Alt-Svc - Cache-Control - Connection - Content-Disposition - Content-Encoding - Content-Language - Content-Length - Content-Location - Content-MD5 - Content-Range - Content-Security-Policy - Content-Security-Policy-Report-Only - Content-Type - Date - Delta-Base - ETag - Expect-CT - Expires - Feature-Policy - IM - Last-Modified - Link - Location - NEL - P3P - Permissions-Policy - Pragma - Preference-Applied - Proxy-Authenticate - Public-Key-Pins - Referrer-Policy - Refresh - Report-To - Retry-After - Server - Set-Cookie - Status - Strict-Transport-Security - Timing-Allow-Origin - Tk - Trailer - Transfer-Encoding - Upgrade - Vary - Via - WWW-Authenticate - Warning - X-Cascade - X-Content-Duration - X-Content-Security-Policy - X-Content-Type-Options - X-Correlation-ID - X-Correlation-Id - X-Download-Options - X-Frame-Options - X-Permitted-Cross-Domain-Policies - X-Powered-By - X-Redirect-By - X-Request-ID - X-Request-Id - X-Runtime - X-UA-Compatible - X-WebKit-CS - X-XSS-Protection - ).each do |str| - downcased = str.downcase.freeze - KNOWN_HEADERS[str] = KNOWN_HEADERS[downcased] = downcased - end - - def self.[](*items) - if items.length % 2 != 0 - if items.length == 1 && items.first.is_a?(Hash) - new.merge!(items.first) - else - raise ArgumentError, "odd number of arguments for Rack::Headers" - end - else - hash = new - loop do - break if items.length == 0 - key = items.shift - value = items.shift - hash[key] = value - end - hash - end - end - - def [](key) - super(downcase_key(key)) - end - - def []=(key, value) - super(KNOWN_HEADERS[key] || key.downcase.freeze, value) - end - alias store []= - - def assoc(key) - super(downcase_key(key)) - end - - def compare_by_identity - raise TypeError, "Rack::Headers cannot compare by identity, use regular Hash" - end - - def delete(key) - super(downcase_key(key)) - end - - def dig(key, *a) - super(downcase_key(key), *a) - end - - def fetch(key, *default, &block) - key = downcase_key(key) - super - end - - def fetch_values(*a) - super(*a.map!{|key| downcase_key(key)}) - end - - def has_key?(key) - super(downcase_key(key)) - end - alias include? has_key? - alias key? has_key? - alias member? has_key? - - def invert - hash = self.class.new - each{|key, value| hash[value] = key} - hash - end - - def merge(hash, &block) - dup.merge!(hash, &block) - end - - def reject(&block) - hash = dup - hash.reject!(&block) - hash - end - - def replace(hash) - clear - update(hash) - end - - def select(&block) - hash = dup - hash.select!(&block) - hash - end - - def to_proc - lambda{|x| self[x]} - end - - def transform_values(&block) - dup.transform_values!(&block) - end - - def update(hash, &block) - hash.each do |key, value| - self[key] = if block_given? && include?(key) - block.call(key, self[key], value) - else - value - end - end - self - end - alias merge! update - - def values_at(*keys) - keys.map{|key| self[key]} - end - - # :nocov: - if RUBY_VERSION >= '2.5' - # :nocov: - def slice(*a) - h = self.class.new - a.each{|k| h[k] = self[k] if has_key?(k)} - h - end - - def transform_keys(&block) - dup.transform_keys!(&block) - end - - def transform_keys! - hash = self.class.new - each do |k, v| - hash[yield k] = v - end - replace(hash) - end - end - - # :nocov: - if RUBY_VERSION >= '3.0' - # :nocov: - def except(*a) - super(*a.map!{|key| downcase_key(key)}) - end - end - - private - - def downcase_key(key) - key.is_a?(String) ? KNOWN_HEADERS[key] || key.downcase : key - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lint.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lint.rb deleted file mode 100644 index 4f36c2e..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lint.rb +++ /dev/null @@ -1,991 +0,0 @@ -# frozen_string_literal: true - -require 'forwardable' -require 'uri' - -require_relative 'constants' -require_relative 'utils' - -module Rack - # Rack::Lint validates your application and the requests and - # responses according to the Rack spec. - - class Lint - REQUEST_PATH_ORIGIN_FORM = /\A\/[^#]*\z/ - REQUEST_PATH_ABSOLUTE_FORM = /\A#{Utils::URI_PARSER.make_regexp}\z/ - REQUEST_PATH_AUTHORITY_FORM = /\A[^\/:]+:\d+\z/ - REQUEST_PATH_ASTERISK_FORM = '*' - - def initialize(app) - @app = app - end - - # :stopdoc: - - class LintError < RuntimeError; end - # AUTHORS: n.b. The trailing whitespace between paragraphs is important and - # should not be removed. The whitespace creates paragraphs in the RDoc - # output. - # - ## This specification aims to formalize the Rack protocol. You - ## can (and should) use Rack::Lint to enforce it. - ## - ## When you develop middleware, be sure to add a Lint before and - ## after to catch all mistakes. - ## - ## = Rack applications - ## - ## A Rack application is a Ruby object (not a class) that - ## responds to +call+. - def call(env = nil) - Wrapper.new(@app, env).response - end - - class Wrapper - def initialize(app, env) - @app = app - @env = env - @response = nil - @head_request = false - - @status = nil - @headers = nil - @body = nil - @invoked = nil - @content_length = nil - @closed = false - @size = 0 - end - - def response - ## It takes exactly one argument, the *environment* - raise LintError, "No env given" unless @env - check_environment(@env) - - ## and returns a non-frozen Array of exactly three values: - @response = @app.call(@env) - raise LintError, "response is not an Array, but #{@response.class}" unless @response.kind_of? Array - raise LintError, "response is frozen" if @response.frozen? - raise LintError, "response array has #{@response.size} elements instead of 3" unless @response.size == 3 - - @status, @headers, @body = @response - ## The *status*, - check_status(@status) - - ## the *headers*, - check_headers(@headers) - - hijack_proc = check_hijack_response(@headers, @env) - if hijack_proc - @headers[RACK_HIJACK] = hijack_proc - end - - ## and the *body*. - check_content_type_header(@status, @headers) - check_content_length_header(@status, @headers) - check_rack_protocol_header(@status, @headers) - @head_request = @env[REQUEST_METHOD] == HEAD - - @lint = (@env['rack.lint'] ||= []) << self - - if (@env['rack.lint.body_iteration'] ||= 0) > 0 - raise LintError, "Middleware must not call #each directly" - end - - return [@status, @headers, self] - end - - ## - ## == The Environment - ## - def check_environment(env) - ## The environment must be an unfrozen instance of Hash that includes - ## CGI-like headers. The Rack application is free to modify the - ## environment. - raise LintError, "env #{env.inspect} is not a Hash, but #{env.class}" unless env.kind_of? Hash - raise LintError, "env should not be frozen, but is" if env.frozen? - - ## - ## The environment is required to include these variables - ## (adopted from {PEP 333}[https://peps.python.org/pep-0333/]), except when they'd be empty, but see - ## below. - - ## REQUEST_METHOD:: The HTTP request method, such as - ## "GET" or "POST". This cannot ever - ## be an empty string, and so is - ## always required. - - ## SCRIPT_NAME:: The initial portion of the request - ## URL's "path" that corresponds to the - ## application object, so that the - ## application knows its virtual - ## "location". This may be an empty - ## string, if the application corresponds - ## to the "root" of the server. - - ## PATH_INFO:: The remainder of the request URL's - ## "path", designating the virtual - ## "location" of the request's target - ## within the application. This may be an - ## empty string, if the request URL targets - ## the application root and does not have a - ## trailing slash. This value may be - ## percent-encoded when originating from - ## a URL. - - ## QUERY_STRING:: The portion of the request URL that - ## follows the ?, if any. May be - ## empty, but is always required! - - ## SERVER_NAME:: When combined with SCRIPT_NAME and - ## PATH_INFO, these variables can be - ## used to complete the URL. Note, however, - ## that HTTP_HOST, if present, - ## should be used in preference to - ## SERVER_NAME for reconstructing - ## the request URL. - ## SERVER_NAME can never be an empty - ## string, and so is always required. - - ## SERVER_PORT:: An optional +Integer+ which is the port the - ## server is running on. Should be specified if - ## the server is running on a non-standard port. - - ## SERVER_PROTOCOL:: A string representing the HTTP version used - ## for the request. - - ## HTTP_ Variables:: Variables corresponding to the - ## client-supplied HTTP request - ## headers (i.e., variables whose - ## names begin with HTTP_). The - ## presence or absence of these - ## variables should correspond with - ## the presence or absence of the - ## appropriate HTTP header in the - ## request. See - ## {RFC3875 section 4.1.18}[https://tools.ietf.org/html/rfc3875#section-4.1.18] - ## for specific behavior. - - ## In addition to this, the Rack environment must include these - ## Rack-specific variables: - - ## rack.url_scheme:: +http+ or +https+, depending on the - ## request URL. - - ## rack.input:: See below, the input stream. - - ## rack.errors:: See below, the error stream. - - ## rack.hijack?:: See below, if present and true, indicates - ## that the server supports partial hijacking. - - ## rack.hijack:: See below, if present, an object responding - ## to +call+ that is used to perform a full - ## hijack. - - ## rack.protocol:: An optional +Array+ of +String+, containing - ## the protocols advertised by the client in - ## the +upgrade+ header (HTTP/1) or the - ## +:protocol+ pseudo-header (HTTP/2). - if protocols = @env['rack.protocol'] - unless protocols.is_a?(Array) && protocols.all?{|protocol| protocol.is_a?(String)} - raise LintError, "rack.protocol must be an Array of Strings" - end - end - - ## Additional environment specifications have approved to - ## standardized middleware APIs. None of these are required to - ## be implemented by the server. - - ## rack.session:: A hash-like interface for storing - ## request session data. - ## The store must implement: - if session = env[RACK_SESSION] - ## store(key, value) (aliased as []=); - unless session.respond_to?(:store) && session.respond_to?(:[]=) - raise LintError, "session #{session.inspect} must respond to store and []=" - end - - ## fetch(key, default = nil) (aliased as []); - unless session.respond_to?(:fetch) && session.respond_to?(:[]) - raise LintError, "session #{session.inspect} must respond to fetch and []" - end - - ## delete(key); - unless session.respond_to?(:delete) - raise LintError, "session #{session.inspect} must respond to delete" - end - - ## clear; - unless session.respond_to?(:clear) - raise LintError, "session #{session.inspect} must respond to clear" - end - - ## to_hash (returning unfrozen Hash instance); - unless session.respond_to?(:to_hash) && session.to_hash.kind_of?(Hash) && !session.to_hash.frozen? - raise LintError, "session #{session.inspect} must respond to to_hash and return unfrozen Hash instance" - end - end - - ## rack.logger:: A common object interface for logging messages. - ## The object must implement: - if logger = env[RACK_LOGGER] - ## info(message, &block) - unless logger.respond_to?(:info) - raise LintError, "logger #{logger.inspect} must respond to info" - end - - ## debug(message, &block) - unless logger.respond_to?(:debug) - raise LintError, "logger #{logger.inspect} must respond to debug" - end - - ## warn(message, &block) - unless logger.respond_to?(:warn) - raise LintError, "logger #{logger.inspect} must respond to warn" - end - - ## error(message, &block) - unless logger.respond_to?(:error) - raise LintError, "logger #{logger.inspect} must respond to error" - end - - ## fatal(message, &block) - unless logger.respond_to?(:fatal) - raise LintError, "logger #{logger.inspect} must respond to fatal" - end - end - - ## rack.multipart.buffer_size:: An Integer hint to the multipart parser as to what chunk size to use for reads and writes. - if bufsize = env[RACK_MULTIPART_BUFFER_SIZE] - unless bufsize.is_a?(Integer) && bufsize > 0 - raise LintError, "rack.multipart.buffer_size must be an Integer > 0 if specified" - end - end - - ## rack.multipart.tempfile_factory:: An object responding to #call with two arguments, the filename and content_type given for the multipart form field, and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate the tempfile for each multipart form file upload field, rather than the default class of Tempfile. - if tempfile_factory = env[RACK_MULTIPART_TEMPFILE_FACTORY] - raise LintError, "rack.multipart.tempfile_factory must respond to #call" unless tempfile_factory.respond_to?(:call) - env[RACK_MULTIPART_TEMPFILE_FACTORY] = lambda do |filename, content_type| - io = tempfile_factory.call(filename, content_type) - raise LintError, "rack.multipart.tempfile_factory return value must respond to #<<" unless io.respond_to?(:<<) - io - end - end - - ## The server or the application can store their own data in the - ## environment, too. The keys must contain at least one dot, - ## and should be prefixed uniquely. The prefix rack. - ## is reserved for use with the Rack core distribution and other - ## accepted specifications and must not be used otherwise. - ## - %w[REQUEST_METHOD SERVER_NAME QUERY_STRING SERVER_PROTOCOL rack.errors].each do |header| - raise LintError, "env missing required key #{header}" unless env.include? header - end - - ## The SERVER_PORT must be an Integer if set. - server_port = env["SERVER_PORT"] - unless server_port.nil? || (Integer(server_port) rescue false) - raise LintError, "env[SERVER_PORT] is not an Integer" - end - - ## The SERVER_NAME must be a valid authority as defined by RFC7540. - unless (URI.parse("http://#{env[SERVER_NAME]}/") rescue false) - raise LintError, "#{env[SERVER_NAME]} must be a valid authority" - end - - ## The HTTP_HOST must be a valid authority as defined by RFC7540. - unless (URI.parse("http://#{env[HTTP_HOST]}/") rescue false) - raise LintError, "#{env[HTTP_HOST]} must be a valid authority" - end - - ## The SERVER_PROTOCOL must match the regexp HTTP/\d(\.\d)?. - server_protocol = env['SERVER_PROTOCOL'] - unless %r{HTTP/\d(\.\d)?}.match?(server_protocol) - raise LintError, "env[SERVER_PROTOCOL] does not match HTTP/\\d(\\.\\d)?" - end - - ## The environment must not contain the keys - ## HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH - ## (use the versions without HTTP_). - %w[HTTP_CONTENT_TYPE HTTP_CONTENT_LENGTH].each { |header| - if env.include? header - raise LintError, "env contains #{header}, must use #{header[5..-1]}" - end - } - - ## The CGI keys (named without a period) must have String values. - ## If the string values for CGI keys contain non-ASCII characters, - ## they should use ASCII-8BIT encoding. - env.each { |key, value| - next if key.include? "." # Skip extensions - unless value.kind_of? String - raise LintError, "env variable #{key} has non-string value #{value.inspect}" - end - next if value.encoding == Encoding::ASCII_8BIT - unless value.b !~ /[\x80-\xff]/n - raise LintError, "env variable #{key} has value containing non-ASCII characters and has non-ASCII-8BIT encoding #{value.inspect} encoding: #{value.encoding}" - end - } - - ## There are the following restrictions: - - ## * rack.url_scheme must either be +http+ or +https+. - unless %w[http https].include?(env[RACK_URL_SCHEME]) - raise LintError, "rack.url_scheme unknown: #{env[RACK_URL_SCHEME].inspect}" - end - - ## * There may be a valid input stream in rack.input. - if rack_input = env[RACK_INPUT] - check_input_stream(rack_input) - @env[RACK_INPUT] = InputWrapper.new(rack_input) - end - - ## * There must be a valid error stream in rack.errors. - rack_errors = env[RACK_ERRORS] - check_error_stream(rack_errors) - @env[RACK_ERRORS] = ErrorWrapper.new(rack_errors) - - ## * There may be a valid hijack callback in rack.hijack - check_hijack env - ## * There may be a valid early hints callback in rack.early_hints - check_early_hints env - - ## * The REQUEST_METHOD must be a valid token. - unless env[REQUEST_METHOD] =~ /\A[0-9A-Za-z!\#$%&'*+.^_`|~-]+\z/ - raise LintError, "REQUEST_METHOD unknown: #{env[REQUEST_METHOD].dump}" - end - - ## * The SCRIPT_NAME, if non-empty, must start with / - if env.include?(SCRIPT_NAME) && env[SCRIPT_NAME] != "" && env[SCRIPT_NAME] !~ /\A\// - raise LintError, "SCRIPT_NAME must start with /" - end - - ## * The PATH_INFO, if provided, must be a valid request target or an empty string. - if env.include?(PATH_INFO) - case env[PATH_INFO] - when REQUEST_PATH_ASTERISK_FORM - ## * Only OPTIONS requests may have PATH_INFO set to * (asterisk-form). - unless env[REQUEST_METHOD] == OPTIONS - raise LintError, "Only OPTIONS requests may have PATH_INFO set to '*' (asterisk-form)" - end - when REQUEST_PATH_AUTHORITY_FORM - ## * Only CONNECT requests may have PATH_INFO set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. - unless env[REQUEST_METHOD] == CONNECT - raise LintError, "Only CONNECT requests may have PATH_INFO set to an authority (authority-form)" - end - when REQUEST_PATH_ABSOLUTE_FORM - ## * CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form). - if env[REQUEST_METHOD] == CONNECT || env[REQUEST_METHOD] == OPTIONS - raise LintError, "CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form)" - end - when REQUEST_PATH_ORIGIN_FORM - ## * Otherwise, PATH_INFO must start with a / and must not include a fragment part starting with '#' (origin-form). - when "" - # Empty string is okay. - else - raise LintError, "PATH_INFO must start with a '/' and must not include a fragment part starting with '#' (origin-form)" - end - end - - ## * The CONTENT_LENGTH, if given, must consist of digits only. - if env.include?("CONTENT_LENGTH") && env["CONTENT_LENGTH"] !~ /\A\d+\z/ - raise LintError, "Invalid CONTENT_LENGTH: #{env["CONTENT_LENGTH"]}" - end - - ## * One of SCRIPT_NAME or PATH_INFO must be - ## set. PATH_INFO should be / if - ## SCRIPT_NAME is empty. - unless env[SCRIPT_NAME] || env[PATH_INFO] - raise LintError, "One of SCRIPT_NAME or PATH_INFO must be set (make PATH_INFO '/' if SCRIPT_NAME is empty)" - end - ## SCRIPT_NAME never should be /, but instead be empty. - unless env[SCRIPT_NAME] != "/" - raise LintError, "SCRIPT_NAME cannot be '/', make it '' and PATH_INFO '/'" - end - - ## rack.response_finished:: An array of callables run by the server after the response has been - ## processed. This would typically be invoked after sending the response to the client, but it could also be - ## invoked if an error occurs while generating the response or sending the response; in that case, the error - ## argument will be a subclass of +Exception+. - ## The callables are invoked with +env, status, headers, error+ arguments and should not raise any - ## exceptions. They should be invoked in reverse order of registration. - if callables = env[RACK_RESPONSE_FINISHED] - raise LintError, "rack.response_finished must be an array of callable objects" unless callables.is_a?(Array) - - callables.each do |callable| - raise LintError, "rack.response_finished values must respond to call(env, status, headers, error)" unless callable.respond_to?(:call) - end - end - end - - ## - ## === The Input Stream - ## - ## The input stream is an IO-like object which contains the raw HTTP - ## POST data. - def check_input_stream(input) - ## When applicable, its external encoding must be "ASCII-8BIT" and it - ## must be opened in binary mode. - if input.respond_to?(:external_encoding) && input.external_encoding != Encoding::ASCII_8BIT - raise LintError, "rack.input #{input} does not have ASCII-8BIT as its external encoding" - end - if input.respond_to?(:binmode?) && !input.binmode? - raise LintError, "rack.input #{input} is not opened in binary mode" - end - - ## The input stream must respond to +gets+, +each+, and +read+. - [:gets, :each, :read].each { |method| - unless input.respond_to? method - raise LintError, "rack.input #{input} does not respond to ##{method}" - end - } - end - - class InputWrapper - def initialize(input) - @input = input - end - - ## * +gets+ must be called without arguments and return a string, - ## or +nil+ on EOF. - def gets(*args) - raise LintError, "rack.input#gets called with arguments" unless args.size == 0 - v = @input.gets - unless v.nil? or v.kind_of? String - raise LintError, "rack.input#gets didn't return a String" - end - v - end - - ## * +read+ behaves like IO#read. - ## Its signature is read([length, [buffer]]). - ## - ## If given, +length+ must be a non-negative Integer (>= 0) or +nil+, - ## and +buffer+ must be a String and may not be nil. - ## - ## If +length+ is given and not nil, then this method reads at most - ## +length+ bytes from the input stream. - ## - ## If +length+ is not given or nil, then this method reads - ## all data until EOF. - ## - ## When EOF is reached, this method returns nil if +length+ is given - ## and not nil, or "" if +length+ is not given or is nil. - ## - ## If +buffer+ is given, then the read data will be placed - ## into +buffer+ instead of a newly created String object. - def read(*args) - unless args.size <= 2 - raise LintError, "rack.input#read called with too many arguments" - end - if args.size >= 1 - unless args.first.kind_of?(Integer) || args.first.nil? - raise LintError, "rack.input#read called with non-integer and non-nil length" - end - unless args.first.nil? || args.first >= 0 - raise LintError, "rack.input#read called with a negative length" - end - end - if args.size >= 2 - unless args[1].kind_of?(String) - raise LintError, "rack.input#read called with non-String buffer" - end - end - - v = @input.read(*args) - - unless v.nil? or v.kind_of? String - raise LintError, "rack.input#read didn't return nil or a String" - end - if args[0].nil? - unless !v.nil? - raise LintError, "rack.input#read(nil) returned nil on EOF" - end - end - - v - end - - ## * +each+ must be called without arguments and only yield Strings. - def each(*args) - raise LintError, "rack.input#each called with arguments" unless args.size == 0 - @input.each { |line| - unless line.kind_of? String - raise LintError, "rack.input#each didn't yield a String" - end - yield line - } - end - - ## * +close+ can be called on the input stream to indicate that - ## any remaining input is not needed. - def close(*args) - @input.close(*args) - end - end - - ## - ## === The Error Stream - ## - def check_error_stream(error) - ## The error stream must respond to +puts+, +write+ and +flush+. - [:puts, :write, :flush].each { |method| - unless error.respond_to? method - raise LintError, "rack.error #{error} does not respond to ##{method}" - end - } - end - - class ErrorWrapper - def initialize(error) - @error = error - end - - ## * +puts+ must be called with a single argument that responds to +to_s+. - def puts(str) - @error.puts str - end - - ## * +write+ must be called with a single argument that is a String. - def write(str) - raise LintError, "rack.errors#write not called with a String" unless str.kind_of? String - @error.write str - end - - ## * +flush+ must be called without arguments and must be called - ## in order to make the error appear for sure. - def flush - @error.flush - end - - ## * +close+ must never be called on the error stream. - def close(*args) - raise LintError, "rack.errors#close must not be called" - end - end - - ## - ## === Hijacking - ## - ## The hijacking interfaces provides a means for an application to take - ## control of the HTTP connection. There are two distinct hijack - ## interfaces: full hijacking where the application takes over the raw - ## connection, and partial hijacking where the application takes over - ## just the response body stream. In both cases, the application is - ## responsible for closing the hijacked stream. - ## - ## Full hijacking only works with HTTP/1. Partial hijacking is functionally - ## equivalent to streaming bodies, and is still optionally supported for - ## backwards compatibility with older Rack versions. - ## - ## ==== Full Hijack - ## - ## Full hijack is used to completely take over an HTTP/1 connection. It - ## occurs before any headers are written and causes the request to - ## ignores any response generated by the application. - ## - ## It is intended to be used when applications need access to raw HTTP/1 - ## connection. - ## - def check_hijack(env) - ## If +rack.hijack+ is present in +env+, it must respond to +call+ - if original_hijack = env[RACK_HIJACK] - raise LintError, "rack.hijack must respond to call" unless original_hijack.respond_to?(:call) - - env[RACK_HIJACK] = proc do - io = original_hijack.call - - ## and return an +IO+ instance which can be used to read and write - ## to the underlying connection using HTTP/1 semantics and - ## formatting. - raise LintError, "rack.hijack must return an IO instance" unless io.is_a?(IO) - - io - end - end - end - - ## - ## ==== Partial Hijack - ## - ## Partial hijack is used for bi-directional streaming of the request and - ## response body. It occurs after the status and headers are written by - ## the server and causes the server to ignore the Body of the response. - ## - ## It is intended to be used when applications need bi-directional - ## streaming. - ## - def check_hijack_response(headers, env) - ## If +rack.hijack?+ is present in +env+ and truthy, - if env[RACK_IS_HIJACK] - ## an application may set the special response header +rack.hijack+ - if original_hijack = headers[RACK_HIJACK] - ## to an object that responds to +call+, - unless original_hijack.respond_to?(:call) - raise LintError, 'rack.hijack header must respond to #call' - end - ## accepting a +stream+ argument. - return proc do |io| - original_hijack.call StreamWrapper.new(io) - end - end - ## - ## After the response status and headers have been sent, this hijack - ## callback will be invoked with a +stream+ argument which follows the - ## same interface as outlined in "Streaming Body". Servers must - ## ignore the +body+ part of the response tuple when the - ## +rack.hijack+ response header is present. Using an empty +Array+ - ## instance is recommended. - else - ## - ## The special response header +rack.hijack+ must only be set - ## if the request +env+ has a truthy +rack.hijack?+. - if headers.key?(RACK_HIJACK) - raise LintError, 'rack.hijack header must not be present if server does not support hijacking' - end - end - - nil - end - - ## - ## === Early Hints - ## - ## The application or any middleware may call the rack.early_hints - ## with an object which would be valid as the headers of a Rack response. - def check_early_hints(env) - if env[RACK_EARLY_HINTS] - ## - ## If rack.early_hints is present, it must respond to #call. - unless env[RACK_EARLY_HINTS].respond_to?(:call) - raise LintError, "rack.early_hints must respond to call" - end - - original_callback = env[RACK_EARLY_HINTS] - env[RACK_EARLY_HINTS] = lambda do |headers| - ## If rack.early_hints is called, it must be called with - ## valid Rack response headers. - check_headers(headers) - original_callback.call(headers) - end - end - end - - ## - ## == The Response - ## - ## === The Status - ## - def check_status(status) - ## This is an HTTP status. It must be an Integer greater than or equal to - ## 100. - unless status.is_a?(Integer) && status >= 100 - raise LintError, "Status must be an Integer >=100" - end - end - - ## - ## === The Headers - ## - def check_headers(headers) - ## The headers must be a unfrozen Hash. - unless headers.kind_of?(Hash) - raise LintError, "headers object should be a hash, but isn't (got #{headers.class} as headers)" - end - - if headers.frozen? - raise LintError, "headers object should not be frozen, but is" - end - - headers.each do |key, value| - ## The header keys must be Strings. - unless key.kind_of? String - raise LintError, "header key must be a string, was #{key.class}" - end - - ## Special headers starting "rack." are for communicating with the - ## server, and must not be sent back to the client. - next if key.start_with?("rack.") - - ## The header must not contain a +Status+ key. - raise LintError, "header must not contain status" if key == "status" - ## Header keys must conform to RFC7230 token specification, i.e. cannot - ## contain non-printable ASCII, DQUOTE or "(),/:;<=>?@[\]{}". - raise LintError, "invalid header name: #{key}" if key =~ /[\(\),\/:;<=>\?@\[\\\]{}[:cntrl:]]/ - ## Header keys must not contain uppercase ASCII characters (A-Z). - raise LintError, "uppercase character in header name: #{key}" if key =~ /[A-Z]/ - - ## Header values must be either a String instance, - if value.kind_of?(String) - check_header_value(key, value) - elsif value.kind_of?(Array) - ## or an Array of String instances, - value.each{|value| check_header_value(key, value)} - else - raise LintError, "a header value must be a String or Array of Strings, but the value of '#{key}' is a #{value.class}" - end - end - end - - def check_header_value(key, value) - ## such that each String instance must not contain characters below 037. - if value =~ /[\000-\037]/ - raise LintError, "invalid header value #{key}: #{value.inspect}" - end - end - - ## - ## ==== The +content-type+ Header - ## - def check_content_type_header(status, headers) - headers.each { |key, value| - ## There must not be a content-type header key when the +Status+ is 1xx, - ## 204, or 304. - if key == "content-type" - if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i - raise LintError, "content-type header found in #{status} response, not allowed" - end - return - end - } - end - - ## - ## ==== The +content-length+ Header - ## - def check_content_length_header(status, headers) - headers.each { |key, value| - if key == 'content-length' - ## There must not be a content-length header key when the - ## +Status+ is 1xx, 204, or 304. - if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i - raise LintError, "content-length header found in #{status} response, not allowed" - end - @content_length = value - end - } - end - - def verify_content_length(size) - if @head_request - unless size == 0 - raise LintError, "Response body was given for HEAD request, but should be empty" - end - elsif @content_length - unless @content_length == size.to_s - raise LintError, "content-length header was #{@content_length}, but should be #{size}" - end - end - end - - ## - ## ==== The +rack.protocol+ Header - ## - def check_rack_protocol_header(status, headers) - ## If the +rack.protocol+ header is present, it must be a +String+, and - ## must be one of the values from the +rack.protocol+ array from the - ## environment. - protocol = headers['rack.protocol'] - - if protocol - request_protocols = @env['rack.protocol'] - - if request_protocols.nil? - raise LintError, "rack.protocol header is #{protocol.inspect}, but rack.protocol was not set in request!" - elsif !request_protocols.include?(protocol) - raise LintError, "rack.protocol header is #{protocol.inspect}, but should be one of #{request_protocols.inspect} from the request!" - end - end - end - ## - ## Setting this value informs the server that it should perform a - ## connection upgrade. In HTTP/1, this is done using the +upgrade+ - ## header. In HTTP/2, this is done by accepting the request. - ## - ## === The Body - ## - ## The Body is typically an +Array+ of +String+ instances, an enumerable - ## that yields +String+ instances, a +Proc+ instance, or a File-like - ## object. - ## - ## The Body must respond to +each+ or +call+. It may optionally respond - ## to +to_path+ or +to_ary+. A Body that responds to +each+ is considered - ## to be an Enumerable Body. A Body that responds to +call+ is considered - ## to be a Streaming Body. - ## - ## A Body that responds to both +each+ and +call+ must be treated as an - ## Enumerable Body, not a Streaming Body. If it responds to +each+, you - ## must call +each+ and not +call+. If the Body doesn't respond to - ## +each+, then you can assume it responds to +call+. - ## - ## The Body must either be consumed or returned. The Body is consumed by - ## optionally calling either +each+ or +call+. - ## Then, if the Body responds to +close+, it must be called to release - ## any resources associated with the generation of the body. - ## In other words, +close+ must always be called at least once; typically - ## after the web server has sent the response to the client, but also in - ## cases where the Rack application makes internal/virtual requests and - ## discards the response. - ## - def close - ## - ## After calling +close+, the Body is considered closed and should not - ## be consumed again. - @closed = true - - ## If the original Body is replaced by a new Body, the new Body must - ## also consume the original Body by calling +close+ if possible. - @body.close if @body.respond_to?(:close) - - index = @lint.index(self) - unless @env['rack.lint'][0..index].all? {|lint| lint.instance_variable_get(:@closed)} - raise LintError, "Body has not been closed" - end - end - - def verify_to_path - ## - ## If the Body responds to +to_path+, it must return a +String+ - ## path for the local file system whose contents are identical - ## to that produced by calling +each+; this may be used by the - ## server as an alternative, possibly more efficient way to - ## transport the response. The +to_path+ method does not consume - ## the body. - if @body.respond_to?(:to_path) - unless ::File.exist? @body.to_path - raise LintError, "The file identified by body.to_path does not exist" - end - end - end - - ## - ## ==== Enumerable Body - ## - def each - ## The Enumerable Body must respond to +each+. - raise LintError, "Enumerable Body must respond to each" unless @body.respond_to?(:each) - - ## It must only be called once. - raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? - - ## It must not be called after being closed, - raise LintError, "Response body is already closed" if @closed - - @invoked = :each - - @body.each do |chunk| - ## and must only yield String values. - unless chunk.kind_of? String - raise LintError, "Body yielded non-string value #{chunk.inspect}" - end - - ## - ## Middleware must not call +each+ directly on the Body. - ## Instead, middleware can return a new Body that calls +each+ on the - ## original Body, yielding at least once per iteration. - if @lint[0] == self - @env['rack.lint.body_iteration'] += 1 - else - if (@env['rack.lint.body_iteration'] -= 1) > 0 - raise LintError, "New body must yield at least once per iteration of old body" - end - end - - @size += chunk.bytesize - yield chunk - end - - verify_content_length(@size) - - verify_to_path - end - - BODY_METHODS = {to_ary: true, each: true, call: true, to_path: true} - - def to_path - @body.to_path - end - - def respond_to?(name, *) - if BODY_METHODS.key?(name) - @body.respond_to?(name) - else - super - end - end - - ## - ## If the Body responds to +to_ary+, it must return an +Array+ whose - ## contents are identical to that produced by calling +each+. - ## Middleware may call +to_ary+ directly on the Body and return a new - ## Body in its place. In other words, middleware can only process the - ## Body directly if it responds to +to_ary+. If the Body responds to both - ## +to_ary+ and +close+, its implementation of +to_ary+ must call - ## +close+. - def to_ary - @body.to_ary.tap do |content| - unless content == @body.enum_for.to_a - raise LintError, "#to_ary not identical to contents produced by calling #each" - end - end - ensure - close - end - - ## - ## ==== Streaming Body - ## - def call(stream) - ## The Streaming Body must respond to +call+. - raise LintError, "Streaming Body must respond to call" unless @body.respond_to?(:call) - - ## It must only be called once. - raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? - - ## It must not be called after being closed. - raise LintError, "Response body is already closed" if @closed - - @invoked = :call - - ## It takes a +stream+ argument. - ## - ## The +stream+ argument must implement: - ## read, write, <<, flush, close, close_read, close_write, closed? - ## - @body.call(StreamWrapper.new(stream)) - end - - class StreamWrapper - extend Forwardable - - ## The semantics of these IO methods must be a best effort match to - ## those of a normal Ruby IO or Socket object, using standard arguments - ## and raising standard exceptions. Servers are encouraged to simply - ## pass on real IO objects, although it is recognized that this approach - ## is not directly compatible with HTTP/2. - REQUIRED_METHODS = [ - :read, :write, :<<, :flush, :close, - :close_read, :close_write, :closed? - ] - - def_delegators :@stream, *REQUIRED_METHODS - - def initialize(stream) - @stream = stream - - REQUIRED_METHODS.each do |method_name| - raise LintError, "Stream must respond to #{method_name}" unless stream.respond_to?(method_name) - end - end - end - - # :startdoc: - end - end -end - -## -## == Thanks -## Some parts of this specification are adopted from {PEP 333 – Python Web Server Gateway Interface v1.0}[https://peps.python.org/pep-0333/] -## I'd like to thank everyone involved in that effort. diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lock.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lock.rb deleted file mode 100644 index 342123a..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/lock.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require_relative 'body_proxy' - -module Rack - # Rack::Lock locks every request inside a mutex, so that every request - # will effectively be executed synchronously. - class Lock - def initialize(app, mutex = Mutex.new) - @app, @mutex = app, mutex - end - - def call(env) - @mutex.lock - begin - response = @app.call(env) - returned = response << BodyProxy.new(response.pop) { unlock } - ensure - unlock unless returned - end - end - - private - - def unlock - @mutex.unlock - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/logger.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/logger.rb deleted file mode 100644 index 081212d..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/logger.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'logger' -require_relative 'constants' - -warn "Rack::Logger is deprecated and will be removed in Rack 3.2.", uplevel: 1 - -module Rack - # Sets up rack.logger to write to rack.errors stream - class Logger - def initialize(app, level = ::Logger::INFO) - @app, @level = app, level - end - - def call(env) - logger = ::Logger.new(env[RACK_ERRORS]) - logger.level = @level - - env[RACK_LOGGER] = logger - @app.call(env) - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/media_type.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/media_type.rb deleted file mode 100644 index 7fc1e39..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/media_type.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Rack::MediaType parse media type and parameters out of content_type string - - class MediaType - SPLIT_PATTERN = /[;,]/ - - class << self - # The media type (type/subtype) portion of the CONTENT_TYPE header - # without any media type parameters. e.g., when CONTENT_TYPE is - # "text/plain;charset=utf-8", the media-type is "text/plain". - # - # For more information on the use of media types in HTTP, see: - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 - def type(content_type) - return nil unless content_type - if type = content_type.split(SPLIT_PATTERN, 2).first - type.rstrip! - type.downcase! - type - end - end - - # The media type parameters provided in CONTENT_TYPE as a Hash, or - # an empty Hash if no CONTENT_TYPE or media-type parameters were - # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", - # this method responds with the following Hash: - # { 'charset' => 'utf-8' } - def params(content_type) - return {} if content_type.nil? - - content_type.split(SPLIT_PATTERN)[1..-1].each_with_object({}) do |s, hsh| - s.strip! - k, v = s.split('=', 2) - k.downcase! - hsh[k] = strip_doublequotes(v) - end - end - - private - - def strip_doublequotes(str) - (str.start_with?('"') && str.end_with?('"')) ? str[1..-2] : str - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/method_override.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/method_override.rb deleted file mode 100644 index 6125b19..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/method_override.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'request' -require_relative 'utils' - -module Rack - class MethodOverride - HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK] - - METHOD_OVERRIDE_PARAM_KEY = "_method" - HTTP_METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE" - ALLOWED_METHODS = %w[POST] - - def initialize(app) - @app = app - end - - def call(env) - if allowed_methods.include?(env[REQUEST_METHOD]) - method = method_override(env) - if HTTP_METHODS.include?(method) - env[RACK_METHODOVERRIDE_ORIGINAL_METHOD] = env[REQUEST_METHOD] - env[REQUEST_METHOD] = method - end - end - - @app.call(env) - end - - def method_override(env) - req = Request.new(env) - method = method_override_param(req) || - env[HTTP_METHOD_OVERRIDE_HEADER] - begin - method.to_s.upcase - rescue ArgumentError - env[RACK_ERRORS].puts "Invalid string for method" - end - end - - private - - def allowed_methods - ALLOWED_METHODS - end - - def method_override_param(req) - req.POST[METHOD_OVERRIDE_PARAM_KEY] if req.form_data? || req.parseable_data? - rescue Utils::InvalidParameterError, Utils::ParameterTypeError, QueryParser::ParamsTooDeepError - req.get_header(RACK_ERRORS).puts "Invalid or incomplete POST params" - rescue EOFError - req.get_header(RACK_ERRORS).puts "Bad request content body" - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mime.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mime.rb deleted file mode 100644 index 0272968..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mime.rb +++ /dev/null @@ -1,694 +0,0 @@ -# frozen_string_literal: true - -module Rack - module Mime - # Returns String with mime type if found, otherwise use +fallback+. - # +ext+ should be filename extension in the '.ext' format that - # File.extname(file) returns. - # +fallback+ may be any object - # - # Also see the documentation for MIME_TYPES - # - # Usage: - # Rack::Mime.mime_type('.foo') - # - # This is a shortcut for: - # Rack::Mime::MIME_TYPES.fetch('.foo', 'application/octet-stream') - - def mime_type(ext, fallback = 'application/octet-stream') - MIME_TYPES.fetch(ext.to_s.downcase, fallback) - end - module_function :mime_type - - # Returns true if the given value is a mime match for the given mime match - # specification, false otherwise. - # - # Rack::Mime.match?('text/html', 'text/*') => true - # Rack::Mime.match?('text/plain', '*') => true - # Rack::Mime.match?('text/html', 'application/json') => false - - def match?(value, matcher) - v1, v2 = value.split('/', 2) - m1, m2 = matcher.split('/', 2) - - (m1 == '*' || v1 == m1) && (m2.nil? || m2 == '*' || m2 == v2) - end - module_function :match? - - # List of most common mime-types, selected various sources - # according to their usefulness in a webserving scope for Ruby - # users. - # - # To amend this list with your local mime.types list you can use: - # - # require 'webrick/httputils' - # list = WEBrick::HTTPUtils.load_mime_types('/etc/mime.types') - # Rack::Mime::MIME_TYPES.merge!(list) - # - # N.B. On Ubuntu the mime.types file does not include the leading period, so - # users may need to modify the data before merging into the hash. - - MIME_TYPES = { - ".123" => "application/vnd.lotus-1-2-3", - ".3dml" => "text/vnd.in3d.3dml", - ".3g2" => "video/3gpp2", - ".3gp" => "video/3gpp", - ".a" => "application/octet-stream", - ".acc" => "application/vnd.americandynamics.acc", - ".ace" => "application/x-ace-compressed", - ".acu" => "application/vnd.acucobol", - ".aep" => "application/vnd.audiograph", - ".afp" => "application/vnd.ibm.modcap", - ".ai" => "application/postscript", - ".aif" => "audio/x-aiff", - ".aiff" => "audio/x-aiff", - ".ami" => "application/vnd.amiga.ami", - ".apng" => "image/apng", - ".appcache" => "text/cache-manifest", - ".apr" => "application/vnd.lotus-approach", - ".asc" => "application/pgp-signature", - ".asf" => "video/x-ms-asf", - ".asm" => "text/x-asm", - ".aso" => "application/vnd.accpac.simply.aso", - ".asx" => "video/x-ms-asf", - ".atc" => "application/vnd.acucorp", - ".atom" => "application/atom+xml", - ".atomcat" => "application/atomcat+xml", - ".atomsvc" => "application/atomsvc+xml", - ".atx" => "application/vnd.antix.game-component", - ".au" => "audio/basic", - ".avi" => "video/x-msvideo", - ".avif" => "image/avif", - ".bat" => "application/x-msdownload", - ".bcpio" => "application/x-bcpio", - ".bdm" => "application/vnd.syncml.dm+wbxml", - ".bh2" => "application/vnd.fujitsu.oasysprs", - ".bin" => "application/octet-stream", - ".bmi" => "application/vnd.bmi", - ".bmp" => "image/bmp", - ".box" => "application/vnd.previewsystems.box", - ".btif" => "image/prs.btif", - ".bz" => "application/x-bzip", - ".bz2" => "application/x-bzip2", - ".c" => "text/x-c", - ".c4g" => "application/vnd.clonk.c4group", - ".cab" => "application/vnd.ms-cab-compressed", - ".cc" => "text/x-c", - ".ccxml" => "application/ccxml+xml", - ".cdbcmsg" => "application/vnd.contact.cmsg", - ".cdkey" => "application/vnd.mediastation.cdkey", - ".cdx" => "chemical/x-cdx", - ".cdxml" => "application/vnd.chemdraw+xml", - ".cdy" => "application/vnd.cinderella", - ".cer" => "application/pkix-cert", - ".cgm" => "image/cgm", - ".chat" => "application/x-chat", - ".chm" => "application/vnd.ms-htmlhelp", - ".chrt" => "application/vnd.kde.kchart", - ".cif" => "chemical/x-cif", - ".cii" => "application/vnd.anser-web-certificate-issue-initiation", - ".cil" => "application/vnd.ms-artgalry", - ".cla" => "application/vnd.claymore", - ".class" => "application/octet-stream", - ".clkk" => "application/vnd.crick.clicker.keyboard", - ".clkp" => "application/vnd.crick.clicker.palette", - ".clkt" => "application/vnd.crick.clicker.template", - ".clkw" => "application/vnd.crick.clicker.wordbank", - ".clkx" => "application/vnd.crick.clicker", - ".clp" => "application/x-msclip", - ".cmc" => "application/vnd.cosmocaller", - ".cmdf" => "chemical/x-cmdf", - ".cml" => "chemical/x-cml", - ".cmp" => "application/vnd.yellowriver-custom-menu", - ".cmx" => "image/x-cmx", - ".com" => "application/x-msdownload", - ".conf" => "text/plain", - ".cpio" => "application/x-cpio", - ".cpp" => "text/x-c", - ".cpt" => "application/mac-compactpro", - ".crd" => "application/x-mscardfile", - ".crl" => "application/pkix-crl", - ".crt" => "application/x-x509-ca-cert", - ".csh" => "application/x-csh", - ".csml" => "chemical/x-csml", - ".csp" => "application/vnd.commonspace", - ".css" => "text/css", - ".csv" => "text/csv", - ".curl" => "application/vnd.curl", - ".cww" => "application/prs.cww", - ".cxx" => "text/x-c", - ".daf" => "application/vnd.mobius.daf", - ".davmount" => "application/davmount+xml", - ".dcr" => "application/x-director", - ".dd2" => "application/vnd.oma.dd2+xml", - ".ddd" => "application/vnd.fujixerox.ddd", - ".deb" => "application/x-debian-package", - ".der" => "application/x-x509-ca-cert", - ".dfac" => "application/vnd.dreamfactory", - ".diff" => "text/x-diff", - ".dis" => "application/vnd.mobius.dis", - ".djv" => "image/vnd.djvu", - ".djvu" => "image/vnd.djvu", - ".dll" => "application/x-msdownload", - ".dmg" => "application/octet-stream", - ".dna" => "application/vnd.dna", - ".doc" => "application/msword", - ".docm" => "application/vnd.ms-word.document.macroEnabled.12", - ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".dot" => "application/msword", - ".dotm" => "application/vnd.ms-word.template.macroEnabled.12", - ".dotx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.template", - ".dp" => "application/vnd.osgi.dp", - ".dpg" => "application/vnd.dpgraph", - ".dsc" => "text/prs.lines.tag", - ".dtd" => "application/xml-dtd", - ".dts" => "audio/vnd.dts", - ".dtshd" => "audio/vnd.dts.hd", - ".dv" => "video/x-dv", - ".dvi" => "application/x-dvi", - ".dwf" => "model/vnd.dwf", - ".dwg" => "image/vnd.dwg", - ".dxf" => "image/vnd.dxf", - ".dxp" => "application/vnd.spotfire.dxp", - ".ear" => "application/java-archive", - ".ecelp4800" => "audio/vnd.nuera.ecelp4800", - ".ecelp7470" => "audio/vnd.nuera.ecelp7470", - ".ecelp9600" => "audio/vnd.nuera.ecelp9600", - ".ecma" => "application/ecmascript", - ".edm" => "application/vnd.novadigm.edm", - ".edx" => "application/vnd.novadigm.edx", - ".efif" => "application/vnd.picsel", - ".ei6" => "application/vnd.pg.osasli", - ".eml" => "message/rfc822", - ".eol" => "audio/vnd.digital-winds", - ".eot" => "application/vnd.ms-fontobject", - ".eps" => "application/postscript", - ".es3" => "application/vnd.eszigno3+xml", - ".esf" => "application/vnd.epson.esf", - ".etx" => "text/x-setext", - ".exe" => "application/x-msdownload", - ".ext" => "application/vnd.novadigm.ext", - ".ez" => "application/andrew-inset", - ".ez2" => "application/vnd.ezpix-album", - ".ez3" => "application/vnd.ezpix-package", - ".f" => "text/x-fortran", - ".f77" => "text/x-fortran", - ".f90" => "text/x-fortran", - ".fbs" => "image/vnd.fastbidsheet", - ".fdf" => "application/vnd.fdf", - ".fe_launch" => "application/vnd.denovo.fcselayout-link", - ".fg5" => "application/vnd.fujitsu.oasysgp", - ".fli" => "video/x-fli", - ".flif" => "image/flif", - ".flo" => "application/vnd.micrografx.flo", - ".flv" => "video/x-flv", - ".flw" => "application/vnd.kde.kivio", - ".flx" => "text/vnd.fmi.flexstor", - ".fly" => "text/vnd.fly", - ".fm" => "application/vnd.framemaker", - ".fnc" => "application/vnd.frogans.fnc", - ".for" => "text/x-fortran", - ".fpx" => "image/vnd.fpx", - ".fsc" => "application/vnd.fsc.weblaunch", - ".fst" => "image/vnd.fst", - ".ftc" => "application/vnd.fluxtime.clip", - ".fti" => "application/vnd.anser-web-funds-transfer-initiation", - ".fvt" => "video/vnd.fvt", - ".fzs" => "application/vnd.fuzzysheet", - ".g3" => "image/g3fax", - ".gac" => "application/vnd.groove-account", - ".gdl" => "model/vnd.gdl", - ".gem" => "application/octet-stream", - ".gemspec" => "text/x-script.ruby", - ".ghf" => "application/vnd.groove-help", - ".gif" => "image/gif", - ".gim" => "application/vnd.groove-identity-message", - ".gmx" => "application/vnd.gmx", - ".gph" => "application/vnd.flographit", - ".gqf" => "application/vnd.grafeq", - ".gram" => "application/srgs", - ".grv" => "application/vnd.groove-injector", - ".grxml" => "application/srgs+xml", - ".gtar" => "application/x-gtar", - ".gtm" => "application/vnd.groove-tool-message", - ".gtw" => "model/vnd.gtw", - ".gv" => "text/vnd.graphviz", - ".gz" => "application/x-gzip", - ".h" => "text/x-c", - ".h261" => "video/h261", - ".h263" => "video/h263", - ".h264" => "video/h264", - ".hbci" => "application/vnd.hbci", - ".hdf" => "application/x-hdf", - ".heic" => "image/heic", - ".heics" => "image/heic-sequence", - ".heif" => "image/heif", - ".heifs" => "image/heif-sequence", - ".hh" => "text/x-c", - ".hlp" => "application/winhlp", - ".hpgl" => "application/vnd.hp-hpgl", - ".hpid" => "application/vnd.hp-hpid", - ".hps" => "application/vnd.hp-hps", - ".hqx" => "application/mac-binhex40", - ".htc" => "text/x-component", - ".htke" => "application/vnd.kenameaapp", - ".htm" => "text/html", - ".html" => "text/html", - ".hvd" => "application/vnd.yamaha.hv-dic", - ".hvp" => "application/vnd.yamaha.hv-voice", - ".hvs" => "application/vnd.yamaha.hv-script", - ".icc" => "application/vnd.iccprofile", - ".ice" => "x-conference/x-cooltalk", - ".ico" => "image/vnd.microsoft.icon", - ".ics" => "text/calendar", - ".ief" => "image/ief", - ".ifb" => "text/calendar", - ".ifm" => "application/vnd.shana.informed.formdata", - ".igl" => "application/vnd.igloader", - ".igs" => "model/iges", - ".igx" => "application/vnd.micrografx.igx", - ".iif" => "application/vnd.shana.informed.interchange", - ".imp" => "application/vnd.accpac.simply.imp", - ".ims" => "application/vnd.ms-ims", - ".ipk" => "application/vnd.shana.informed.package", - ".irm" => "application/vnd.ibm.rights-management", - ".irp" => "application/vnd.irepository.package+xml", - ".iso" => "application/octet-stream", - ".itp" => "application/vnd.shana.informed.formtemplate", - ".ivp" => "application/vnd.immervision-ivp", - ".ivu" => "application/vnd.immervision-ivu", - ".jad" => "text/vnd.sun.j2me.app-descriptor", - ".jam" => "application/vnd.jam", - ".jar" => "application/java-archive", - ".java" => "text/x-java-source", - ".jisp" => "application/vnd.jisp", - ".jlt" => "application/vnd.hp-jlyt", - ".jnlp" => "application/x-java-jnlp-file", - ".joda" => "application/vnd.joost.joda-archive", - ".jp2" => "image/jp2", - ".jpeg" => "image/jpeg", - ".jpg" => "image/jpeg", - ".jpgv" => "video/jpeg", - ".jpm" => "video/jpm", - ".js" => "text/javascript", - ".json" => "application/json", - ".karbon" => "application/vnd.kde.karbon", - ".kfo" => "application/vnd.kde.kformula", - ".kia" => "application/vnd.kidspiration", - ".kml" => "application/vnd.google-earth.kml+xml", - ".kmz" => "application/vnd.google-earth.kmz", - ".kne" => "application/vnd.kinar", - ".kon" => "application/vnd.kde.kontour", - ".kpr" => "application/vnd.kde.kpresenter", - ".ksp" => "application/vnd.kde.kspread", - ".ktz" => "application/vnd.kahootz", - ".kwd" => "application/vnd.kde.kword", - ".latex" => "application/x-latex", - ".lbd" => "application/vnd.llamagraphics.life-balance.desktop", - ".lbe" => "application/vnd.llamagraphics.life-balance.exchange+xml", - ".les" => "application/vnd.hhe.lesson-player", - ".link66" => "application/vnd.route66.link66+xml", - ".log" => "text/plain", - ".lostxml" => "application/lost+xml", - ".lrm" => "application/vnd.ms-lrm", - ".ltf" => "application/vnd.frogans.ltf", - ".lvp" => "audio/vnd.lucent.voice", - ".lwp" => "application/vnd.lotus-wordpro", - ".m3u" => "audio/x-mpegurl", - ".m3u8" => "application/x-mpegurl", - ".m4a" => "audio/mp4a-latm", - ".m4v" => "video/mp4", - ".ma" => "application/mathematica", - ".mag" => "application/vnd.ecowin.chart", - ".man" => "text/troff", - ".manifest" => "text/cache-manifest", - ".mathml" => "application/mathml+xml", - ".mbk" => "application/vnd.mobius.mbk", - ".mbox" => "application/mbox", - ".mc1" => "application/vnd.medcalcdata", - ".mcd" => "application/vnd.mcd", - ".mdb" => "application/x-msaccess", - ".mdi" => "image/vnd.ms-modi", - ".mdoc" => "text/troff", - ".me" => "text/troff", - ".mfm" => "application/vnd.mfmp", - ".mgz" => "application/vnd.proteus.magazine", - ".mid" => "audio/midi", - ".midi" => "audio/midi", - ".mif" => "application/vnd.mif", - ".mime" => "message/rfc822", - ".mj2" => "video/mj2", - ".mjs" => "text/javascript", - ".mlp" => "application/vnd.dolby.mlp", - ".mmd" => "application/vnd.chipnuts.karaoke-mmd", - ".mmf" => "application/vnd.smaf", - ".mml" => "application/mathml+xml", - ".mmr" => "image/vnd.fujixerox.edmics-mmr", - ".mng" => "video/x-mng", - ".mny" => "application/x-msmoney", - ".mov" => "video/quicktime", - ".movie" => "video/x-sgi-movie", - ".mp3" => "audio/mpeg", - ".mp4" => "video/mp4", - ".mp4a" => "audio/mp4", - ".mp4s" => "application/mp4", - ".mp4v" => "video/mp4", - ".mpc" => "application/vnd.mophun.certificate", - ".mpd" => "application/dash+xml", - ".mpeg" => "video/mpeg", - ".mpg" => "video/mpeg", - ".mpga" => "audio/mpeg", - ".mpkg" => "application/vnd.apple.installer+xml", - ".mpm" => "application/vnd.blueice.multipass", - ".mpn" => "application/vnd.mophun.application", - ".mpp" => "application/vnd.ms-project", - ".mpy" => "application/vnd.ibm.minipay", - ".mqy" => "application/vnd.mobius.mqy", - ".mrc" => "application/marc", - ".ms" => "text/troff", - ".mscml" => "application/mediaservercontrol+xml", - ".mseq" => "application/vnd.mseq", - ".msf" => "application/vnd.epson.msf", - ".msh" => "model/mesh", - ".msi" => "application/x-msdownload", - ".msl" => "application/vnd.mobius.msl", - ".msty" => "application/vnd.muvee.style", - ".mts" => "model/vnd.mts", - ".mus" => "application/vnd.musician", - ".mvb" => "application/x-msmediaview", - ".mwf" => "application/vnd.mfer", - ".mxf" => "application/mxf", - ".mxl" => "application/vnd.recordare.musicxml", - ".mxml" => "application/xv+xml", - ".mxs" => "application/vnd.triscape.mxs", - ".mxu" => "video/vnd.mpegurl", - ".n" => "application/vnd.nokia.n-gage.symbian.install", - ".nc" => "application/x-netcdf", - ".ngdat" => "application/vnd.nokia.n-gage.data", - ".nlu" => "application/vnd.neurolanguage.nlu", - ".nml" => "application/vnd.enliven", - ".nnd" => "application/vnd.noblenet-directory", - ".nns" => "application/vnd.noblenet-sealer", - ".nnw" => "application/vnd.noblenet-web", - ".npx" => "image/vnd.net-fpx", - ".nsf" => "application/vnd.lotus-notes", - ".oa2" => "application/vnd.fujitsu.oasys2", - ".oa3" => "application/vnd.fujitsu.oasys3", - ".oas" => "application/vnd.fujitsu.oasys", - ".obd" => "application/x-msbinder", - ".oda" => "application/oda", - ".odc" => "application/vnd.oasis.opendocument.chart", - ".odf" => "application/vnd.oasis.opendocument.formula", - ".odg" => "application/vnd.oasis.opendocument.graphics", - ".odi" => "application/vnd.oasis.opendocument.image", - ".odp" => "application/vnd.oasis.opendocument.presentation", - ".ods" => "application/vnd.oasis.opendocument.spreadsheet", - ".odt" => "application/vnd.oasis.opendocument.text", - ".oga" => "audio/ogg", - ".ogg" => "application/ogg", - ".ogv" => "video/ogg", - ".ogx" => "application/ogg", - ".org" => "application/vnd.lotus-organizer", - ".otc" => "application/vnd.oasis.opendocument.chart-template", - ".otf" => "font/otf", - ".otg" => "application/vnd.oasis.opendocument.graphics-template", - ".oth" => "application/vnd.oasis.opendocument.text-web", - ".oti" => "application/vnd.oasis.opendocument.image-template", - ".otm" => "application/vnd.oasis.opendocument.text-master", - ".ots" => "application/vnd.oasis.opendocument.spreadsheet-template", - ".ott" => "application/vnd.oasis.opendocument.text-template", - ".oxt" => "application/vnd.openofficeorg.extension", - ".p" => "text/x-pascal", - ".p10" => "application/pkcs10", - ".p12" => "application/x-pkcs12", - ".p7b" => "application/x-pkcs7-certificates", - ".p7m" => "application/pkcs7-mime", - ".p7r" => "application/x-pkcs7-certreqresp", - ".p7s" => "application/pkcs7-signature", - ".pas" => "text/x-pascal", - ".pbd" => "application/vnd.powerbuilder6", - ".pbm" => "image/x-portable-bitmap", - ".pcl" => "application/vnd.hp-pcl", - ".pclxl" => "application/vnd.hp-pclxl", - ".pcx" => "image/x-pcx", - ".pdb" => "chemical/x-pdb", - ".pdf" => "application/pdf", - ".pem" => "application/x-x509-ca-cert", - ".pfr" => "application/font-tdpfr", - ".pgm" => "image/x-portable-graymap", - ".pgn" => "application/x-chess-pgn", - ".pgp" => "application/pgp-encrypted", - ".pic" => "image/x-pict", - ".pict" => "image/pict", - ".pkg" => "application/octet-stream", - ".pki" => "application/pkixcmp", - ".pkipath" => "application/pkix-pkipath", - ".pl" => "text/x-script.perl", - ".plb" => "application/vnd.3gpp.pic-bw-large", - ".plc" => "application/vnd.mobius.plc", - ".plf" => "application/vnd.pocketlearn", - ".pls" => "application/pls+xml", - ".pm" => "text/x-script.perl-module", - ".pml" => "application/vnd.ctc-posml", - ".png" => "image/png", - ".pnm" => "image/x-portable-anymap", - ".pntg" => "image/x-macpaint", - ".portpkg" => "application/vnd.macports.portpkg", - ".pot" => "application/vnd.ms-powerpoint", - ".potm" => "application/vnd.ms-powerpoint.template.macroEnabled.12", - ".potx" => "application/vnd.openxmlformats-officedocument.presentationml.template", - ".ppa" => "application/vnd.ms-powerpoint", - ".ppam" => "application/vnd.ms-powerpoint.addin.macroEnabled.12", - ".ppd" => "application/vnd.cups-ppd", - ".ppm" => "image/x-portable-pixmap", - ".pps" => "application/vnd.ms-powerpoint", - ".ppsm" => "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", - ".ppsx" => "application/vnd.openxmlformats-officedocument.presentationml.slideshow", - ".ppt" => "application/vnd.ms-powerpoint", - ".pptm" => "application/vnd.ms-powerpoint.presentation.macroEnabled.12", - ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ".prc" => "application/vnd.palm", - ".pre" => "application/vnd.lotus-freelance", - ".prf" => "application/pics-rules", - ".ps" => "application/postscript", - ".psb" => "application/vnd.3gpp.pic-bw-small", - ".psd" => "image/vnd.adobe.photoshop", - ".ptid" => "application/vnd.pvi.ptid1", - ".pub" => "application/x-mspublisher", - ".pvb" => "application/vnd.3gpp.pic-bw-var", - ".pwn" => "application/vnd.3m.post-it-notes", - ".py" => "text/x-script.python", - ".pya" => "audio/vnd.ms-playready.media.pya", - ".pyv" => "video/vnd.ms-playready.media.pyv", - ".qam" => "application/vnd.epson.quickanime", - ".qbo" => "application/vnd.intu.qbo", - ".qfx" => "application/vnd.intu.qfx", - ".qps" => "application/vnd.publishare-delta-tree", - ".qt" => "video/quicktime", - ".qtif" => "image/x-quicktime", - ".qxd" => "application/vnd.quark.quarkxpress", - ".ra" => "audio/x-pn-realaudio", - ".rake" => "text/x-script.ruby", - ".ram" => "audio/x-pn-realaudio", - ".rar" => "application/x-rar-compressed", - ".ras" => "image/x-cmu-raster", - ".rb" => "text/x-script.ruby", - ".rcprofile" => "application/vnd.ipunplugged.rcprofile", - ".rdf" => "application/rdf+xml", - ".rdz" => "application/vnd.data-vision.rdz", - ".rep" => "application/vnd.businessobjects", - ".rgb" => "image/x-rgb", - ".rif" => "application/reginfo+xml", - ".rl" => "application/resource-lists+xml", - ".rlc" => "image/vnd.fujixerox.edmics-rlc", - ".rld" => "application/resource-lists-diff+xml", - ".rm" => "application/vnd.rn-realmedia", - ".rmp" => "audio/x-pn-realaudio-plugin", - ".rms" => "application/vnd.jcp.javame.midlet-rms", - ".rnc" => "application/relax-ng-compact-syntax", - ".roff" => "text/troff", - ".rpm" => "application/x-redhat-package-manager", - ".rpss" => "application/vnd.nokia.radio-presets", - ".rpst" => "application/vnd.nokia.radio-preset", - ".rq" => "application/sparql-query", - ".rs" => "application/rls-services+xml", - ".rsd" => "application/rsd+xml", - ".rss" => "application/rss+xml", - ".rtf" => "application/rtf", - ".rtx" => "text/richtext", - ".ru" => "text/x-script.ruby", - ".s" => "text/x-asm", - ".saf" => "application/vnd.yamaha.smaf-audio", - ".sbml" => "application/sbml+xml", - ".sc" => "application/vnd.ibm.secure-container", - ".scd" => "application/x-msschedule", - ".scm" => "application/vnd.lotus-screencam", - ".scq" => "application/scvp-cv-request", - ".scs" => "application/scvp-cv-response", - ".sdkm" => "application/vnd.solent.sdkm+xml", - ".sdp" => "application/sdp", - ".see" => "application/vnd.seemail", - ".sema" => "application/vnd.sema", - ".semd" => "application/vnd.semd", - ".semf" => "application/vnd.semf", - ".setpay" => "application/set-payment-initiation", - ".setreg" => "application/set-registration-initiation", - ".sfd" => "application/vnd.hydrostatix.sof-data", - ".sfs" => "application/vnd.spotfire.sfs", - ".sgm" => "text/sgml", - ".sgml" => "text/sgml", - ".sh" => "application/x-sh", - ".shar" => "application/x-shar", - ".shf" => "application/shf+xml", - ".sig" => "application/pgp-signature", - ".sit" => "application/x-stuffit", - ".sitx" => "application/x-stuffitx", - ".skp" => "application/vnd.koan", - ".slt" => "application/vnd.epson.salt", - ".smi" => "application/smil+xml", - ".snd" => "audio/basic", - ".so" => "application/octet-stream", - ".spf" => "application/vnd.yamaha.smaf-phrase", - ".spl" => "application/x-futuresplash", - ".spot" => "text/vnd.in3d.spot", - ".spp" => "application/scvp-vp-response", - ".spq" => "application/scvp-vp-request", - ".src" => "application/x-wais-source", - ".srt" => "text/srt", - ".srx" => "application/sparql-results+xml", - ".sse" => "application/vnd.kodak-descriptor", - ".ssf" => "application/vnd.epson.ssf", - ".ssml" => "application/ssml+xml", - ".stf" => "application/vnd.wt.stf", - ".stk" => "application/hyperstudio", - ".str" => "application/vnd.pg.format", - ".sus" => "application/vnd.sus-calendar", - ".sv4cpio" => "application/x-sv4cpio", - ".sv4crc" => "application/x-sv4crc", - ".svd" => "application/vnd.svd", - ".svg" => "image/svg+xml", - ".svgz" => "image/svg+xml", - ".swf" => "application/x-shockwave-flash", - ".swi" => "application/vnd.arastra.swi", - ".t" => "text/troff", - ".tao" => "application/vnd.tao.intent-module-archive", - ".tar" => "application/x-tar", - ".tbz" => "application/x-bzip-compressed-tar", - ".tcap" => "application/vnd.3gpp2.tcap", - ".tcl" => "application/x-tcl", - ".tex" => "application/x-tex", - ".texi" => "application/x-texinfo", - ".texinfo" => "application/x-texinfo", - ".text" => "text/plain", - ".tif" => "image/tiff", - ".tiff" => "image/tiff", - ".tmo" => "application/vnd.tmobile-livetv", - ".torrent" => "application/x-bittorrent", - ".tpl" => "application/vnd.groove-tool-template", - ".tpt" => "application/vnd.trid.tpt", - ".tr" => "text/troff", - ".tra" => "application/vnd.trueapp", - ".trm" => "application/x-msterminal", - ".ts" => "video/mp2t", - ".tsv" => "text/tab-separated-values", - ".ttf" => "font/ttf", - ".twd" => "application/vnd.simtech-mindmapper", - ".txd" => "application/vnd.genomatix.tuxedo", - ".txf" => "application/vnd.mobius.txf", - ".txt" => "text/plain", - ".ufd" => "application/vnd.ufdl", - ".umj" => "application/vnd.umajin", - ".unityweb" => "application/vnd.unity", - ".uoml" => "application/vnd.uoml+xml", - ".uri" => "text/uri-list", - ".ustar" => "application/x-ustar", - ".utz" => "application/vnd.uiq.theme", - ".uu" => "text/x-uuencode", - ".vcd" => "application/x-cdlink", - ".vcf" => "text/x-vcard", - ".vcg" => "application/vnd.groove-vcard", - ".vcs" => "text/x-vcalendar", - ".vcx" => "application/vnd.vcx", - ".vis" => "application/vnd.visionary", - ".viv" => "video/vnd.vivo", - ".vrml" => "model/vrml", - ".vsd" => "application/vnd.visio", - ".vsf" => "application/vnd.vsf", - ".vtt" => "text/vtt", - ".vtu" => "model/vnd.vtu", - ".vxml" => "application/voicexml+xml", - ".war" => "application/java-archive", - ".wasm" => "application/wasm", - ".wav" => "audio/x-wav", - ".wax" => "audio/x-ms-wax", - ".wbmp" => "image/vnd.wap.wbmp", - ".wbs" => "application/vnd.criticaltools.wbs+xml", - ".wbxml" => "application/vnd.wap.wbxml", - ".webm" => "video/webm", - ".webp" => "image/webp", - ".wm" => "video/x-ms-wm", - ".wma" => "audio/x-ms-wma", - ".wmd" => "application/x-ms-wmd", - ".wmf" => "application/x-msmetafile", - ".wml" => "text/vnd.wap.wml", - ".wmlc" => "application/vnd.wap.wmlc", - ".wmls" => "text/vnd.wap.wmlscript", - ".wmlsc" => "application/vnd.wap.wmlscriptc", - ".wmv" => "video/x-ms-wmv", - ".wmx" => "video/x-ms-wmx", - ".wmz" => "application/x-ms-wmz", - ".woff" => "font/woff", - ".woff2" => "font/woff2", - ".wpd" => "application/vnd.wordperfect", - ".wpl" => "application/vnd.ms-wpl", - ".wps" => "application/vnd.ms-works", - ".wqd" => "application/vnd.wqd", - ".wri" => "application/x-mswrite", - ".wrl" => "model/vrml", - ".wsdl" => "application/wsdl+xml", - ".wspolicy" => "application/wspolicy+xml", - ".wtb" => "application/vnd.webturbo", - ".wvx" => "video/x-ms-wvx", - ".x3d" => "application/vnd.hzn-3d-crossword", - ".xar" => "application/vnd.xara", - ".xbd" => "application/vnd.fujixerox.docuworks.binder", - ".xbm" => "image/x-xbitmap", - ".xdm" => "application/vnd.syncml.dm+xml", - ".xdp" => "application/vnd.adobe.xdp+xml", - ".xdw" => "application/vnd.fujixerox.docuworks", - ".xenc" => "application/xenc+xml", - ".xer" => "application/patch-ops-error+xml", - ".xfdf" => "application/vnd.adobe.xfdf", - ".xfdl" => "application/vnd.xfdl", - ".xhtml" => "application/xhtml+xml", - ".xif" => "image/vnd.xiff", - ".xla" => "application/vnd.ms-excel", - ".xlam" => "application/vnd.ms-excel.addin.macroEnabled.12", - ".xls" => "application/vnd.ms-excel", - ".xlsb" => "application/vnd.ms-excel.sheet.binary.macroEnabled.12", - ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".xlsm" => "application/vnd.ms-excel.sheet.macroEnabled.12", - ".xlt" => "application/vnd.ms-excel", - ".xltx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.template", - ".xml" => "application/xml", - ".xo" => "application/vnd.olpc-sugar", - ".xop" => "application/xop+xml", - ".xpm" => "image/x-xpixmap", - ".xpr" => "application/vnd.is-xpr", - ".xps" => "application/vnd.ms-xpsdocument", - ".xpw" => "application/vnd.intercon.formnet", - ".xsl" => "application/xml", - ".xslt" => "application/xslt+xml", - ".xsm" => "application/vnd.syncml+xml", - ".xspf" => "application/xspf+xml", - ".xul" => "application/vnd.mozilla.xul+xml", - ".xwd" => "image/x-xwindowdump", - ".xyz" => "chemical/x-xyz", - ".yaml" => "text/yaml", - ".yml" => "text/yaml", - ".zaz" => "application/vnd.zzazz.deck+xml", - ".zip" => "application/zip", - ".zmm" => "application/vnd.handheld-entertainment+xml", - } - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock.rb deleted file mode 100644 index 5e5c457..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock.rb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -require_relative 'mock_request' diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_request.rb deleted file mode 100644 index 7c87bea..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_request.rb +++ /dev/null @@ -1,161 +0,0 @@ -# frozen_string_literal: true - -require 'uri' -require 'stringio' - -require_relative 'constants' -require_relative 'mock_response' - -module Rack - # Rack::MockRequest helps testing your Rack application without - # actually using HTTP. - # - # After performing a request on a URL with get/post/put/patch/delete, it - # returns a MockResponse with useful helper methods for effective - # testing. - # - # You can pass a hash with additional configuration to the - # get/post/put/patch/delete. - # :input:: A String or IO-like to be used as rack.input. - # :fatal:: Raise a FatalWarning if the app writes to rack.errors. - # :lint:: If true, wrap the application in a Rack::Lint. - - class MockRequest - class FatalWarning < RuntimeError - end - - class FatalWarner - def puts(warning) - raise FatalWarning, warning - end - - def write(warning) - raise FatalWarning, warning - end - - def flush - end - - def string - "" - end - end - - def initialize(app) - @app = app - end - - # Make a GET request and return a MockResponse. See #request. - def get(uri, opts = {}) request(GET, uri, opts) end - # Make a POST request and return a MockResponse. See #request. - def post(uri, opts = {}) request(POST, uri, opts) end - # Make a PUT request and return a MockResponse. See #request. - def put(uri, opts = {}) request(PUT, uri, opts) end - # Make a PATCH request and return a MockResponse. See #request. - def patch(uri, opts = {}) request(PATCH, uri, opts) end - # Make a DELETE request and return a MockResponse. See #request. - def delete(uri, opts = {}) request(DELETE, uri, opts) end - # Make a HEAD request and return a MockResponse. See #request. - def head(uri, opts = {}) request(HEAD, uri, opts) end - # Make an OPTIONS request and return a MockResponse. See #request. - def options(uri, opts = {}) request(OPTIONS, uri, opts) end - - # Make a request using the given request method for the given - # uri to the rack application and return a MockResponse. - # Options given are passed to MockRequest.env_for. - def request(method = GET, uri = "", opts = {}) - env = self.class.env_for(uri, opts.merge(method: method)) - - if opts[:lint] - app = Rack::Lint.new(@app) - else - app = @app - end - - errors = env[RACK_ERRORS] - status, headers, body = app.call(env) - MockResponse.new(status, headers, body, errors) - ensure - body.close if body.respond_to?(:close) - end - - # For historical reasons, we're pinning to RFC 2396. - # URI::Parser = URI::RFC2396_Parser - def self.parse_uri_rfc2396(uri) - @parser ||= URI::Parser.new - @parser.parse(uri) - end - - # Return the Rack environment used for a request to +uri+. - # All options that are strings are added to the returned environment. - # Options: - # :fatal :: Whether to raise an exception if request outputs to rack.errors - # :input :: The rack.input to set - # :http_version :: The SERVER_PROTOCOL to set - # :method :: The HTTP request method to use - # :params :: The params to use - # :script_name :: The SCRIPT_NAME to set - def self.env_for(uri = "", opts = {}) - uri = parse_uri_rfc2396(uri) - uri.path = "/#{uri.path}" unless uri.path[0] == ?/ - - env = {} - - env[REQUEST_METHOD] = (opts[:method] ? opts[:method].to_s.upcase : GET).b - env[SERVER_NAME] = (uri.host || "example.org").b - env[SERVER_PORT] = (uri.port ? uri.port.to_s : "80").b - env[SERVER_PROTOCOL] = opts[:http_version] || 'HTTP/1.1' - env[QUERY_STRING] = (uri.query.to_s).b - env[PATH_INFO] = (uri.path).b - env[RACK_URL_SCHEME] = (uri.scheme || "http").b - env[HTTPS] = (env[RACK_URL_SCHEME] == "https" ? "on" : "off").b - - env[SCRIPT_NAME] = opts[:script_name] || "" - - if opts[:fatal] - env[RACK_ERRORS] = FatalWarner.new - else - env[RACK_ERRORS] = StringIO.new - end - - if params = opts[:params] - if env[REQUEST_METHOD] == GET - params = Utils.parse_nested_query(params) if params.is_a?(String) - params.update(Utils.parse_nested_query(env[QUERY_STRING])) - env[QUERY_STRING] = Utils.build_nested_query(params) - elsif !opts.has_key?(:input) - opts["CONTENT_TYPE"] = "application/x-www-form-urlencoded" - if params.is_a?(Hash) - if data = Rack::Multipart.build_multipart(params) - opts[:input] = data - opts["CONTENT_LENGTH"] ||= data.length.to_s - opts["CONTENT_TYPE"] = "multipart/form-data; boundary=#{Rack::Multipart::MULTIPART_BOUNDARY}" - else - opts[:input] = Utils.build_nested_query(params) - end - else - opts[:input] = params - end - end - end - - rack_input = opts[:input] - if String === rack_input - rack_input = StringIO.new(rack_input) - end - - if rack_input - rack_input.set_encoding(Encoding::BINARY) if rack_input.respond_to?(:set_encoding) - env[RACK_INPUT] = rack_input - - env["CONTENT_LENGTH"] ||= env[RACK_INPUT].size.to_s if env[RACK_INPUT].respond_to?(:size) - end - - opts.each { |field, value| - env[field] = value if String === field - } - - env - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_response.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_response.rb deleted file mode 100644 index 9af8079..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/mock_response.rb +++ /dev/null @@ -1,124 +0,0 @@ -# frozen_string_literal: true - -require 'cgi/cookie' -require 'time' - -require_relative 'response' - -module Rack - # Rack::MockResponse provides useful helpers for testing your apps. - # Usually, you don't create the MockResponse on your own, but use - # MockRequest. - - class MockResponse < Rack::Response - class << self - alias [] new - end - - # Headers - attr_reader :original_headers, :cookies - - # Errors - attr_accessor :errors - - def initialize(status, headers, body, errors = nil) - @original_headers = headers - - if errors - @errors = errors.string if errors.respond_to?(:string) - else - @errors = "" - end - - super(body, status, headers) - - @cookies = parse_cookies_from_header - buffered_body! - end - - def =~(other) - body =~ other - end - - def match(other) - body.match other - end - - def body - return @buffered_body if defined?(@buffered_body) - - # FIXME: apparently users of MockResponse expect the return value of - # MockResponse#body to be a string. However, the real response object - # returns the body as a list. - # - # See spec_showstatus.rb: - # - # should "not replace existing messages" do - # ... - # res.body.should == "foo!" - # end - buffer = @buffered_body = String.new - - @body.each do |chunk| - buffer << chunk - end - - return buffer - end - - def empty? - [201, 204, 304].include? status - end - - def cookie(name) - cookies.fetch(name, nil) - end - - private - - def parse_cookies_from_header - cookies = Hash.new - set_cookie_header = headers['set-cookie'] - if set_cookie_header && !set_cookie_header.empty? - Array(set_cookie_header).each do |cookie| - cookie_name, cookie_filling = cookie.split('=', 2) - cookie_attributes = identify_cookie_attributes cookie_filling - parsed_cookie = CGI::Cookie.new( - 'name' => cookie_name.strip, - 'value' => cookie_attributes.fetch('value'), - 'path' => cookie_attributes.fetch('path', nil), - 'domain' => cookie_attributes.fetch('domain', nil), - 'expires' => cookie_attributes.fetch('expires', nil), - 'secure' => cookie_attributes.fetch('secure', false) - ) - cookies.store(cookie_name, parsed_cookie) - end - end - cookies - end - - def identify_cookie_attributes(cookie_filling) - cookie_bits = cookie_filling.split(';') - cookie_attributes = Hash.new - cookie_attributes.store('value', cookie_bits[0].strip) - cookie_bits.drop(1).each do |bit| - if bit.include? '=' - cookie_attribute, attribute_value = bit.split('=', 2) - cookie_attributes.store(cookie_attribute.strip.downcase, attribute_value.strip) - end - if bit.include? 'secure' - cookie_attributes.store('secure', true) - end - end - - if cookie_attributes.key? 'max-age' - cookie_attributes.store('expires', Time.now + cookie_attributes['max-age'].to_i) - elsif cookie_attributes.key? 'expires' - cookie_attributes.store('expires', Time.httpdate(cookie_attributes['expires'])) - end - - cookie_attributes - end - - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart.rb deleted file mode 100644 index 4b02fb3..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' - -require_relative 'multipart/parser' -require_relative 'multipart/generator' - -require_relative 'bad_request' - -module Rack - # A multipart form data parser, adapted from IOWA. - # - # Usually, Rack::Request#POST takes care of calling this. - module Multipart - MULTIPART_BOUNDARY = "AaB03x" - - class MissingInputError < StandardError - include BadRequest - end - - # Accumulator for multipart form data, conforming to the QueryParser API. - # In future, the Parser could return the pair list directly, but that would - # change its API. - class ParamList # :nodoc: - def self.make_params - new - end - - def self.normalize_params(params, key, value) - params << [key, value] - end - - def initialize - @pairs = [] - end - - def <<(pair) - @pairs << pair - end - - def to_params_hash - @pairs - end - end - - class << self - def parse_multipart(env, params = Rack::Utils.default_query_parser) - unless io = env[RACK_INPUT] - raise MissingInputError, "Missing input stream!" - end - - if content_length = env['CONTENT_LENGTH'] - content_length = content_length.to_i - end - - content_type = env['CONTENT_TYPE'] - - tempfile = env[RACK_MULTIPART_TEMPFILE_FACTORY] || Parser::TEMPFILE_FACTORY - bufsize = env[RACK_MULTIPART_BUFFER_SIZE] || Parser::BUFSIZE - - info = Parser.parse(io, content_length, content_type, tempfile, bufsize, params) - env[RACK_TEMPFILES] = info.tmp_files - - return info.params - end - - def extract_multipart(request, params = Rack::Utils.default_query_parser) - parse_multipart(request.env) - end - - def build_multipart(params, first = true) - Generator.new(params, first).dump - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb deleted file mode 100644 index 30d7f51..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/generator.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -require_relative 'uploaded_file' - -module Rack - module Multipart - class Generator - def initialize(params, first = true) - @params, @first = params, first - - if @first && !@params.is_a?(Hash) - raise ArgumentError, "value must be a Hash" - end - end - - def dump - return nil if @first && !multipart? - return flattened_params unless @first - - flattened_params.map do |name, file| - if file.respond_to?(:original_filename) - if file.path - ::File.open(file.path, 'rb') do |f| - f.set_encoding(Encoding::BINARY) - content_for_tempfile(f, file, name) - end - else - content_for_tempfile(file, file, name) - end - else - content_for_other(file, name) - end - end.join << "--#{MULTIPART_BOUNDARY}--\r" - end - - private - def multipart? - query = lambda { |value| - case value - when Array - value.any?(&query) - when Hash - value.values.any?(&query) - when Rack::Multipart::UploadedFile - true - end - } - - @params.values.any?(&query) - end - - def flattened_params - @flattened_params ||= begin - h = Hash.new - @params.each do |key, value| - k = @first ? key.to_s : "[#{key}]" - - case value - when Array - value.map { |v| - Multipart.build_multipart(v, false).each { |subkey, subvalue| - h["#{k}[]#{subkey}"] = subvalue - } - } - when Hash - Multipart.build_multipart(value, false).each { |subkey, subvalue| - h[k + subkey] = subvalue - } - else - h[k] = value - end - end - h - end - end - - def content_for_tempfile(io, file, name) - length = ::File.stat(file.path).size if file.path - filename = "; filename=\"#{Utils.escape_path(file.original_filename)}\"" -<<-EOF ---#{MULTIPART_BOUNDARY}\r -content-disposition: form-data; name="#{name}"#{filename}\r -content-type: #{file.content_type}\r -#{"content-length: #{length}\r\n" if length}\r -#{io.read}\r -EOF - end - - def content_for_other(file, name) -<<-EOF ---#{MULTIPART_BOUNDARY}\r -content-disposition: form-data; name="#{name}"\r -\r -#{file}\r -EOF - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb deleted file mode 100644 index 3960b37..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/parser.rb +++ /dev/null @@ -1,502 +0,0 @@ -# frozen_string_literal: true - -require 'strscan' - -require_relative '../utils' -require_relative '../bad_request' - -module Rack - module Multipart - class MultipartPartLimitError < Errno::EMFILE - include BadRequest - end - - class MultipartTotalPartLimitError < StandardError - include BadRequest - end - - # Use specific error class when parsing multipart request - # that ends early. - class EmptyContentError < ::EOFError - include BadRequest - end - - # Base class for multipart exceptions that do not subclass from - # other exception classes for backwards compatibility. - class BoundaryTooLongError < StandardError - include BadRequest - end - - # Prefer to use the BoundaryTooLongError class or Rack::BadRequest. - Error = BoundaryTooLongError - - EOL = "\r\n" - MULTIPART = %r|\Amultipart/.*boundary=\"?([^\";,]+)\"?|ni - MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{EOL}/ni - MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:(.*)(?=#{EOL}(\S|\z))/ni - MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{EOL}]*)/ni - - class Parser - BUFSIZE = 1_048_576 - TEXT_PLAIN = "text/plain" - TEMPFILE_FACTORY = lambda { |filename, content_type| - extension = ::File.extname(filename.gsub("\0", '%00'))[0, 129] - - Tempfile.new(["RackMultipart", extension]) - } - - class BoundedIO # :nodoc: - def initialize(io, content_length) - @io = io - @content_length = content_length - @cursor = 0 - end - - def read(size, outbuf = nil) - return if @cursor >= @content_length - - left = @content_length - @cursor - - str = if left < size - @io.read left, outbuf - else - @io.read size, outbuf - end - - if str - @cursor += str.bytesize - else - # Raise an error for mismatching content-length and actual contents - raise EOFError, "bad content body" - end - - str - end - end - - MultipartInfo = Struct.new :params, :tmp_files - EMPTY = MultipartInfo.new(nil, []) - - def self.parse_boundary(content_type) - return unless content_type - data = content_type.match(MULTIPART) - return unless data - data[1] - end - - def self.parse(io, content_length, content_type, tmpfile, bufsize, qp) - return EMPTY if 0 == content_length - - boundary = parse_boundary content_type - return EMPTY unless boundary - - if boundary.length > 70 - # RFC 1521 Section 7.2.1 imposes a 70 character maximum for the boundary. - # Most clients use no more than 55 characters. - raise BoundaryTooLongError, "multipart boundary size too large (#{boundary.length} characters)" - end - - io = BoundedIO.new(io, content_length) if content_length - - parser = new(boundary, tmpfile, bufsize, qp) - parser.parse(io) - - parser.result - end - - class Collector - class MimePart < Struct.new(:body, :head, :filename, :content_type, :name) - def get_data - data = body - if filename == "" - # filename is blank which means no file has been selected - return - elsif filename - body.rewind if body.respond_to?(:rewind) - - # Take the basename of the upload's original filename. - # This handles the full Windows paths given by Internet Explorer - # (and perhaps other broken user agents) without affecting - # those which give the lone filename. - fn = filename.split(/[\/\\]/).last - - data = { filename: fn, type: content_type, - name: name, tempfile: body, head: head } - end - - yield data - end - end - - class BufferPart < MimePart - def file?; false; end - def close; end - end - - class TempfilePart < MimePart - def file?; true; end - def close; body.close; end - end - - include Enumerable - - def initialize(tempfile) - @tempfile = tempfile - @mime_parts = [] - @open_files = 0 - end - - def each - @mime_parts.each { |part| yield part } - end - - def on_mime_head(mime_index, head, filename, content_type, name) - if filename - body = @tempfile.call(filename, content_type) - body.binmode if body.respond_to?(:binmode) - klass = TempfilePart - @open_files += 1 - else - body = String.new - klass = BufferPart - end - - @mime_parts[mime_index] = klass.new(body, head, filename, content_type, name) - - check_part_limits - end - - def on_mime_body(mime_index, content) - @mime_parts[mime_index].body << content - end - - def on_mime_finish(mime_index) - end - - private - - def check_part_limits - file_limit = Utils.multipart_file_limit - part_limit = Utils.multipart_total_part_limit - - if file_limit && file_limit > 0 - if @open_files >= file_limit - @mime_parts.each(&:close) - raise MultipartPartLimitError, 'Maximum file multiparts in content reached' - end - end - - if part_limit && part_limit > 0 - if @mime_parts.size >= part_limit - @mime_parts.each(&:close) - raise MultipartTotalPartLimitError, 'Maximum total multiparts in content reached' - end - end - end - end - - attr_reader :state - - def initialize(boundary, tempfile, bufsize, query_parser) - @query_parser = query_parser - @params = query_parser.make_params - @bufsize = bufsize - - @state = :FAST_FORWARD - @mime_index = 0 - @collector = Collector.new tempfile - - @sbuf = StringScanner.new("".dup) - @body_regex = /(?:#{EOL}|\A)--#{Regexp.quote(boundary)}(?:#{EOL}|--)/m - @body_regex_at_end = /#{@body_regex}\z/m - @end_boundary_size = boundary.bytesize + 4 # (-- at start, -- at finish) - @rx_max_size = boundary.bytesize + 6 # (\r\n-- at start, either \r\n or -- at finish) - @head_regex = /(.*?#{EOL})#{EOL}/m - end - - def parse(io) - outbuf = String.new - read_data(io, outbuf) - - loop do - status = - case @state - when :FAST_FORWARD - handle_fast_forward - when :CONSUME_TOKEN - handle_consume_token - when :MIME_HEAD - handle_mime_head - when :MIME_BODY - handle_mime_body - else # when :DONE - return - end - - read_data(io, outbuf) if status == :want_read - end - end - - def result - @collector.each do |part| - part.get_data do |data| - tag_multipart_encoding(part.filename, part.content_type, part.name, data) - @query_parser.normalize_params(@params, part.name, data) - end - end - MultipartInfo.new @params.to_params_hash, @collector.find_all(&:file?).map(&:body) - end - - private - - def dequote(str) # From WEBrick::HTTPUtils - ret = (/\A"(.*)"\Z/ =~ str) ? $1 : str.dup - ret.gsub!(/\\(.)/, "\\1") - ret - end - - def read_data(io, outbuf) - content = io.read(@bufsize, outbuf) - handle_empty_content!(content) - @sbuf.concat(content) - end - - # This handles the initial parser state. We read until we find the starting - # boundary, then we can transition to the next state. If we find the ending - # boundary, this is an invalid multipart upload, but keep scanning for opening - # boundary in that case. If no boundary found, we need to keep reading data - # and retry. It's highly unlikely the initial read will not consume the - # boundary. The client would have to deliberately craft a response - # with the opening boundary beyond the buffer size for that to happen. - def handle_fast_forward - while true - case consume_boundary - when :BOUNDARY - # found opening boundary, transition to next state - @state = :MIME_HEAD - return - when :END_BOUNDARY - # invalid multipart upload - if @sbuf.pos == @end_boundary_size && @sbuf.rest == EOL - # stop parsing a buffer if a buffer is only an end boundary. - @state = :DONE - return - end - - # retry for opening boundary - else - # no boundary found, keep reading data - return :want_read - end - end - end - - def handle_consume_token - tok = consume_boundary - # break if we're at the end of a buffer, but not if it is the end of a field - @state = if tok == :END_BOUNDARY || (@sbuf.eos? && tok != :BOUNDARY) - :DONE - else - :MIME_HEAD - end - end - - CONTENT_DISPOSITION_MAX_PARAMS = 16 - CONTENT_DISPOSITION_MAX_BYTES = 1536 - def handle_mime_head - if @sbuf.scan_until(@head_regex) - head = @sbuf[1] - content_type = head[MULTIPART_CONTENT_TYPE, 1] - if (disposition = head[MULTIPART_CONTENT_DISPOSITION, 1]) && - disposition.bytesize <= CONTENT_DISPOSITION_MAX_BYTES - - # ignore actual content-disposition value (should always be form-data) - i = disposition.index(';') - disposition.slice!(0, i+1) - param = nil - num_params = 0 - - # Parse parameter list - while i = disposition.index('=') - # Only parse up to max parameters, to avoid potential denial of service - num_params += 1 - break if num_params > CONTENT_DISPOSITION_MAX_PARAMS - - # Found end of parameter name, ensure forward progress in loop - param = disposition.slice!(0, i+1) - - # Remove ending equals and preceding whitespace from parameter name - param.chomp!('=') - param.lstrip! - - if disposition[0] == '"' - # Parameter value is quoted, parse it, handling backslash escapes - disposition.slice!(0, 1) - value = String.new - - while i = disposition.index(/(["\\])/) - c = $1 - - # Append all content until ending quote or escape - value << disposition.slice!(0, i) - - # Remove either backslash or ending quote, - # ensures forward progress in loop - disposition.slice!(0, 1) - - # stop parsing parameter value if found ending quote - break if c == '"' - - escaped_char = disposition.slice!(0, 1) - if param == 'filename' && escaped_char != '"' - # Possible IE uploaded filename, append both escape backslash and value - value << c << escaped_char - else - # Other only append escaped value - value << escaped_char - end - end - else - if i = disposition.index(';') - # Parameter value unquoted (which may be invalid), value ends at semicolon - value = disposition.slice!(0, i) - else - # If no ending semicolon, assume remainder of line is value and stop - # parsing - disposition.strip! - value = disposition - disposition = '' - end - end - - case param - when 'name' - name = value - when 'filename' - filename = value - when 'filename*' - filename_star = value - # else - # ignore other parameters - end - - # skip trailing semicolon, to proceed to next parameter - if i = disposition.index(';') - disposition.slice!(0, i+1) - end - end - else - name = head[MULTIPART_CONTENT_ID, 1] - end - - if filename_star - encoding, _, filename = filename_star.split("'", 3) - filename = normalize_filename(filename || '') - filename.force_encoding(find_encoding(encoding)) - elsif filename - filename = normalize_filename(filename) - end - - if name.nil? || name.empty? - name = filename || "#{content_type || TEXT_PLAIN}[]".dup - end - - @collector.on_mime_head @mime_index, head, filename, content_type, name - @state = :MIME_BODY - else - :want_read - end - end - - def handle_mime_body - if (body_with_boundary = @sbuf.check_until(@body_regex)) # check but do not advance the pointer yet - body = body_with_boundary.sub(@body_regex_at_end, '') # remove the boundary from the string - @collector.on_mime_body @mime_index, body - @sbuf.pos += body.length + 2 # skip \r\n after the content - @state = :CONSUME_TOKEN - @mime_index += 1 - else - # Save what we have so far - if @rx_max_size < @sbuf.rest_size - delta = @sbuf.rest_size - @rx_max_size - @collector.on_mime_body @mime_index, @sbuf.peek(delta) - @sbuf.pos += delta - @sbuf.string = @sbuf.rest - end - :want_read - end - end - - # Scan until the we find the start or end of the boundary. - # If we find it, return the appropriate symbol for the start or - # end of the boundary. If we don't find the start or end of the - # boundary, clear the buffer and return nil. - def consume_boundary - if read_buffer = @sbuf.scan_until(@body_regex) - read_buffer.end_with?(EOL) ? :BOUNDARY : :END_BOUNDARY - else - @sbuf.terminate - nil - end - end - - def normalize_filename(filename) - if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) } - filename = Utils.unescape_path(filename) - end - - filename.scrub! - - filename.split(/[\/\\]/).last || String.new - end - - CHARSET = "charset" - deprecate_constant :CHARSET - - def tag_multipart_encoding(filename, content_type, name, body) - name = name.to_s - encoding = Encoding::UTF_8 - - name.force_encoding(encoding) - - return if filename - - if content_type - list = content_type.split(';') - type_subtype = list.first - type_subtype.strip! - if TEXT_PLAIN == type_subtype - rest = list.drop 1 - rest.each do |param| - k, v = param.split('=', 2) - k.strip! - v.strip! - v = v[1..-2] if v.start_with?('"') && v.end_with?('"') - if k == "charset" - encoding = find_encoding(v) - end - end - end - end - - name.force_encoding(encoding) - body.force_encoding(encoding) - end - - # Return the related Encoding object. However, because - # enc is submitted by the user, it may be invalid, so - # use a binary encoding in that case. - def find_encoding(enc) - Encoding.find enc - rescue ArgumentError - Encoding::BINARY - end - - def handle_empty_content!(content) - if content.nil? || content.empty? - raise EmptyContentError - end - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb deleted file mode 100644 index 2782e44..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -require 'tempfile' -require 'fileutils' - -module Rack - module Multipart - class UploadedFile - - # The filename, *not* including the path, of the "uploaded" file - attr_reader :original_filename - - # The content type of the "uploaded" file - attr_accessor :content_type - - def initialize(filepath = nil, ct = "text/plain", bin = false, - path: filepath, content_type: ct, binary: bin, filename: nil, io: nil) - if io - @tempfile = io - @original_filename = filename - else - raise "#{path} file does not exist" unless ::File.exist?(path) - @original_filename = filename || ::File.basename(path) - @tempfile = Tempfile.new([@original_filename, ::File.extname(path)], encoding: Encoding::BINARY) - @tempfile.binmode if binary - FileUtils.copy_file(path, @tempfile.path) - end - @content_type = content_type - end - - def path - @tempfile.path if @tempfile.respond_to?(:path) - end - alias_method :local_path, :path - - def respond_to?(*args) - super or @tempfile.respond_to?(*args) - end - - def method_missing(method_name, *args, &block) #:nodoc: - @tempfile.__send__(method_name, *args, &block) - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/null_logger.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/null_logger.rb deleted file mode 100644 index 52fc125..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/null_logger.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' - -module Rack - class NullLogger - def initialize(app) - @app = app - end - - def call(env) - env[RACK_LOGGER] = self - @app.call(env) - end - - def info(progname = nil, &block); end - def debug(progname = nil, &block); end - def warn(progname = nil, &block); end - def error(progname = nil, &block); end - def fatal(progname = nil, &block); end - def unknown(progname = nil, &block); end - def info? ; end - def debug? ; end - def warn? ; end - def error? ; end - def fatal? ; end - def debug! ; end - def error! ; end - def fatal! ; end - def info! ; end - def warn! ; end - def level ; end - def progname ; end - def datetime_format ; end - def formatter ; end - def sev_threshold ; end - def level=(level); end - def progname=(progname); end - def datetime_format=(datetime_format); end - def formatter=(formatter); end - def sev_threshold=(sev_threshold); end - def close ; end - def add(severity, message = nil, progname = nil, &block); end - def log(severity, message = nil, progname = nil, &block); end - def <<(msg); end - def reopen(logdev = nil); end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/query_parser.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/query_parser.rb deleted file mode 100644 index 28cbce1..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/query_parser.rb +++ /dev/null @@ -1,200 +0,0 @@ -# frozen_string_literal: true - -require_relative 'bad_request' -require 'uri' - -module Rack - class QueryParser - DEFAULT_SEP = /& */n - COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n } - - # ParameterTypeError is the error that is raised when incoming structural - # parameters (parsed by parse_nested_query) contain conflicting types. - class ParameterTypeError < TypeError - include BadRequest - end - - # InvalidParameterError is the error that is raised when incoming structural - # parameters (parsed by parse_nested_query) contain invalid format or byte - # sequence. - class InvalidParameterError < ArgumentError - include BadRequest - end - - # ParamsTooDeepError is the error that is raised when params are recursively - # nested over the specified limit. - class ParamsTooDeepError < RangeError - include BadRequest - end - - def self.make_default(param_depth_limit) - new Params, param_depth_limit - end - - attr_reader :param_depth_limit - - def initialize(params_class, param_depth_limit) - @params_class = params_class - @param_depth_limit = param_depth_limit - end - - # Stolen from Mongrel, with some small modifications: - # Parses a query string by breaking it up at the '&'. You can also use this - # to parse cookies by changing the characters used in the second parameter - # (which defaults to '&'). - def parse_query(qs, separator = nil, &unescaper) - unescaper ||= method(:unescape) - - params = make_params - - (qs || '').split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p| - next if p.empty? - k, v = p.split('=', 2).map!(&unescaper) - - if cur = params[k] - if cur.class == Array - params[k] << v - else - params[k] = [cur, v] - end - else - params[k] = v - end - end - - return params.to_h - end - - # parse_nested_query expands a query string into structural types. Supported - # types are Arrays, Hashes and basic value types. It is possible to supply - # query strings with parameters of conflicting types, in this case a - # ParameterTypeError is raised. Users are encouraged to return a 400 in this - # case. - def parse_nested_query(qs, separator = nil) - params = make_params - - unless qs.nil? || qs.empty? - (qs || '').split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p| - k, v = p.split('=', 2).map! { |s| unescape(s) } - - _normalize_params(params, k, v, 0) - end - end - - return params.to_h - rescue ArgumentError => e - raise InvalidParameterError, e.message, e.backtrace - end - - # normalize_params recursively expands parameters into structural types. If - # the structural types represented by two different parameter names are in - # conflict, a ParameterTypeError is raised. The depth argument is deprecated - # and should no longer be used, it is kept for backwards compatibility with - # earlier versions of rack. - def normalize_params(params, name, v, _depth=nil) - _normalize_params(params, name, v, 0) - end - - private def _normalize_params(params, name, v, depth) - raise ParamsTooDeepError if depth >= param_depth_limit - - if !name - # nil name, treat same as empty string (required by tests) - k = after = '' - elsif depth == 0 - # Start of parsing, don't treat [] or [ at start of string specially - if start = name.index('[', 1) - # Start of parameter nesting, use part before brackets as key - k = name[0, start] - after = name[start, name.length] - else - # Plain parameter with no nesting - k = name - after = '' - end - elsif name.start_with?('[]') - # Array nesting - k = '[]' - after = name[2, name.length] - elsif name.start_with?('[') && (start = name.index(']', 1)) - # Hash nesting, use the part inside brackets as the key - k = name[1, start-1] - after = name[start+1, name.length] - else - # Probably malformed input, nested but not starting with [ - # treat full name as key for backwards compatibility. - k = name - after = '' - end - - return if k.empty? - - if after == '' - if k == '[]' && depth != 0 - return [v] - else - params[k] = v - end - elsif after == "[" - params[name] = v - elsif after == "[]" - params[k] ||= [] - raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) - params[k] << v - elsif after.start_with?('[]') - # Recognize x[][y] (hash inside array) parameters - unless after[2] == '[' && after.end_with?(']') && (child_key = after[3, after.length-4]) && !child_key.empty? && !child_key.index('[') && !child_key.index(']') - # Handle other nested array parameters - child_key = after[2, after.length] - end - params[k] ||= [] - raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) - if params_hash_type?(params[k].last) && !params_hash_has_key?(params[k].last, child_key) - _normalize_params(params[k].last, child_key, v, depth + 1) - else - params[k] << _normalize_params(make_params, child_key, v, depth + 1) - end - else - params[k] ||= make_params - raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k]) - params[k] = _normalize_params(params[k], after, v, depth + 1) - end - - params - end - - def make_params - @params_class.new - end - - def new_depth_limit(param_depth_limit) - self.class.new @params_class, param_depth_limit - end - - private - - def params_hash_type?(obj) - obj.kind_of?(@params_class) - end - - def params_hash_has_key?(hash, key) - return false if /\[\]/.match?(key) - - key.split(/[\[\]]+/).inject(hash) do |h, part| - next h if part == '' - return false unless params_hash_type?(h) && h.key?(part) - h[part] - end - - true - end - - def unescape(string, encoding = Encoding::UTF_8) - URI.decode_www_form_component(string, encoding) - end - - class Params < Hash - alias_method :to_params_hash, :to_h - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/recursive.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/recursive.rb deleted file mode 100644 index 0945d32..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/recursive.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -require 'uri' - -require_relative 'constants' - -module Rack - # Rack::ForwardRequest gets caught by Rack::Recursive and redirects - # the current request to the app at +url+. - # - # raise ForwardRequest.new("/not-found") - # - - class ForwardRequest < Exception - attr_reader :url, :env - - def initialize(url, env = {}) - @url = URI(url) - @env = env - - @env[PATH_INFO] = @url.path - @env[QUERY_STRING] = @url.query if @url.query - @env[HTTP_HOST] = @url.host if @url.host - @env[HTTP_PORT] = @url.port if @url.port - @env[RACK_URL_SCHEME] = @url.scheme if @url.scheme - - super "forwarding to #{url}" - end - end - - # Rack::Recursive allows applications called down the chain to - # include data from other applications (by using - # rack['rack.recursive.include'][...] or raise a - # ForwardRequest to redirect internally. - - class Recursive - def initialize(app) - @app = app - end - - def call(env) - dup._call(env) - end - - def _call(env) - @script_name = env[SCRIPT_NAME] - @app.call(env.merge(RACK_RECURSIVE_INCLUDE => method(:include))) - rescue ForwardRequest => req - call(env.merge(req.env)) - end - - def include(env, path) - unless path.index(@script_name) == 0 && (path[@script_name.size] == ?/ || - path[@script_name.size].nil?) - raise ArgumentError, "can only include below #{@script_name}, not #{path}" - end - - env = env.merge(PATH_INFO => path, - SCRIPT_NAME => @script_name, - REQUEST_METHOD => GET, - "CONTENT_LENGTH" => "0", "CONTENT_TYPE" => "", - RACK_INPUT => StringIO.new("")) - @app.call(env) - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/reloader.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/reloader.rb deleted file mode 100644 index a15064a..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/reloader.rb +++ /dev/null @@ -1,112 +0,0 @@ -# frozen_string_literal: true - -# Copyright (C) 2009-2018 Michael Fellinger -# Rack::Reloader is subject to the terms of an MIT-style license. -# See MIT-LICENSE or https://opensource.org/licenses/MIT. - -require 'pathname' - -module Rack - - # High performant source reloader - # - # This class acts as Rack middleware. - # - # What makes it especially suited for use in a production environment is that - # any file will only be checked once and there will only be made one system - # call stat(2). - # - # Please note that this will not reload files in the background, it does so - # only when actively called. - # - # It is performing a check/reload cycle at the start of every request, but - # also respects a cool down time, during which nothing will be done. - class Reloader - def initialize(app, cooldown = 10, backend = Stat) - @app = app - @cooldown = cooldown - @last = (Time.now - cooldown) - @cache = {} - @mtimes = {} - @reload_mutex = Mutex.new - - extend backend - end - - def call(env) - if @cooldown and Time.now > @last + @cooldown - if Thread.list.size > 1 - @reload_mutex.synchronize{ reload! } - else - reload! - end - - @last = Time.now - end - - @app.call(env) - end - - def reload!(stderr = $stderr) - rotation do |file, mtime| - previous_mtime = @mtimes[file] ||= mtime - safe_load(file, mtime, stderr) if mtime > previous_mtime - end - end - - # A safe Kernel::load, issuing the hooks depending on the results - def safe_load(file, mtime, stderr = $stderr) - load(file) - stderr.puts "#{self.class}: reloaded `#{file}'" - file - rescue LoadError, SyntaxError => ex - stderr.puts ex - ensure - @mtimes[file] = mtime - end - - module Stat - def rotation - files = [$0, *$LOADED_FEATURES].uniq - paths = ['./', *$LOAD_PATH].uniq - - files.map{|file| - next if /\.(so|bundle)$/.match?(file) # cannot reload compiled files - - found, stat = figure_path(file, paths) - next unless found && stat && mtime = stat.mtime - - @cache[file] = found - - yield(found, mtime) - }.compact - end - - # Takes a relative or absolute +file+ name, a couple possible +paths+ that - # the +file+ might reside in. Returns the full path and File::Stat for the - # path. - def figure_path(file, paths) - found = @cache[file] - found = file if !found and Pathname.new(file).absolute? - found, stat = safe_stat(found) - return found, stat if found - - paths.find do |possible_path| - path = ::File.join(possible_path, file) - found, stat = safe_stat(path) - return ::File.expand_path(found), stat if found - end - - return false, false - end - - def safe_stat(file) - return unless file - stat = ::File.stat(file) - return file, stat if stat.file? - rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ESRCH - @cache.delete(file) and false - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/request.rb deleted file mode 100644 index 93526a0..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/request.rb +++ /dev/null @@ -1,796 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' -require_relative 'media_type' - -module Rack - # Rack::Request provides a convenient interface to a Rack - # environment. It is stateless, the environment +env+ passed to the - # constructor will be directly modified. - # - # req = Rack::Request.new(env) - # req.post? - # req.params["data"] - - class Request - class << self - attr_accessor :ip_filter - - # The priority when checking forwarded headers. The default - # is [:forwarded, :x_forwarded], which means, check the - # +Forwarded+ header first, followed by the appropriate - # X-Forwarded-* header. You can revert the priority by - # reversing the priority, or remove checking of either - # or both headers by removing elements from the array. - # - # This should be set as appropriate in your environment - # based on what reverse proxies are in use. If you are not - # using reverse proxies, you should probably use an empty - # array. - attr_accessor :forwarded_priority - - # The priority when checking either the X-Forwarded-Proto - # or X-Forwarded-Scheme header for the forwarded protocol. - # The default is [:proto, :scheme], to try the - # X-Forwarded-Proto header before the - # X-Forwarded-Scheme header. Rack 2 had behavior - # similar to [:scheme, :proto]. You can remove either or - # both of the entries in array to ignore that respective header. - attr_accessor :x_forwarded_proto_priority - end - - @forwarded_priority = [:forwarded, :x_forwarded] - @x_forwarded_proto_priority = [:proto, :scheme] - - valid_ipv4_octet = /\.(25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])/ - - trusted_proxies = Regexp.union( - /\A127#{valid_ipv4_octet}{3}\z/, # localhost IPv4 range 127.x.x.x, per RFC-3330 - /\A::1\z/, # localhost IPv6 ::1 - /\Af[cd][0-9a-f]{2}(?::[0-9a-f]{0,4}){0,7}\z/i, # private IPv6 range fc00 .. fdff - /\A10#{valid_ipv4_octet}{3}\z/, # private IPv4 range 10.x.x.x - /\A172\.(1[6-9]|2[0-9]|3[01])#{valid_ipv4_octet}{2}\z/, # private IPv4 range 172.16.0.0 .. 172.31.255.255 - /\A192\.168#{valid_ipv4_octet}{2}\z/, # private IPv4 range 192.168.x.x - /\Alocalhost\z|\Aunix(\z|:)/i, # localhost hostname, and unix domain sockets - ) - - self.ip_filter = lambda { |ip| trusted_proxies.match?(ip) } - - ALLOWED_SCHEMES = %w(https http wss ws).freeze - - def initialize(env) - @env = env - @params = nil - end - - def params - @params ||= super - end - - def update_param(k, v) - super - @params = nil - end - - def delete_param(k) - v = super - @params = nil - v - end - - module Env - # The environment of the request. - attr_reader :env - - def initialize(env) - @env = env - # This module is included at least in `ActionDispatch::Request` - # The call to `super()` allows additional mixed-in initializers are called - super() - end - - # Predicate method to test to see if `name` has been set as request - # specific data - def has_header?(name) - @env.key? name - end - - # Get a request specific value for `name`. - def get_header(name) - @env[name] - end - - # If a block is given, it yields to the block if the value hasn't been set - # on the request. - def fetch_header(name, &block) - @env.fetch(name, &block) - end - - # Loops through each key / value pair in the request specific data. - def each_header(&block) - @env.each(&block) - end - - # Set a request specific value for `name` to `v` - def set_header(name, v) - @env[name] = v - end - - # Add a header that may have multiple values. - # - # Example: - # request.add_header 'Accept', 'image/png' - # request.add_header 'Accept', '*/*' - # - # assert_equal 'image/png,*/*', request.get_header('Accept') - # - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 - def add_header(key, v) - if v.nil? - get_header key - elsif has_header? key - set_header key, "#{get_header key},#{v}" - else - set_header key, v - end - end - - # Delete a request specific value for `name`. - def delete_header(name) - @env.delete name - end - - def initialize_copy(other) - @env = other.env.dup - end - end - - module Helpers - # The set of form-data media-types. Requests that do not indicate - # one of the media types present in this list will not be eligible - # for form-data / param parsing. - FORM_DATA_MEDIA_TYPES = [ - 'application/x-www-form-urlencoded', - 'multipart/form-data' - ] - - # The set of media-types. Requests that do not indicate - # one of the media types present in this list will not be eligible - # for param parsing like soap attachments or generic multiparts - PARSEABLE_DATA_MEDIA_TYPES = [ - 'multipart/related', - 'multipart/mixed' - ] - - # Default ports depending on scheme. Used to decide whether or not - # to include the port in a generated URI. - DEFAULT_PORTS = { 'http' => 80, 'https' => 443, 'coffee' => 80 } - - # The address of the client which connected to the proxy. - HTTP_X_FORWARDED_FOR = 'HTTP_X_FORWARDED_FOR' - - # The contents of the host/:authority header sent to the proxy. - HTTP_X_FORWARDED_HOST = 'HTTP_X_FORWARDED_HOST' - - HTTP_FORWARDED = 'HTTP_FORWARDED' - - # The value of the scheme sent to the proxy. - HTTP_X_FORWARDED_SCHEME = 'HTTP_X_FORWARDED_SCHEME' - - # The protocol used to connect to the proxy. - HTTP_X_FORWARDED_PROTO = 'HTTP_X_FORWARDED_PROTO' - - # The port used to connect to the proxy. - HTTP_X_FORWARDED_PORT = 'HTTP_X_FORWARDED_PORT' - - # Another way for specifying https scheme was used. - HTTP_X_FORWARDED_SSL = 'HTTP_X_FORWARDED_SSL' - - def body; get_header(RACK_INPUT) end - def script_name; get_header(SCRIPT_NAME).to_s end - def script_name=(s); set_header(SCRIPT_NAME, s.to_s) end - - def path_info; get_header(PATH_INFO).to_s end - def path_info=(s); set_header(PATH_INFO, s.to_s) end - - def request_method; get_header(REQUEST_METHOD) end - def query_string; get_header(QUERY_STRING).to_s end - def content_length; get_header('CONTENT_LENGTH') end - def logger; get_header(RACK_LOGGER) end - def user_agent; get_header('HTTP_USER_AGENT') end - - # the referer of the client - def referer; get_header('HTTP_REFERER') end - alias referrer referer - - def session - fetch_header(RACK_SESSION) do |k| - set_header RACK_SESSION, default_session - end - end - - def session_options - fetch_header(RACK_SESSION_OPTIONS) do |k| - set_header RACK_SESSION_OPTIONS, {} - end - end - - # Checks the HTTP request method (or verb) to see if it was of type DELETE - def delete?; request_method == DELETE end - - # Checks the HTTP request method (or verb) to see if it was of type GET - def get?; request_method == GET end - - # Checks the HTTP request method (or verb) to see if it was of type HEAD - def head?; request_method == HEAD end - - # Checks the HTTP request method (or verb) to see if it was of type OPTIONS - def options?; request_method == OPTIONS end - - # Checks the HTTP request method (or verb) to see if it was of type LINK - def link?; request_method == LINK end - - # Checks the HTTP request method (or verb) to see if it was of type PATCH - def patch?; request_method == PATCH end - - # Checks the HTTP request method (or verb) to see if it was of type POST - def post?; request_method == POST end - - # Checks the HTTP request method (or verb) to see if it was of type PUT - def put?; request_method == PUT end - - # Checks the HTTP request method (or verb) to see if it was of type TRACE - def trace?; request_method == TRACE end - - # Checks the HTTP request method (or verb) to see if it was of type UNLINK - def unlink?; request_method == UNLINK end - - def scheme - if get_header(HTTPS) == 'on' - 'https' - elsif get_header(HTTP_X_FORWARDED_SSL) == 'on' - 'https' - elsif forwarded_scheme - forwarded_scheme - else - get_header(RACK_URL_SCHEME) - end - end - - # The authority of the incoming request as defined by RFC3976. - # https://tools.ietf.org/html/rfc3986#section-3.2 - # - # In HTTP/1, this is the `host` header. - # In HTTP/2, this is the `:authority` pseudo-header. - def authority - forwarded_authority || host_authority || server_authority - end - - # The authority as defined by the `SERVER_NAME` and `SERVER_PORT` - # variables. - def server_authority - host = self.server_name - port = self.server_port - - if host - if port - "#{host}:#{port}" - else - host - end - end - end - - def server_name - get_header(SERVER_NAME) - end - - def server_port - get_header(SERVER_PORT) - end - - def cookies - hash = fetch_header(RACK_REQUEST_COOKIE_HASH) do |key| - set_header(key, {}) - end - - string = get_header(HTTP_COOKIE) - - unless string == get_header(RACK_REQUEST_COOKIE_STRING) - hash.replace Utils.parse_cookies_header(string) - set_header(RACK_REQUEST_COOKIE_STRING, string) - end - - hash - end - - def content_type - content_type = get_header('CONTENT_TYPE') - content_type.nil? || content_type.empty? ? nil : content_type - end - - def xhr? - get_header("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" - end - - # The `HTTP_HOST` header. - def host_authority - get_header(HTTP_HOST) - end - - def host_with_port(authority = self.authority) - host, _, port = split_authority(authority) - - if port == DEFAULT_PORTS[self.scheme] - host - else - authority - end - end - - # Returns a formatted host, suitable for being used in a URI. - def host - split_authority(self.authority)[0] - end - - # Returns an address suitable for being to resolve to an address. - # In the case of a domain name or IPv4 address, the result is the same - # as +host+. In the case of IPv6 or future address formats, the square - # brackets are removed. - def hostname - split_authority(self.authority)[1] - end - - def port - if authority = self.authority - _, _, port = split_authority(authority) - end - - port || forwarded_port&.last || DEFAULT_PORTS[scheme] || server_port - end - - def forwarded_for - forwarded_priority.each do |type| - case type - when :forwarded - if forwarded_for = get_http_forwarded(:for) - return(forwarded_for.map! do |authority| - split_authority(authority)[1] - end) - end - when :x_forwarded - if value = get_header(HTTP_X_FORWARDED_FOR) - return(split_header(value).map do |authority| - split_authority(wrap_ipv6(authority))[1] - end) - end - end - end - - nil - end - - def forwarded_port - forwarded_priority.each do |type| - case type - when :forwarded - if forwarded = get_http_forwarded(:for) - return(forwarded.map do |authority| - split_authority(authority)[2] - end.compact) - end - when :x_forwarded - if value = get_header(HTTP_X_FORWARDED_PORT) - return split_header(value).map(&:to_i) - end - end - end - - nil - end - - def forwarded_authority - forwarded_priority.each do |type| - case type - when :forwarded - if forwarded = get_http_forwarded(:host) - return forwarded.last - end - when :x_forwarded - if value = get_header(HTTP_X_FORWARDED_HOST) - return wrap_ipv6(split_header(value).last) - end - end - end - - nil - end - - def ssl? - scheme == 'https' || scheme == 'wss' - end - - def ip - remote_addresses = split_header(get_header('REMOTE_ADDR')) - external_addresses = reject_trusted_ip_addresses(remote_addresses) - - unless external_addresses.empty? - return external_addresses.last - end - - if (forwarded_for = self.forwarded_for) && !forwarded_for.empty? - # The forwarded for addresses are ordered: client, proxy1, proxy2. - # So we reject all the trusted addresses (proxy*) and return the - # last client. Or if we trust everyone, we just return the first - # address. - return reject_trusted_ip_addresses(forwarded_for).last || forwarded_for.first - end - - # If all the addresses are trusted, and we aren't forwarded, just return - # the first remote address, which represents the source of the request. - remote_addresses.first - end - - # The media type (type/subtype) portion of the CONTENT_TYPE header - # without any media type parameters. e.g., when CONTENT_TYPE is - # "text/plain;charset=utf-8", the media-type is "text/plain". - # - # For more information on the use of media types in HTTP, see: - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 - def media_type - MediaType.type(content_type) - end - - # The media type parameters provided in CONTENT_TYPE as a Hash, or - # an empty Hash if no CONTENT_TYPE or media-type parameters were - # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", - # this method responds with the following Hash: - # { 'charset' => 'utf-8' } - def media_type_params - MediaType.params(content_type) - end - - # The character set of the request body if a "charset" media type - # parameter was given, or nil if no "charset" was specified. Note - # that, per RFC2616, text/* media types that specify no explicit - # charset are to be considered ISO-8859-1. - def content_charset - media_type_params['charset'] - end - - # Determine whether the request body contains form-data by checking - # the request content-type for one of the media-types: - # "application/x-www-form-urlencoded" or "multipart/form-data". The - # list of form-data media types can be modified through the - # +FORM_DATA_MEDIA_TYPES+ array. - # - # A request body is also assumed to contain form-data when no - # content-type header is provided and the request_method is POST. - def form_data? - type = media_type - meth = get_header(RACK_METHODOVERRIDE_ORIGINAL_METHOD) || get_header(REQUEST_METHOD) - - (meth == POST && type.nil?) || FORM_DATA_MEDIA_TYPES.include?(type) - end - - # Determine whether the request body contains data by checking - # the request media_type against registered parse-data media-types - def parseable_data? - PARSEABLE_DATA_MEDIA_TYPES.include?(media_type) - end - - # Returns the data received in the query string. - def GET - rr_query_string = get_header(RACK_REQUEST_QUERY_STRING) - query_string = self.query_string - if rr_query_string == query_string - get_header(RACK_REQUEST_QUERY_HASH) - else - if rr_query_string - warn "query string used for GET parsing different from current query string. Starting in Rack 3.2, Rack will used the cached GET value instead of parsing the current query string.", uplevel: 1 - end - query_hash = parse_query(query_string, '&') - set_header(RACK_REQUEST_QUERY_STRING, query_string) - set_header(RACK_REQUEST_QUERY_HASH, query_hash) - end - end - - # Returns the data received in the request body. - # - # This method support both application/x-www-form-urlencoded and - # multipart/form-data. - def POST - if error = get_header(RACK_REQUEST_FORM_ERROR) - raise error.class, error.message, cause: error.cause - end - - begin - rack_input = get_header(RACK_INPUT) - - # If the form hash was already memoized: - if form_hash = get_header(RACK_REQUEST_FORM_HASH) - form_input = get_header(RACK_REQUEST_FORM_INPUT) - # And it was memoized from the same input: - if form_input.equal?(rack_input) - return form_hash - elsif form_input - warn "input stream used for POST parsing different from current input stream. Starting in Rack 3.2, Rack will used the cached POST value instead of parsing the current input stream.", uplevel: 1 - end - end - - # Otherwise, figure out how to parse the input: - if rack_input.nil? - set_header RACK_REQUEST_FORM_INPUT, nil - set_header(RACK_REQUEST_FORM_HASH, {}) - elsif form_data? || parseable_data? - if pairs = Rack::Multipart.parse_multipart(env, Rack::Multipart::ParamList) - set_header RACK_REQUEST_FORM_PAIRS, pairs - set_header RACK_REQUEST_FORM_HASH, expand_param_pairs(pairs) - else - form_vars = get_header(RACK_INPUT).read - - # Fix for Safari Ajax postings that always append \0 - # form_vars.sub!(/\0\z/, '') # performance replacement: - form_vars.slice!(-1) if form_vars.end_with?("\0") - - set_header RACK_REQUEST_FORM_VARS, form_vars - set_header RACK_REQUEST_FORM_HASH, parse_query(form_vars, '&') - end - - set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) - get_header RACK_REQUEST_FORM_HASH - else - set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) - set_header(RACK_REQUEST_FORM_HASH, {}) - end - rescue => error - set_header(RACK_REQUEST_FORM_ERROR, error) - raise - end - end - - # The union of GET and POST data. - # - # Note that modifications will not be persisted in the env. Use update_param or delete_param if you want to destructively modify params. - def params - self.GET.merge(self.POST) - end - - # Destructively update a parameter, whether it's in GET and/or POST. Returns nil. - # - # The parameter is updated wherever it was previous defined, so GET, POST, or both. If it wasn't previously defined, it's inserted into GET. - # - # env['rack.input'] is not touched. - def update_param(k, v) - found = false - if self.GET.has_key?(k) - found = true - self.GET[k] = v - end - if self.POST.has_key?(k) - found = true - self.POST[k] = v - end - unless found - self.GET[k] = v - end - end - - # Destructively delete a parameter, whether it's in GET or POST. Returns the value of the deleted parameter. - # - # If the parameter is in both GET and POST, the POST value takes precedence since that's how #params works. - # - # env['rack.input'] is not touched. - def delete_param(k) - post_value, get_value = self.POST.delete(k), self.GET.delete(k) - post_value || get_value - end - - def base_url - "#{scheme}://#{host_with_port}" - end - - # Tries to return a remake of the original request URL as a string. - def url - base_url + fullpath - end - - def path - script_name + path_info - end - - def fullpath - query_string.empty? ? path : "#{path}?#{query_string}" - end - - def accept_encoding - parse_http_accept_header(get_header("HTTP_ACCEPT_ENCODING")) - end - - def accept_language - parse_http_accept_header(get_header("HTTP_ACCEPT_LANGUAGE")) - end - - def trusted_proxy?(ip) - Rack::Request.ip_filter.call(ip) - end - - # like Hash#values_at - def values_at(*keys) - warn("Request#values_at is deprecated and will be removed in a future version of Rack. Please use request.params.values_at instead", uplevel: 1) - - keys.map { |key| params[key] } - end - - private - - def default_session; {}; end - - # Assist with compatibility when processing `X-Forwarded-For`. - def wrap_ipv6(host) - # Even thought IPv6 addresses should be wrapped in square brackets, - # sometimes this is not done in various legacy/underspecified headers. - # So we try to fix this situation for compatibility reasons. - - # Try to detect IPv6 addresses which aren't escaped yet: - if !host.start_with?('[') && host.count(':') > 1 - "[#{host}]" - else - host - end - end - - def parse_http_accept_header(header) - # It would be nice to use filter_map here, but it's Ruby 2.7+ - parts = header.to_s.split(',') - - parts.map! do |part| - part.strip! - next if part.empty? - - attribute, parameters = part.split(';', 2) - attribute.strip! - parameters&.strip! - quality = 1.0 - if parameters and /\Aq=([\d.]+)/ =~ parameters - quality = $1.to_f - end - [attribute, quality] - end - - parts.compact! - - parts - end - - # Get an array of values set in the RFC 7239 `Forwarded` request header. - def get_http_forwarded(token) - Utils.forwarded_values(get_header(HTTP_FORWARDED))&.[](token) - end - - def query_parser - Utils.default_query_parser - end - - def parse_query(qs, d = '&') - query_parser.parse_nested_query(qs, d) - end - - def parse_multipart - Rack::Multipart.extract_multipart(self, query_parser) - end - - def expand_param_pairs(pairs, query_parser = query_parser()) - params = query_parser.make_params - - pairs.each do |k, v| - query_parser.normalize_params(params, k, v) - end - - params.to_params_hash - end - - def split_header(value) - value ? value.strip.split(/[,\s]+/) : [] - end - - # ipv6 extracted from resolv stdlib, simplified - # to remove numbered match group creation. - ipv6 = Regexp.union( - /(?:[0-9A-Fa-f]{1,4}:){7} - [0-9A-Fa-f]{1,4}/x, - /(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: - (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?/x, - /(?:[0-9A-Fa-f]{1,4}:){6,6} - \d+\.\d+\.\d+\.\d+/x, - /(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: - (?:[0-9A-Fa-f]{1,4}:)* - \d+\.\d+\.\d+\.\d+/x, - /[Ff][Ee]80 - (?::[0-9A-Fa-f]{1,4}){7} - %[-0-9A-Za-z._~]+/x, - /[Ff][Ee]80: - (?: - (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: - (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? - | - :(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? - )? - :[0-9A-Fa-f]{1,4}%[-0-9A-Za-z._~]+/x) - - AUTHORITY = / - \A - (? - # Match IPv6 as a string of hex digits and colons in square brackets - \[(?
#{ipv6})\] - | - # Match any other printable string (except square brackets) as a hostname - (?
[[[:graph:]&&[^\[\]]]]*?) - ) - (:(?\d+))? - \z - /x - - private_constant :AUTHORITY - - def split_authority(authority) - return [] if authority.nil? - return [] unless match = AUTHORITY.match(authority) - return match[:host], match[:address], match[:port]&.to_i - end - - def reject_trusted_ip_addresses(ip_addresses) - ip_addresses.reject { |ip| trusted_proxy?(ip) } - end - - FORWARDED_SCHEME_HEADERS = { - proto: HTTP_X_FORWARDED_PROTO, - scheme: HTTP_X_FORWARDED_SCHEME - }.freeze - private_constant :FORWARDED_SCHEME_HEADERS - def forwarded_scheme - forwarded_priority.each do |type| - case type - when :forwarded - if (forwarded_proto = get_http_forwarded(:proto)) && - (scheme = allowed_scheme(forwarded_proto.last)) - return scheme - end - when :x_forwarded - x_forwarded_proto_priority.each do |x_type| - if header = FORWARDED_SCHEME_HEADERS[x_type] - split_header(get_header(header)).reverse_each do |scheme| - if allowed_scheme(scheme) - return scheme - end - end - end - end - end - end - - nil - end - - def allowed_scheme(header) - header if ALLOWED_SCHEMES.include?(header) - end - - def forwarded_priority - Request.forwarded_priority - end - - def x_forwarded_proto_priority - Request.x_forwarded_proto_priority - end - end - - include Env - include Helpers - end -end - -# :nocov: -require_relative 'multipart' unless defined?(Rack::Multipart) -# :nocov: diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/response.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/response.rb deleted file mode 100644 index ece451d..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/response.rb +++ /dev/null @@ -1,403 +0,0 @@ -# frozen_string_literal: true - -require 'time' - -require_relative 'constants' -require_relative 'utils' -require_relative 'media_type' -require_relative 'headers' - -module Rack - # Rack::Response provides a convenient interface to create a Rack - # response. - # - # It allows setting of headers and cookies, and provides useful - # defaults (an OK response with empty headers and body). - # - # You can use Response#write to iteratively generate your response, - # but note that this is buffered by Rack::Response until you call - # +finish+. +finish+ however can take a block inside which calls to - # +write+ are synchronous with the Rack response. - # - # Your application's +call+ should end returning Response#finish. - class Response - def self.[](status, headers, body) - self.new(body, status, headers) - end - - CHUNKED = 'chunked' - STATUS_WITH_NO_ENTITY_BODY = Utils::STATUS_WITH_NO_ENTITY_BODY - - attr_accessor :length, :status, :body - attr_reader :headers - - # Initialize the response object with the specified +body+, +status+ - # and +headers+. - # - # If the +body+ is +nil+, construct an empty response object with internal - # buffering. - # - # If the +body+ responds to +to_str+, assume it's a string-like object and - # construct a buffered response object containing using that string as the - # initial contents of the buffer. - # - # Otherwise it is expected +body+ conforms to the normal requirements of a - # Rack response body, typically implementing one of +each+ (enumerable - # body) or +call+ (streaming body). - # - # The +status+ defaults to +200+ which is the "OK" HTTP status code. You - # can provide any other valid status code. - # - # The +headers+ must be a +Hash+ of key-value header pairs which conform to - # the Rack specification for response headers. The key must be a +String+ - # instance and the value can be either a +String+ or +Array+ instance. - def initialize(body = nil, status = 200, headers = {}) - @status = status.to_i - - unless headers.is_a?(Hash) - raise ArgumentError, "Headers must be a Hash!" - end - - @headers = Headers.new - # Convert headers input to a plain hash with lowercase keys. - headers.each do |k, v| - @headers[k] = v - end - - @writer = self.method(:append) - - @block = nil - - # Keep track of whether we have expanded the user supplied body. - if body.nil? - @body = [] - @buffered = true - # Body is unspecified - it may be a buffered response, or it may be a HEAD response. - @length = nil - elsif body.respond_to?(:to_str) - @body = [body] - @buffered = true - @length = body.to_str.bytesize - else - @body = body - @buffered = nil # undetermined as of yet. - @length = nil - end - - yield self if block_given? - end - - def redirect(target, status = 302) - self.status = status - self.location = target - end - - def chunked? - CHUNKED == get_header(TRANSFER_ENCODING) - end - - def no_entity_body? - # The response body is an enumerable body and it is not allowed to have an entity body. - @body.respond_to?(:each) && STATUS_WITH_NO_ENTITY_BODY[@status] - end - - # Generate a response array consistent with the requirements of the SPEC. - # @return [Array] a 3-tuple suitable of `[status, headers, body]` - # which is suitable to be returned from the middleware `#call(env)` method. - def finish(&block) - if no_entity_body? - delete_header CONTENT_TYPE - delete_header CONTENT_LENGTH - close - return [@status, @headers, []] - else - if block_given? - # We don't add the content-length here as the user has provided a block that can #write additional chunks to the body. - @block = block - return [@status, @headers, self] - else - # If we know the length of the body, set the content-length header... except if we are chunked? which is a legacy special case where the body might already be encoded and thus the actual encoded body length and the content-length are likely to be different. - if @length && !chunked? - @headers[CONTENT_LENGTH] = @length.to_s - end - return [@status, @headers, @body] - end - end - end - - alias to_a finish # For *response - - def each(&callback) - @body.each(&callback) - @buffered = true - - if @block - @writer = callback - @block.call(self) - end - end - - # Append a chunk to the response body. - # - # Converts the response into a buffered response if it wasn't already. - # - # NOTE: Do not mix #write and direct #body access! - # - def write(chunk) - buffered_body! - - @writer.call(chunk.to_s) - end - - def close - @body.close if @body.respond_to?(:close) - end - - def empty? - @block == nil && @body.empty? - end - - def has_header?(key) - raise ArgumentError unless key.is_a?(String) - @headers.key?(key) - end - def get_header(key) - raise ArgumentError unless key.is_a?(String) - @headers[key] - end - def set_header(key, value) - raise ArgumentError unless key.is_a?(String) - @headers[key] = value - end - def delete_header(key) - raise ArgumentError unless key.is_a?(String) - @headers.delete key - end - - alias :[] :get_header - alias :[]= :set_header - - module Helpers - def invalid?; status < 100 || status >= 600; end - - def informational?; status >= 100 && status < 200; end - def successful?; status >= 200 && status < 300; end - def redirection?; status >= 300 && status < 400; end - def client_error?; status >= 400 && status < 500; end - def server_error?; status >= 500 && status < 600; end - - def ok?; status == 200; end - def created?; status == 201; end - def accepted?; status == 202; end - def no_content?; status == 204; end - def moved_permanently?; status == 301; end - def bad_request?; status == 400; end - def unauthorized?; status == 401; end - def forbidden?; status == 403; end - def not_found?; status == 404; end - def method_not_allowed?; status == 405; end - def not_acceptable?; status == 406; end - def request_timeout?; status == 408; end - def precondition_failed?; status == 412; end - def unprocessable?; status == 422; end - - def redirect?; [301, 302, 303, 307, 308].include? status; end - - def include?(header) - has_header?(header) - end - - # Add a header that may have multiple values. - # - # Example: - # response.add_header 'vary', 'accept-encoding' - # response.add_header 'vary', 'cookie' - # - # assert_equal 'accept-encoding,cookie', response.get_header('vary') - # - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 - def add_header(key, value) - raise ArgumentError unless key.is_a?(String) - - if value.nil? - return get_header(key) - end - - value = value.to_s - - if header = get_header(key) - if header.is_a?(Array) - header << value - else - set_header(key, [header, value]) - end - else - set_header(key, value) - end - end - - # Get the content type of the response. - def content_type - get_header CONTENT_TYPE - end - - # Set the content type of the response. - def content_type=(content_type) - set_header CONTENT_TYPE, content_type - end - - def media_type - MediaType.type(content_type) - end - - def media_type_params - MediaType.params(content_type) - end - - def content_length - cl = get_header CONTENT_LENGTH - cl ? cl.to_i : cl - end - - def location - get_header "location" - end - - def location=(location) - set_header "location", location - end - - def set_cookie(key, value) - add_header SET_COOKIE, Utils.set_cookie_header(key, value) - end - - def delete_cookie(key, value = {}) - set_header(SET_COOKIE, - Utils.delete_set_cookie_header!( - get_header(SET_COOKIE), key, value - ) - ) - end - - def set_cookie_header - get_header SET_COOKIE - end - - def set_cookie_header=(value) - set_header SET_COOKIE, value - end - - def cache_control - get_header CACHE_CONTROL - end - - def cache_control=(value) - set_header CACHE_CONTROL, value - end - - # Specifies that the content shouldn't be cached. Overrides `cache!` if already called. - def do_not_cache! - set_header CACHE_CONTROL, "no-cache, must-revalidate" - set_header EXPIRES, Time.now.httpdate - end - - # Specify that the content should be cached. - # @param duration [Integer] The number of seconds until the cache expires. - # @option directive [String] The cache control directive, one of "public", "private", "no-cache" or "no-store". - def cache!(duration = 3600, directive: "public") - unless headers[CACHE_CONTROL] =~ /no-cache/ - set_header CACHE_CONTROL, "#{directive}, max-age=#{duration}" - set_header EXPIRES, (Time.now + duration).httpdate - end - end - - def etag - get_header ETAG - end - - def etag=(value) - set_header ETAG, value - end - - protected - - # Convert the body of this response into an internally buffered Array if possible. - # - # `@buffered` is a ternary value which indicates whether the body is buffered. It can be: - # * `nil` - The body has not been buffered yet. - # * `true` - The body is buffered as an Array instance. - # * `false` - The body is not buffered and cannot be buffered. - # - # @return [Boolean] whether the body is buffered as an Array instance. - def buffered_body! - if @buffered.nil? - if @body.is_a?(Array) - # The user supplied body was an array: - @body = @body.compact - @length = @body.sum{|part| part.bytesize} - @buffered = true - elsif @body.respond_to?(:each) - # Turn the user supplied body into a buffered array: - body = @body - @body = Array.new - @buffered = true - - body.each do |part| - @writer.call(part.to_s) - end - - body.close if body.respond_to?(:close) - else - # We don't know how to buffer the user-supplied body: - @buffered = false - end - end - - return @buffered - end - - def append(chunk) - chunk = chunk.dup unless chunk.frozen? - @body << chunk - - if @length - @length += chunk.bytesize - elsif @buffered - @length = chunk.bytesize - end - - return chunk - end - end - - include Helpers - - class Raw - include Helpers - - attr_reader :headers - attr_accessor :status - - def initialize(status, headers) - @status = status - @headers = headers - end - - def has_header?(key) - headers.key?(key) - end - - def get_header(key) - headers[key] - end - - def set_header(key, value) - headers[key] = value - end - - def delete_header(key) - headers.delete(key) - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb deleted file mode 100644 index 730c6a2..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/rewindable_input.rb +++ /dev/null @@ -1,113 +0,0 @@ -# -*- encoding: binary -*- -# frozen_string_literal: true - -require 'tempfile' - -require_relative 'constants' - -module Rack - # Class which can make any IO object rewindable, including non-rewindable ones. It does - # this by buffering the data into a tempfile, which is rewindable. - # - # Don't forget to call #close when you're done. This frees up temporary resources that - # RewindableInput uses, though it does *not* close the original IO object. - class RewindableInput - # Makes rack.input rewindable, for compatibility with applications and middleware - # designed for earlier versions of Rack (where rack.input was required to be - # rewindable). - class Middleware - def initialize(app) - @app = app - end - - def call(env) - env[RACK_INPUT] = RewindableInput.new(env[RACK_INPUT]) - @app.call(env) - end - end - - def initialize(io) - @io = io - @rewindable_io = nil - @unlinked = false - end - - def gets - make_rewindable unless @rewindable_io - @rewindable_io.gets - end - - def read(*args) - make_rewindable unless @rewindable_io - @rewindable_io.read(*args) - end - - def each(&block) - make_rewindable unless @rewindable_io - @rewindable_io.each(&block) - end - - def rewind - make_rewindable unless @rewindable_io - @rewindable_io.rewind - end - - def size - make_rewindable unless @rewindable_io - @rewindable_io.size - end - - # Closes this RewindableInput object without closing the originally - # wrapped IO object. Cleans up any temporary resources that this RewindableInput - # has created. - # - # This method may be called multiple times. It does nothing on subsequent calls. - def close - if @rewindable_io - if @unlinked - @rewindable_io.close - else - @rewindable_io.close! - end - @rewindable_io = nil - end - end - - private - - def make_rewindable - # Buffer all data into a tempfile. Since this tempfile is private to this - # RewindableInput object, we chmod it so that nobody else can read or write - # it. On POSIX filesystems we also unlink the file so that it doesn't - # even have a file entry on the filesystem anymore, though we can still - # access it because we have the file handle open. - @rewindable_io = Tempfile.new('RackRewindableInput') - @rewindable_io.chmod(0000) - @rewindable_io.set_encoding(Encoding::BINARY) - @rewindable_io.binmode - # :nocov: - if filesystem_has_posix_semantics? - raise 'Unlink failed. IO closed.' if @rewindable_io.closed? - @unlinked = true - end - # :nocov: - - buffer = "".dup - while @io.read(1024 * 4, buffer) - entire_buffer_written_out = false - while !entire_buffer_written_out - written = @rewindable_io.write(buffer) - entire_buffer_written_out = written == buffer.bytesize - if !entire_buffer_written_out - buffer.slice!(0 .. written - 1) - end - end - end - @rewindable_io.rewind - end - - def filesystem_has_posix_semantics? - RUBY_PLATFORM !~ /(mswin|mingw|cygwin|java)/ - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/runtime.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/runtime.rb deleted file mode 100644 index a1bfa69..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/runtime.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require_relative 'utils' - -module Rack - # Sets an "x-runtime" response header, indicating the response - # time of the request, in seconds - # - # You can put it right before the application to see the processing - # time, or before all the other middlewares to include time for them, - # too. - class Runtime - FORMAT_STRING = "%0.6f" # :nodoc: - HEADER_NAME = "x-runtime" # :nodoc: - - def initialize(app, name = nil) - @app = app - @header_name = HEADER_NAME - @header_name += "-#{name.to_s.downcase}" if name - end - - def call(env) - start_time = Utils.clock_time - _, headers, _ = response = @app.call(env) - - request_time = Utils.clock_time - start_time - - unless headers.key?(@header_name) - headers[@header_name] = FORMAT_STRING % request_time - end - - response - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/sendfile.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/sendfile.rb deleted file mode 100644 index 9c6e0c4..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/sendfile.rb +++ /dev/null @@ -1,167 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' -require_relative 'body_proxy' - -module Rack - - # = Sendfile - # - # The Sendfile middleware intercepts responses whose body is being - # served from a file and replaces it with a server specific x-sendfile - # header. The web server is then responsible for writing the file contents - # to the client. This can dramatically reduce the amount of work required - # by the Ruby backend and takes advantage of the web server's optimized file - # delivery code. - # - # In order to take advantage of this middleware, the response body must - # respond to +to_path+ and the request must include an x-sendfile-type - # header. Rack::Files and other components implement +to_path+ so there's - # rarely anything you need to do in your application. The x-sendfile-type - # header is typically set in your web servers configuration. The following - # sections attempt to document - # - # === Nginx - # - # Nginx supports the x-accel-redirect header. This is similar to x-sendfile - # but requires parts of the filesystem to be mapped into a private URL - # hierarchy. - # - # The following example shows the Nginx configuration required to create - # a private "/files/" area, enable x-accel-redirect, and pass the special - # x-sendfile-type and x-accel-mapping headers to the backend: - # - # location ~ /files/(.*) { - # internal; - # alias /var/www/$1; - # } - # - # location / { - # proxy_redirect off; - # - # proxy_set_header Host $host; - # proxy_set_header X-Real-IP $remote_addr; - # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # - # proxy_set_header x-sendfile-type x-accel-redirect; - # proxy_set_header x-accel-mapping /var/www/=/files/; - # - # proxy_pass http://127.0.0.1:8080/; - # } - # - # Note that the x-sendfile-type header must be set exactly as shown above. - # The x-accel-mapping header should specify the location on the file system, - # followed by an equals sign (=), followed name of the private URL pattern - # that it maps to. The middleware performs a simple substitution on the - # resulting path. - # - # See Also: https://www.nginx.com/resources/wiki/start/topics/examples/xsendfile - # - # === lighttpd - # - # Lighttpd has supported some variation of the x-sendfile header for some - # time, although only recent version support x-sendfile in a reverse proxy - # configuration. - # - # $HTTP["host"] == "example.com" { - # proxy-core.protocol = "http" - # proxy-core.balancer = "round-robin" - # proxy-core.backends = ( - # "127.0.0.1:8000", - # "127.0.0.1:8001", - # ... - # ) - # - # proxy-core.allow-x-sendfile = "enable" - # proxy-core.rewrite-request = ( - # "x-sendfile-type" => (".*" => "x-sendfile") - # ) - # } - # - # See Also: http://redmine.lighttpd.net/wiki/lighttpd/Docs:ModProxyCore - # - # === Apache - # - # x-sendfile is supported under Apache 2.x using a separate module: - # - # https://tn123.org/mod_xsendfile/ - # - # Once the module is compiled and installed, you can enable it using - # XSendFile config directive: - # - # RequestHeader Set x-sendfile-type x-sendfile - # ProxyPassReverse / http://localhost:8001/ - # XSendFile on - # - # === Mapping parameter - # - # The third parameter allows for an overriding extension of the - # x-accel-mapping header. Mappings should be provided in tuples of internal to - # external. The internal values may contain regular expression syntax, they - # will be matched with case indifference. - - class Sendfile - def initialize(app, variation = nil, mappings = []) - @app = app - @variation = variation - @mappings = mappings.map do |internal, external| - [/^#{internal}/i, external] - end - end - - def call(env) - _, headers, body = response = @app.call(env) - - if body.respond_to?(:to_path) - case type = variation(env) - when /x-accel-redirect/i - path = ::File.expand_path(body.to_path) - if url = map_accel_path(env, path) - headers[CONTENT_LENGTH] = '0' - # '?' must be percent-encoded because it is not query string but a part of path - headers[type.downcase] = ::Rack::Utils.escape_path(url).gsub('?', '%3F') - obody = body - response[2] = Rack::BodyProxy.new([]) do - obody.close if obody.respond_to?(:close) - end - else - env[RACK_ERRORS].puts "x-accel-mapping header missing" - end - when /x-sendfile|x-lighttpd-send-file/i - path = ::File.expand_path(body.to_path) - headers[CONTENT_LENGTH] = '0' - headers[type.downcase] = path - obody = body - response[2] = Rack::BodyProxy.new([]) do - obody.close if obody.respond_to?(:close) - end - when '', nil - else - env[RACK_ERRORS].puts "Unknown x-sendfile variation: '#{type}'.\n" - end - end - response - end - - private - def variation(env) - @variation || - env['sendfile.type'] || - env['HTTP_X_SENDFILE_TYPE'] - end - - def map_accel_path(env, path) - if mapping = @mappings.find { |internal, _| internal =~ path } - path.sub(*mapping) - elsif mapping = env['HTTP_X_ACCEL_MAPPING'] - mapping.split(',').map(&:strip).each do |m| - internal, external = m.split('=', 2).map(&:strip) - new_path = path.sub(/^#{internal}/i, external) - return new_path unless path == new_path - end - path - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb deleted file mode 100644 index 9172a4d..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_exceptions.rb +++ /dev/null @@ -1,407 +0,0 @@ -# frozen_string_literal: true - -require 'erb' - -require_relative 'constants' -require_relative 'utils' -require_relative 'request' - -module Rack - # Rack::ShowExceptions catches all exceptions raised from the app it - # wraps. It shows a useful backtrace with the sourcefile and - # clickable context, the whole Rack environment and the request - # data. - # - # Be careful when you use this on public-facing sites as it could - # reveal information helpful to attackers. - - class ShowExceptions - CONTEXT = 7 - - Frame = Struct.new(:filename, :lineno, :function, - :pre_context_lineno, :pre_context, - :context_line, :post_context_lineno, - :post_context) - - def initialize(app) - @app = app - end - - def call(env) - @app.call(env) - rescue StandardError, LoadError, SyntaxError => e - exception_string = dump_exception(e) - - env[RACK_ERRORS].puts(exception_string) - env[RACK_ERRORS].flush - - if accepts_html?(env) - content_type = "text/html" - body = pretty(env, e) - else - content_type = "text/plain" - body = exception_string - end - - [ - 500, - { - CONTENT_TYPE => content_type, - CONTENT_LENGTH => body.bytesize.to_s, - }, - [body], - ] - end - - def prefers_plaintext?(env) - !accepts_html?(env) - end - - def accepts_html?(env) - Rack::Utils.best_q_match(env["HTTP_ACCEPT"], %w[text/html]) - end - private :accepts_html? - - def dump_exception(exception) - if exception.respond_to?(:detailed_message) - message = exception.detailed_message(highlight: false) - else - message = exception.message - end - string = "#{exception.class}: #{message}\n".dup - string << exception.backtrace.map { |l| "\t#{l}" }.join("\n") - string - end - - def pretty(env, exception) - req = Rack::Request.new(env) - - # This double assignment is to prevent an "unused variable" warning. - # Yes, it is dumb, but I don't like Ruby yelling at me. - path = path = (req.script_name + req.path_info).squeeze("/") - - # This double assignment is to prevent an "unused variable" warning. - # Yes, it is dumb, but I don't like Ruby yelling at me. - frames = frames = exception.backtrace.map { |line| - frame = Frame.new - if line =~ /(.*?):(\d+)(:in `(.*)')?/ - frame.filename = $1 - frame.lineno = $2.to_i - frame.function = $4 - - begin - lineno = frame.lineno - 1 - lines = ::File.readlines(frame.filename) - frame.pre_context_lineno = [lineno - CONTEXT, 0].max - frame.pre_context = lines[frame.pre_context_lineno...lineno] - frame.context_line = lines[lineno].chomp - frame.post_context_lineno = [lineno + CONTEXT, lines.size].min - frame.post_context = lines[lineno + 1..frame.post_context_lineno] - rescue - end - - frame - else - nil - end - }.compact - - template.result(binding) - end - - def template - TEMPLATE - end - - def h(obj) # :nodoc: - case obj - when String - Utils.escape_html(obj) - else - Utils.escape_html(obj.inspect) - end - end - - # :stopdoc: - - # adapted from Django - # Copyright (c) Django Software Foundation and individual contributors. - # Used under the modified BSD license: - # http://www.xfree86.org/3.3.6/COPYRIGHT2.html#5 - TEMPLATE = ERB.new(<<-'HTML'.gsub(/^ /, '')) - - - - - - <%=h exception.class %> at <%=h path %> - - - - - -
-

<%=h exception.class %> at <%=h path %>

- <% if exception.respond_to?(:detailed_message) %> -

<%=h exception.detailed_message(highlight: false) %>

- <% else %> -

<%=h exception.message %>

- <% end %> - - - - - - -
Ruby - <% if first = frames.first %> - <%=h first.filename %>: in <%=h first.function %>, line <%=h frames.first.lineno %> - <% else %> - unknown location - <% end %> -
Web<%=h req.request_method %> <%=h(req.host + path)%>
- -

Jump to:

- -
- -
-

Traceback (innermost first)

-
    - <% frames.each { |frame| %> -
  • - <%=h frame.filename %>: in <%=h frame.function %> - - <% if frame.context_line %> -
    - <% if frame.pre_context %> -
      - <% frame.pre_context.each { |line| %> -
    1. <%=h line %>
    2. - <% } %> -
    - <% end %> - -
      -
    1. <%=h frame.context_line %>...
    - - <% if frame.post_context %> -
      - <% frame.post_context.each { |line| %> -
    1. <%=h line %>
    2. - <% } %> -
    - <% end %> -
    - <% end %> -
  • - <% } %> -
-
- -
-

Request information

- -

GET

- <% if req.GET and not req.GET.empty? %> - - - - - - - - - <% req.GET.sort_by { |k, v| k.to_s }.each { |key, val| %> - - - - - <% } %> - -
VariableValue
<%=h key %>
<%=h val.inspect %>
- <% else %> -

No GET data.

- <% end %> - -

POST

- <% if ((req.POST and not req.POST.empty?) rescue (no_post_data = "Invalid POST data"; nil)) %> - - - - - - - - - <% req.POST.sort_by { |k, v| k.to_s }.each { |key, val| %> - - - - - <% } %> - -
VariableValue
<%=h key %>
<%=h val.inspect %>
- <% else %> -

<%= no_post_data || "No POST data" %>.

- <% end %> - - - - <% unless req.cookies.empty? %> - - - - - - - - - <% req.cookies.each { |key, val| %> - - - - - <% } %> - -
VariableValue
<%=h key %>
<%=h val.inspect %>
- <% else %> -

No cookie data.

- <% end %> - -

Rack ENV

- - - - - - - - - <% env.sort_by { |k, v| k.to_s }.each { |key, val| %> - - - - - <% } %> - -
VariableValue
<%=h key %>
<%=h val.inspect %>
- -
- -
-

- You're seeing this error because you use Rack::ShowExceptions. -

-
- - - - HTML - - # :startdoc: - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_status.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_status.rb deleted file mode 100644 index b6f75a0..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/show_status.rb +++ /dev/null @@ -1,123 +0,0 @@ -# frozen_string_literal: true - -require 'erb' - -require_relative 'constants' -require_relative 'utils' -require_relative 'request' -require_relative 'body_proxy' - -module Rack - # Rack::ShowStatus catches all empty responses and replaces them - # with a site explaining the error. - # - # Additional details can be put into rack.showstatus.detail - # and will be shown as HTML. If such details exist, the error page - # is always rendered, even if the reply was not empty. - - class ShowStatus - def initialize(app) - @app = app - @template = ERB.new(TEMPLATE) - end - - def call(env) - status, headers, body = response = @app.call(env) - empty = headers[CONTENT_LENGTH].to_i <= 0 - - # client or server error, or explicit message - if (status.to_i >= 400 && empty) || env[RACK_SHOWSTATUS_DETAIL] - # This double assignment is to prevent an "unused variable" warning. - # Yes, it is dumb, but I don't like Ruby yelling at me. - req = req = Rack::Request.new(env) - - message = Rack::Utils::HTTP_STATUS_CODES[status.to_i] || status.to_s - - # This double assignment is to prevent an "unused variable" warning. - # Yes, it is dumb, but I don't like Ruby yelling at me. - detail = detail = env[RACK_SHOWSTATUS_DETAIL] || message - - html = @template.result(binding) - size = html.bytesize - - response[2] = Rack::BodyProxy.new([html]) do - body.close if body.respond_to?(:close) - end - - headers[CONTENT_TYPE] = "text/html" - headers[CONTENT_LENGTH] = size.to_s - end - - response - end - - def h(obj) # :nodoc: - case obj - when String - Utils.escape_html(obj) - else - Utils.escape_html(obj.inspect) - end - end - - # :stopdoc: - -# adapted from Django -# Copyright (c) Django Software Foundation and individual contributors. -# Used under the modified BSD license: -# http://www.xfree86.org/3.3.6/COPYRIGHT2.html#5 -TEMPLATE = <<'HTML' - - - - - <%=h message %> at <%=h req.script_name + req.path_info %> - - - - -
-

<%=h message %> (<%= status.to_i %>)

- - - - - - - - - -
Request Method:<%=h req.request_method %>
Request URL:<%=h req.url %>
-
-
-

<%=h detail %>

-
- -
-

- You're seeing this error because you use Rack::ShowStatus. -

-
- - -HTML - - # :startdoc: - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/static.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/static.rb deleted file mode 100644 index 5c9b676..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/static.rb +++ /dev/null @@ -1,187 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'files' -require_relative 'mime' - -module Rack - - # The Rack::Static middleware intercepts requests for static files - # (javascript files, images, stylesheets, etc) based on the url prefixes or - # route mappings passed in the options, and serves them using a Rack::Files - # object. This allows a Rack stack to serve both static and dynamic content. - # - # Examples: - # - # Serve all requests beginning with /media from the "media" folder located - # in the current directory (ie media/*): - # - # use Rack::Static, :urls => ["/media"] - # - # Same as previous, but instead of returning 404 for missing files under - # /media, call the next middleware: - # - # use Rack::Static, :urls => ["/media"], :cascade => true - # - # Serve all requests beginning with /css or /images from the folder "public" - # in the current directory (ie public/css/* and public/images/*): - # - # use Rack::Static, :urls => ["/css", "/images"], :root => "public" - # - # Serve all requests to / with "index.html" from the folder "public" in the - # current directory (ie public/index.html): - # - # use Rack::Static, :urls => {"/" => 'index.html'}, :root => 'public' - # - # Serve all requests normally from the folder "public" in the current - # directory but uses index.html as default route for "/" - # - # use Rack::Static, :urls => [""], :root => 'public', :index => - # 'index.html' - # - # Set custom HTTP Headers for based on rules: - # - # use Rack::Static, :root => 'public', - # :header_rules => [ - # [rule, {header_field => content, header_field => content}], - # [rule, {header_field => content}] - # ] - # - # Rules for selecting files: - # - # 1) All files - # Provide the :all symbol - # :all => Matches every file - # - # 2) Folders - # Provide the folder path as a string - # '/folder' or '/folder/subfolder' => Matches files in a certain folder - # - # 3) File Extensions - # Provide the file extensions as an array - # ['css', 'js'] or %w(css js) => Matches files ending in .css or .js - # - # 4) Regular Expressions / Regexp - # Provide a regular expression - # %r{\.(?:css|js)\z} => Matches files ending in .css or .js - # /\.(?:eot|ttf|otf|woff2|woff|svg)\z/ => Matches files ending in - # the most common web font formats (.eot, .ttf, .otf, .woff2, .woff, .svg) - # Note: This Regexp is available as a shortcut, using the :fonts rule - # - # 5) Font Shortcut - # Provide the :fonts symbol - # :fonts => Uses the Regexp rule stated right above to match all common web font endings - # - # Rule Ordering: - # Rules are applied in the order that they are provided. - # List rather general rules above special ones. - # - # Complete example use case including HTTP header rules: - # - # use Rack::Static, :root => 'public', - # :header_rules => [ - # # Cache all static files in public caches (e.g. Rack::Cache) - # # as well as in the browser - # [:all, {'cache-control' => 'public, max-age=31536000'}], - # - # # Provide web fonts with cross-origin access-control-headers - # # Firefox requires this when serving assets using a Content Delivery Network - # [:fonts, {'access-control-allow-origin' => '*'}] - # ] - # - class Static - def initialize(app, options = {}) - @app = app - @urls = options[:urls] || ["/favicon.ico"] - @index = options[:index] - @gzip = options[:gzip] - @cascade = options[:cascade] - root = options[:root] || Dir.pwd - - # HTTP Headers - @header_rules = options[:header_rules] || [] - # Allow for legacy :cache_control option while prioritizing global header_rules setting - @header_rules.unshift([:all, { CACHE_CONTROL => options[:cache_control] }]) if options[:cache_control] - - @file_server = Rack::Files.new(root) - end - - def add_index_root?(path) - @index && route_file(path) && path.end_with?('/') - end - - def overwrite_file_path(path) - @urls.kind_of?(Hash) && @urls.key?(path) || add_index_root?(path) - end - - def route_file(path) - @urls.kind_of?(Array) && @urls.any? { |url| path.index(url) == 0 } - end - - def can_serve(path) - route_file(path) || overwrite_file_path(path) - end - - def call(env) - path = env[PATH_INFO] - - if can_serve(path) - if overwrite_file_path(path) - env[PATH_INFO] = (add_index_root?(path) ? path + @index : @urls[path]) - elsif @gzip && env['HTTP_ACCEPT_ENCODING'] && /\bgzip\b/.match?(env['HTTP_ACCEPT_ENCODING']) - path = env[PATH_INFO] - env[PATH_INFO] += '.gz' - response = @file_server.call(env) - env[PATH_INFO] = path - - if response[0] == 404 - response = nil - elsif response[0] == 304 - # Do nothing, leave headers as is - else - response[1][CONTENT_TYPE] = Mime.mime_type(::File.extname(path), 'text/plain') - response[1]['content-encoding'] = 'gzip' - end - end - - path = env[PATH_INFO] - response ||= @file_server.call(env) - - if @cascade && response[0] == 404 - return @app.call(env) - end - - headers = response[1] - applicable_rules(path).each do |rule, new_headers| - new_headers.each { |field, content| headers[field] = content } - end - - response - else - @app.call(env) - end - end - - # Convert HTTP header rules to HTTP headers - def applicable_rules(path) - @header_rules.find_all do |rule, new_headers| - case rule - when :all - true - when :fonts - /\.(?:ttf|otf|eot|woff2|woff|svg)\z/.match?(path) - when String - path = ::Rack::Utils.unescape(path) - path.start_with?(rule) || path.start_with?('/' + rule) - when Array - /\.(#{rule.join('|')})\z/.match?(path) - when Regexp - rule.match?(path) - else - false - end - end - end - - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb deleted file mode 100644 index 0b94cc7..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'body_proxy' - -module Rack - - # Middleware tracks and cleans Tempfiles created throughout a request (i.e. Rack::Multipart) - # Ideas/strategy based on posts by Eric Wong and Charles Oliver Nutter - # https://groups.google.com/forum/#!searchin/rack-devel/temp/rack-devel/brK8eh-MByw/sw61oJJCGRMJ - class TempfileReaper - def initialize(app) - @app = app - end - - def call(env) - env[RACK_TEMPFILES] ||= [] - - begin - _, _, body = response = @app.call(env) - rescue Exception - env[RACK_TEMPFILES]&.each(&:close!) - raise - end - - response[2] = BodyProxy.new(body) do - env[RACK_TEMPFILES]&.each(&:close!) - end - - response - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/urlmap.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/urlmap.rb deleted file mode 100644 index 99c4d82..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/urlmap.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -require 'set' - -require_relative 'constants' - -module Rack - # Rack::URLMap takes a hash mapping urls or paths to apps, and - # dispatches accordingly. Support for HTTP/1.1 host names exists if - # the URLs start with http:// or https://. - # - # URLMap modifies the SCRIPT_NAME and PATH_INFO such that the part - # relevant for dispatch is in the SCRIPT_NAME, and the rest in the - # PATH_INFO. This should be taken care of when you need to - # reconstruct the URL in order to create links. - # - # URLMap dispatches in such a way that the longest paths are tried - # first, since they are most specific. - - class URLMap - def initialize(map = {}) - remap(map) - end - - def remap(map) - @known_hosts = Set[] - @mapping = map.map { |location, app| - if location =~ %r{\Ahttps?://(.*?)(/.*)} - host, location = $1, $2 - @known_hosts << host - else - host = nil - end - - unless location[0] == ?/ - raise ArgumentError, "paths need to start with /" - end - - location = location.chomp('/') - match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", Regexp::NOENCODING) - - [host, location, match, app] - }.sort_by do |(host, location, _, _)| - [host ? -host.size : Float::INFINITY, -location.size] - end - end - - def call(env) - path = env[PATH_INFO] - script_name = env[SCRIPT_NAME] - http_host = env[HTTP_HOST] - server_name = env[SERVER_NAME] - server_port = env[SERVER_PORT] - - is_same_server = casecmp?(http_host, server_name) || - casecmp?(http_host, "#{server_name}:#{server_port}") - - is_host_known = @known_hosts.include? http_host - - @mapping.each do |host, location, match, app| - unless casecmp?(http_host, host) \ - || casecmp?(server_name, host) \ - || (!host && is_same_server) \ - || (!host && !is_host_known) # If we don't have a matching host, default to the first without a specified host - next - end - - next unless m = match.match(path.to_s) - - rest = m[1] - next unless !rest || rest.empty? || rest[0] == ?/ - - env[SCRIPT_NAME] = (script_name + location) - env[PATH_INFO] = rest - - return app.call(env) - end - - [404, { CONTENT_TYPE => "text/plain", "x-cascade" => "pass" }, ["Not Found: #{path}"]] - - ensure - env[PATH_INFO] = path - env[SCRIPT_NAME] = script_name - end - - private - def casecmp?(v1, v2) - # if both nil, or they're the same string - return true if v1 == v2 - - # if either are nil... (but they're not the same) - return false if v1.nil? - return false if v2.nil? - - # otherwise check they're not case-insensitive the same - v1.casecmp(v2).zero? - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/utils.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/utils.rb deleted file mode 100644 index bbf4969..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/utils.rb +++ /dev/null @@ -1,631 +0,0 @@ -# -*- encoding: binary -*- -# frozen_string_literal: true - -require 'uri' -require 'fileutils' -require 'set' -require 'tempfile' -require 'time' -require 'erb' - -require_relative 'query_parser' -require_relative 'mime' -require_relative 'headers' -require_relative 'constants' - -module Rack - # Rack::Utils contains a grab-bag of useful methods for writing web - # applications adopted from all kinds of Ruby libraries. - - module Utils - ParameterTypeError = QueryParser::ParameterTypeError - InvalidParameterError = QueryParser::InvalidParameterError - ParamsTooDeepError = QueryParser::ParamsTooDeepError - DEFAULT_SEP = QueryParser::DEFAULT_SEP - COMMON_SEP = QueryParser::COMMON_SEP - KeySpaceConstrainedParams = QueryParser::Params - URI_PARSER = defined?(::URI::RFC2396_PARSER) ? ::URI::RFC2396_PARSER : ::URI::DEFAULT_PARSER - - class << self - attr_accessor :default_query_parser - end - # The default amount of nesting to allowed by hash parameters. - # This helps prevent a rogue client from triggering a possible stack overflow - # when parsing parameters. - self.default_query_parser = QueryParser.make_default(32) - - module_function - - # URI escapes. (CGI style space to +) - def escape(s) - URI.encode_www_form_component(s) - end - - # Like URI escaping, but with %20 instead of +. Strictly speaking this is - # true URI escaping. - def escape_path(s) - URI_PARSER.escape s - end - - # Unescapes the **path** component of a URI. See Rack::Utils.unescape for - # unescaping query parameters or form components. - def unescape_path(s) - URI_PARSER.unescape s - end - - # Unescapes a URI escaped string with +encoding+. +encoding+ will be the - # target encoding of the string returned, and it defaults to UTF-8 - def unescape(s, encoding = Encoding::UTF_8) - URI.decode_www_form_component(s, encoding) - end - - class << self - attr_accessor :multipart_total_part_limit - - attr_accessor :multipart_file_limit - - # multipart_part_limit is the original name of multipart_file_limit, but - # the limit only counts parts with filenames. - alias multipart_part_limit multipart_file_limit - alias multipart_part_limit= multipart_file_limit= - end - - # The maximum number of file parts a request can contain. Accepting too - # many parts can lead to the server running out of file handles. - # Set to `0` for no limit. - self.multipart_file_limit = (ENV['RACK_MULTIPART_PART_LIMIT'] || ENV['RACK_MULTIPART_FILE_LIMIT'] || 128).to_i - - # The maximum total number of parts a request can contain. Accepting too - # many can lead to excessive memory use and parsing time. - self.multipart_total_part_limit = (ENV['RACK_MULTIPART_TOTAL_PART_LIMIT'] || 4096).to_i - - def self.param_depth_limit - default_query_parser.param_depth_limit - end - - def self.param_depth_limit=(v) - self.default_query_parser = self.default_query_parser.new_depth_limit(v) - end - - if defined?(Process::CLOCK_MONOTONIC) - def clock_time - Process.clock_gettime(Process::CLOCK_MONOTONIC) - end - else - # :nocov: - def clock_time - Time.now.to_f - end - # :nocov: - end - - def parse_query(qs, d = nil, &unescaper) - Rack::Utils.default_query_parser.parse_query(qs, d, &unescaper) - end - - def parse_nested_query(qs, d = nil) - Rack::Utils.default_query_parser.parse_nested_query(qs, d) - end - - def build_query(params) - params.map { |k, v| - if v.class == Array - build_query(v.map { |x| [k, x] }) - else - v.nil? ? escape(k) : "#{escape(k)}=#{escape(v)}" - end - }.join("&") - end - - def build_nested_query(value, prefix = nil) - case value - when Array - value.map { |v| - build_nested_query(v, "#{prefix}[]") - }.join("&") - when Hash - value.map { |k, v| - build_nested_query(v, prefix ? "#{prefix}[#{k}]" : k) - }.delete_if(&:empty?).join('&') - when nil - escape(prefix) - else - raise ArgumentError, "value must be a Hash" if prefix.nil? - "#{escape(prefix)}=#{escape(value)}" - end - end - - def q_values(q_value_header) - q_value_header.to_s.split(',').map do |part| - value, parameters = part.split(';', 2).map(&:strip) - quality = 1.0 - if parameters && (md = /\Aq=([\d.]+)/.match(parameters)) - quality = md[1].to_f - end - [value, quality] - end - end - - def forwarded_values(forwarded_header) - return nil unless forwarded_header - forwarded_header = forwarded_header.to_s.gsub("\n", ";") - - forwarded_header.split(';').each_with_object({}) do |field, values| - field.split(',').each do |pair| - pair = pair.split('=').map(&:strip).join('=') - return nil unless pair =~ /\A(by|for|host|proto)="?([^"]+)"?\Z/i - (values[$1.downcase.to_sym] ||= []) << $2 - end - end - end - module_function :forwarded_values - - # Return best accept value to use, based on the algorithm - # in RFC 2616 Section 14. If there are multiple best - # matches (same specificity and quality), the value returned - # is arbitrary. - def best_q_match(q_value_header, available_mimes) - values = q_values(q_value_header) - - matches = values.map do |req_mime, quality| - match = available_mimes.find { |am| Rack::Mime.match?(am, req_mime) } - next unless match - [match, quality] - end.compact.sort_by do |match, quality| - (match.split('/', 2).count('*') * -10) + quality - end.last - matches&.first - end - - # Introduced in ERB 4.0. ERB::Escape is an alias for ERB::Utils which - # doesn't get monkey-patched by rails - if defined?(ERB::Escape) && ERB::Escape.instance_method(:html_escape) - define_method(:escape_html, ERB::Escape.instance_method(:html_escape)) - else - require 'cgi/escape' - # Escape ampersands, brackets and quotes to their HTML/XML entities. - def escape_html(string) - CGI.escapeHTML(string.to_s) - end - end - - def select_best_encoding(available_encodings, accept_encoding) - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html - - expanded_accept_encoding = [] - - accept_encoding.each do |m, q| - preference = available_encodings.index(m) || available_encodings.size - - if m == "*" - (available_encodings - accept_encoding.map(&:first)).each do |m2| - expanded_accept_encoding << [m2, q, preference] - end - else - expanded_accept_encoding << [m, q, preference] - end - end - - encoding_candidates = expanded_accept_encoding - .sort_by { |_, q, p| [-q, p] } - .map!(&:first) - - unless encoding_candidates.include?("identity") - encoding_candidates.push("identity") - end - - expanded_accept_encoding.each do |m, q| - encoding_candidates.delete(m) if q == 0.0 - end - - (encoding_candidates & available_encodings)[0] - end - - # :call-seq: - # parse_cookies_header(value) -> hash - # - # Parse cookies from the provided header +value+ according to RFC6265. The - # syntax for cookie headers only supports semicolons. Returns a map of - # cookie +key+ to cookie +value+. - # - # parse_cookies_header('myname=myvalue; max-age=0') - # # => {"myname"=>"myvalue", "max-age"=>"0"} - # - def parse_cookies_header(value) - return {} unless value - - value.split(/; */n).each_with_object({}) do |cookie, cookies| - next if cookie.empty? - key, value = cookie.split('=', 2) - cookies[key] = (unescape(value) rescue value) unless cookies.key?(key) - end - end - - # :call-seq: - # parse_cookies(env) -> hash - # - # Parse cookies from the provided request environment using - # parse_cookies_header. Returns a map of cookie +key+ to cookie +value+. - # - # parse_cookies({'HTTP_COOKIE' => 'myname=myvalue'}) - # # => {'myname' => 'myvalue'} - # - def parse_cookies(env) - parse_cookies_header env[HTTP_COOKIE] - end - - # A valid cookie key according to RFC2616. - # A can be any US-ASCII characters, except control characters, spaces, or tabs. It also must not contain a separator character like the following: ( ) < > @ , ; : \ " / [ ] ? = { }. - VALID_COOKIE_KEY = /\A[!#$%&'*+\-\.\^_`|~0-9a-zA-Z]+\z/.freeze - private_constant :VALID_COOKIE_KEY - - private def escape_cookie_key(key) - if key =~ VALID_COOKIE_KEY - key - else - warn "Cookie key #{key.inspect} is not valid according to RFC2616; it will be escaped. This behaviour is deprecated and will be removed in a future version of Rack.", uplevel: 2 - escape(key) - end - end - - # :call-seq: - # set_cookie_header(key, value) -> encoded string - # - # Generate an encoded string using the provided +key+ and +value+ suitable - # for the +set-cookie+ header according to RFC6265. The +value+ may be an - # instance of either +String+ or +Hash+. - # - # If the cookie +value+ is an instance of +Hash+, it considers the following - # cookie attribute keys: +domain+, +max_age+, +expires+ (must be instance - # of +Time+), +secure+, +http_only+, +same_site+ and +value+. For more - # details about the interpretation of these fields, consult - # [RFC6265 Section 5.2](https://datatracker.ietf.org/doc/html/rfc6265#section-5.2). - # - # An extra cookie attribute +escape_key+ can be provided to control whether - # or not the cookie key is URL encoded. If explicitly set to +false+, the - # cookie key name will not be url encoded (escaped). The default is +true+. - # - # set_cookie_header("myname", "myvalue") - # # => "myname=myvalue" - # - # set_cookie_header("myname", {value: "myvalue", max_age: 10}) - # # => "myname=myvalue; max-age=10" - # - def set_cookie_header(key, value) - case value - when Hash - key = escape_cookie_key(key) unless value[:escape_key] == false - domain = "; domain=#{value[:domain]}" if value[:domain] - path = "; path=#{value[:path]}" if value[:path] - max_age = "; max-age=#{value[:max_age]}" if value[:max_age] - expires = "; expires=#{value[:expires].httpdate}" if value[:expires] - secure = "; secure" if value[:secure] - httponly = "; httponly" if (value.key?(:httponly) ? value[:httponly] : value[:http_only]) - same_site = - case value[:same_site] - when false, nil - nil - when :none, 'None', :None - '; samesite=none' - when :lax, 'Lax', :Lax - '; samesite=lax' - when true, :strict, 'Strict', :Strict - '; samesite=strict' - else - raise ArgumentError, "Invalid :same_site value: #{value[:same_site].inspect}" - end - partitioned = "; partitioned" if value[:partitioned] - value = value[:value] - else - key = escape_cookie_key(key) - end - - value = [value] unless Array === value - - return "#{key}=#{value.map { |v| escape v }.join('&')}#{domain}" \ - "#{path}#{max_age}#{expires}#{secure}#{httponly}#{same_site}#{partitioned}" - end - - # :call-seq: - # set_cookie_header!(headers, key, value) -> header value - # - # Append a cookie in the specified headers with the given cookie +key+ and - # +value+ using set_cookie_header. - # - # If the headers already contains a +set-cookie+ key, it will be converted - # to an +Array+ if not already, and appended to. - def set_cookie_header!(headers, key, value) - if header = headers[SET_COOKIE] - if header.is_a?(Array) - header << set_cookie_header(key, value) - else - headers[SET_COOKIE] = [header, set_cookie_header(key, value)] - end - else - headers[SET_COOKIE] = set_cookie_header(key, value) - end - end - - # :call-seq: - # delete_set_cookie_header(key, value = {}) -> encoded string - # - # Generate an encoded string based on the given +key+ and +value+ using - # set_cookie_header for the purpose of causing the specified cookie to be - # deleted. The +value+ may be an instance of +Hash+ and can include - # attributes as outlined by set_cookie_header. The encoded cookie will have - # a +max_age+ of 0 seconds, an +expires+ date in the past and an empty - # +value+. When used with the +set-cookie+ header, it will cause the client - # to *remove* any matching cookie. - # - # delete_set_cookie_header("myname") - # # => "myname=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" - # - def delete_set_cookie_header(key, value = {}) - set_cookie_header(key, value.merge(max_age: '0', expires: Time.at(0), value: '')) - end - - def delete_cookie_header!(headers, key, value = {}) - headers[SET_COOKIE] = delete_set_cookie_header!(headers[SET_COOKIE], key, value) - - return nil - end - - # :call-seq: - # delete_set_cookie_header!(header, key, value = {}) -> header value - # - # Set an expired cookie in the specified headers with the given cookie - # +key+ and +value+ using delete_set_cookie_header. This causes - # the client to immediately delete the specified cookie. - # - # delete_set_cookie_header!(nil, "mycookie") - # # => "mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" - # - # If the header is non-nil, it will be modified in place. - # - # header = [] - # delete_set_cookie_header!(header, "mycookie") - # # => ["mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"] - # header - # # => ["mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"] - # - def delete_set_cookie_header!(header, key, value = {}) - if header - header = Array(header) - header << delete_set_cookie_header(key, value) - else - header = delete_set_cookie_header(key, value) - end - - return header - end - - def rfc2822(time) - time.rfc2822 - end - - # Parses the "Range:" header, if present, into an array of Range objects. - # Returns nil if the header is missing or syntactically invalid. - # Returns an empty array if none of the ranges are satisfiable. - def byte_ranges(env, size) - get_byte_ranges env['HTTP_RANGE'], size - end - - def get_byte_ranges(http_range, size) - # See - # Ignore Range when file size is 0 to avoid a 416 error. - return nil if size.zero? - return nil unless http_range && http_range =~ /bytes=([^;]+)/ - ranges = [] - $1.split(/,\s*/).each do |range_spec| - return nil unless range_spec.include?('-') - range = range_spec.split('-') - r0, r1 = range[0], range[1] - if r0.nil? || r0.empty? - return nil if r1.nil? - # suffix-byte-range-spec, represents trailing suffix of file - r0 = size - r1.to_i - r0 = 0 if r0 < 0 - r1 = size - 1 - else - r0 = r0.to_i - if r1.nil? - r1 = size - 1 - else - r1 = r1.to_i - return nil if r1 < r0 # backwards range is syntactically invalid - r1 = size - 1 if r1 >= size - end - end - ranges << (r0..r1) if r0 <= r1 - end - - return [] if ranges.map(&:size).sum > size - - ranges - end - - # :nocov: - if defined?(OpenSSL.fixed_length_secure_compare) - # Constant time string comparison. - # - # NOTE: the values compared should be of fixed length, such as strings - # that have already been processed by HMAC. This should not be used - # on variable length plaintext strings because it could leak length info - # via timing attacks. - def secure_compare(a, b) - return false unless a.bytesize == b.bytesize - - OpenSSL.fixed_length_secure_compare(a, b) - end - # :nocov: - else - def secure_compare(a, b) - return false unless a.bytesize == b.bytesize - - l = a.unpack("C*") - - r, i = 0, -1 - b.each_byte { |v| r |= v ^ l[i += 1] } - r == 0 - end - end - - # Context allows the use of a compatible middleware at different points - # in a request handling stack. A compatible middleware must define - # #context which should take the arguments env and app. The first of which - # would be the request environment. The second of which would be the rack - # application that the request would be forwarded to. - class Context - attr_reader :for, :app - - def initialize(app_f, app_r) - raise 'running context does not respond to #context' unless app_f.respond_to? :context - @for, @app = app_f, app_r - end - - def call(env) - @for.context(env, @app) - end - - def recontext(app) - self.class.new(@for, app) - end - - def context(env, app = @app) - recontext(app).call(env) - end - end - - # Every standard HTTP code mapped to the appropriate message. - # Generated with: - # curl -s https://www.iana.org/assignments/http-status-codes/http-status-codes-1.csv \ - # | ruby -rcsv -e "puts CSV.parse(STDIN, headers: true) \ - # .reject {|v| v['Description'] == 'Unassigned' or v['Description'].include? '(' } \ - # .map {|v| %Q/#{v['Value']} => '#{v['Description']}'/ }.join(','+?\n)" - HTTP_STATUS_CODES = { - 100 => 'Continue', - 101 => 'Switching Protocols', - 102 => 'Processing', - 103 => 'Early Hints', - 200 => 'OK', - 201 => 'Created', - 202 => 'Accepted', - 203 => 'Non-Authoritative Information', - 204 => 'No Content', - 205 => 'Reset Content', - 206 => 'Partial Content', - 207 => 'Multi-Status', - 208 => 'Already Reported', - 226 => 'IM Used', - 300 => 'Multiple Choices', - 301 => 'Moved Permanently', - 302 => 'Found', - 303 => 'See Other', - 304 => 'Not Modified', - 305 => 'Use Proxy', - 307 => 'Temporary Redirect', - 308 => 'Permanent Redirect', - 400 => 'Bad Request', - 401 => 'Unauthorized', - 402 => 'Payment Required', - 403 => 'Forbidden', - 404 => 'Not Found', - 405 => 'Method Not Allowed', - 406 => 'Not Acceptable', - 407 => 'Proxy Authentication Required', - 408 => 'Request Timeout', - 409 => 'Conflict', - 410 => 'Gone', - 411 => 'Length Required', - 412 => 'Precondition Failed', - 413 => 'Content Too Large', - 414 => 'URI Too Long', - 415 => 'Unsupported Media Type', - 416 => 'Range Not Satisfiable', - 417 => 'Expectation Failed', - 421 => 'Misdirected Request', - 422 => 'Unprocessable Content', - 423 => 'Locked', - 424 => 'Failed Dependency', - 425 => 'Too Early', - 426 => 'Upgrade Required', - 428 => 'Precondition Required', - 429 => 'Too Many Requests', - 431 => 'Request Header Fields Too Large', - 451 => 'Unavailable For Legal Reasons', - 500 => 'Internal Server Error', - 501 => 'Not Implemented', - 502 => 'Bad Gateway', - 503 => 'Service Unavailable', - 504 => 'Gateway Timeout', - 505 => 'HTTP Version Not Supported', - 506 => 'Variant Also Negotiates', - 507 => 'Insufficient Storage', - 508 => 'Loop Detected', - 511 => 'Network Authentication Required' - } - - # Responses with HTTP status codes that should not have an entity body - STATUS_WITH_NO_ENTITY_BODY = Hash[((100..199).to_a << 204 << 304).product([true])] - - SYMBOL_TO_STATUS_CODE = Hash[*HTTP_STATUS_CODES.map { |code, message| - [message.downcase.gsub(/\s|-/, '_').to_sym, code] - }.flatten] - - OBSOLETE_SYMBOLS_TO_STATUS_CODES = { - payload_too_large: 413, - unprocessable_entity: 422, - bandwidth_limit_exceeded: 509, - not_extended: 510 - }.freeze - private_constant :OBSOLETE_SYMBOLS_TO_STATUS_CODES - - OBSOLETE_SYMBOL_MAPPINGS = { - payload_too_large: :content_too_large, - unprocessable_entity: :unprocessable_content - }.freeze - private_constant :OBSOLETE_SYMBOL_MAPPINGS - - def status_code(status) - if status.is_a?(Symbol) - SYMBOL_TO_STATUS_CODE.fetch(status) do - fallback_code = OBSOLETE_SYMBOLS_TO_STATUS_CODES.fetch(status) { raise ArgumentError, "Unrecognized status code #{status.inspect}" } - message = "Status code #{status.inspect} is deprecated and will be removed in a future version of Rack." - if canonical_symbol = OBSOLETE_SYMBOL_MAPPINGS[status] - # message = "#{message} Please use #{canonical_symbol.inspect} instead." - # For now, let's not emit any warning when there is a mapping. - else - warn message, uplevel: 3 - end - fallback_code - end - else - status.to_i - end - end - - PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact) - - def clean_path_info(path_info) - parts = path_info.split PATH_SEPS - - clean = [] - - parts.each do |part| - next if part.empty? || part == '.' - part == '..' ? clean.pop : clean << part - end - - clean_path = clean.join(::File::SEPARATOR) - clean_path.prepend("/") if parts.empty? || parts.first.empty? - clean_path - end - - NULL_BYTE = "\0" - - def valid_path?(path) - path.valid_encoding? && !path.include?(NULL_BYTE) - end - - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/version.rb b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/version.rb deleted file mode 100644 index 5b45e76..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/lib/rack/version.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -# Copyright (C) 2007-2019 Leah Neukirchen -# -# Rack is freely distributable under the terms of an MIT-style license. -# See MIT-LICENSE or https://opensource.org/licenses/MIT. - -# The Rack main module, serving as a namespace for all core Rack -# modules and classes. -# -# All modules meant for use in your application are autoloaded here, -# so it should be enough just to require 'rack' in your code. - -module Rack - RELEASE = "3.1.8" - - # Return the Rack release as a dotted string. - def self.release - RELEASE - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/rack.gemspec b/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/rack.gemspec deleted file mode 100644 index ed37415..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/after/vendored/rack-3.1.8/rack.gemspec +++ /dev/null @@ -1,31 +0,0 @@ -# -*- encoding: utf-8 -*- -# stub: rack 3.1.8 ruby lib - -Gem::Specification.new do |s| - s.name = "rack".freeze - s.version = "3.1.8".freeze - - s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= - s.metadata = { "bug_tracker_uri" => "https://github.com/rack/rack/issues", "changelog_uri" => "https://github.com/rack/rack/blob/main/CHANGELOG.md", "documentation_uri" => "https://rubydoc.info/github/rack/rack", "source_code_uri" => "https://github.com/rack/rack" } if s.respond_to? :metadata= - s.require_paths = ["lib".freeze] - s.authors = ["Leah Neukirchen".freeze] - s.date = "2024-10-14" - s.description = "Rack provides a minimal, modular and adaptable interface for developing\nweb applications in Ruby. By wrapping HTTP requests and responses in\nthe simplest way possible, it unifies and distills the API for web\nservers, web frameworks, and software in between (the so-called\nmiddleware) into a single method call.\n".freeze - s.email = "leah@vuxu.org".freeze - s.extra_rdoc_files = ["README.md".freeze, "CHANGELOG.md".freeze, "CONTRIBUTING.md".freeze] - s.files = ["CHANGELOG.md".freeze, "CONTRIBUTING.md".freeze, "README.md".freeze] - s.homepage = "https://github.com/rack/rack".freeze - s.licenses = ["MIT".freeze] - s.required_ruby_version = Gem::Requirement.new(">= 2.4.0".freeze) - s.rubygems_version = "3.5.11".freeze - s.summary = "A modular Ruby webserver interface.".freeze - - s.installed_by_version = "3.5.22".freeze - - s.specification_version = 4 - - s.add_development_dependency(%q.freeze, ["~> 5.0".freeze]) - s.add_development_dependency(%q.freeze, [">= 0".freeze]) - s.add_development_dependency(%q.freeze, [">= 0".freeze]) - s.add_development_dependency(%q.freeze, [">= 0".freeze]) -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/.bundle/config b/spikes/gem-checksums/stale-checksum-v1-bug/before/.bundle/config deleted file mode 100644 index 6eb400d..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/.bundle/config +++ /dev/null @@ -1,3 +0,0 @@ ---- -BUNDLE_PATH: "vendor/bundle" -BUNDLE_LOCKFILE_CHECKSUMS: "true" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile b/spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile deleted file mode 100644 index 6d26ec6..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem "rack", "3.1.8", path: "./vendored/rack-3.1.8" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile.lock b/spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile.lock deleted file mode 100644 index 2cbbe92..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/Gemfile.lock +++ /dev/null @@ -1,21 +0,0 @@ -PATH - remote: vendored/rack-3.1.8 - specs: - rack (3.1.8) - -GEM - remote: https://rubygems.org/ - specs: - -PLATFORMS - aarch64-linux - ruby - -DEPENDENCIES - rack (= 3.1.8)! - -CHECKSUMS - rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 - -BUNDLED WITH - 2.7.2 diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CHANGELOG.md b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CHANGELOG.md deleted file mode 100644 index 18069d3..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CHANGELOG.md +++ /dev/null @@ -1,998 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. For info on how to format all future additions to this file please reference [Keep A Changelog](https://keepachangelog.com/en/1.0.0/). - -## [3.1.8] - 2024-10-14 - -- Resolve deprecation warnings about uri `DEFAULT_PARSER`. ([#2249](https://github.com/rack/rack/pull/2249), [@earlopain]) - -## [3.1.7] - 2024-07-11 - -### Fixed - -- Do not remove escaped opening/closing quotes for content-disposition filenames. ([#2229](https://github.com/rack/rack/pull/2229), [@jeremyevans]) -- Fix encoding setting for non-binary IO-like objects in MockRequest#env_for. ([#2227](https://github.com/rack/rack/pull/2227), [@jeremyevans]) -- `Rack::Response` should not generate invalid `content-length` header. ([#2219](https://github.com/rack/rack/pull/2219), [@ioquatix]) -- Allow empty PATH_INFO. ([#2214](https://github.com/rack/rack/pull/2214), [@ioquatix]) - -## [3.1.6] - 2024-07-03 - -### Fixed - -- Fix several edge cases in `Rack::Request#parse_http_accept_header`'s implementation. ([#2226](https://github.com/rack/rack/pull/2226), [@ioquatix]) - -## [3.1.5] - 2024-07-02 - -### Security - -- Fix potential ReDoS attack in `Rack::Request#parse_http_accept_header`. ([GHSA-cj83-2ww7-mvq7](https://github.com/rack/rack/security/advisories/GHSA-cj83-2ww7-mvq7), [@dwisiswant0](https://github.com/dwisiswant0)) - -## [3.1.4] - 2024-06-22 - -### Fixed - -- Fix `Rack::Lint` matching some paths incorrectly as authority form. ([#2220](https://github.com/rack/rack/pull/2220), [@ioquatix]) - -## [3.1.3] - 2024-06-12 - -### Fixed - -- Fix passing non-strings to `Rack::Utils.escape_html`. ([#2202](https://github.com/rack/rack/pull/2202), [@earlopain]) -- `Rack::MockResponse` gracefully handles empty cookies ([#2203](https://github.com/rack/rack/pull/2203) [@wynksaiddestroy]) - -## [3.1.2] - 2024-06-11 - -- `Rack::Response` will take in to consideration chunked encoding responses ([#2204](https://github.com/rack/rack/pull/2204), [@tenderlove]) - -## [3.1.1] - 2024-06-11 - -- Oops! I shouldn't have shipped that - -## [3.1.0] - 2024-06-11 - -:warning: **This release includes several breaking changes.** Refer to the **Removed** section below for the list of deprecated methods that have been removed in this release. - -Rack v3.1 is primarily a maintenance release that removes features deprecated in Rack v3.0. Alongside these removals, there are several improvements to the Rack SPEC, mainly focused on enhancing input and output handling. These changes aim to make Rack more efficient and align better with the requirements of server implementations and relevant HTTP specifications. - -### SPEC Changes - -- `rack.input` is now optional. ([#1997](https://github.com/rack/rack/pull/1997), [#2018](https://github.com/rack/rack/pull/2018), [@ioquatix]) -- `PATH_INFO` is now validated according to the HTTP/1.1 specification. ([#2117](https://github.com/rack/rack/pull/2117), [#2181](https://github.com/rack/rack/pull/2181), [@ioquatix]) - - `OPTIONS *` is now accepted. ([#2114](https://github.com/rack/rack/pull/2114), [@doriantaylor](https://github.com/doriantaylor)) -- Introduce optional `rack.protocol` request and response header for handling connection upgrades. ([#1954](https://github.com/rack/rack/pull/1954), [@ioquatix]) - -### Added - -- Introduce `Rack::Multipart::MissingInputError` for improved handling of missing input in `#parse_multipart`. ([#2018](https://github.com/rack/rack/pull/2018), [@ioquatix]) -- Introduce `module Rack::BadRequest` which is included in multipart and query parser errors. ([#2019](https://github.com/rack/rack/pull/2019), [@ioquatix]) -- Add `.mjs` MIME type ([#2057](https://github.com/rack/rack/pull/2057), [@axilleas](https://github.com/axilleas)) -- `set_cookie_header` utility now supports the `partitioned` cookie attribute. This is required by Chrome in some embedded contexts. ([#2131](https://github.com/rack/rack/pull/2131), [@flavio-b](https://github.com/flavio-b)) -- Introduce `rack.early_hints` for sending `103 Early Hints` informational responses. ([#1831](https://github.com/rack/rack/pull/1831), [@casperisfine](https://github.com/casperisfine), [@jeremyevans]) - -### Changed - -- MIME type for JavaScript files (`.js`) changed from `application/javascript` to `text/javascript` ([`1bd0f15`](https://github.com/rack/rack/commit/1bd0f1597d8f4a90d47115f3e156a8ce7870c9c8), [@ioquatix]) -- Update MIME types associated to `.ttf`, `.woff`, `.woff2` and `.otf` extensions to use mondern `font/*` types. ([#2065](https://github.com/rack/rack/pull/2065), [@davidstosik]) -- `Rack::Utils.escape_html` is now delegated to `CGI.escapeHTML`. `'` is escaped to `#39;` instead of `#x27;`. (decimal vs hexadecimal) ([#2099](https://github.com/rack/rack/pull/2099), [@JunichiIto](https://github.com/JunichiIto)) -- Clarify use of `@buffered` and only update `content-length` when `Rack::Response#finish` is invoked. ([#2149](https://github.com/rack/rack/pull/2149), [@ioquatix]) - -### Deprecated - -- Deprecate automatic cache invalidation in `Request#{GET,POST}` ([#2073](https://github.com/rack/rack/pull/2073), [@jeremyevans]) -- Only cookie keys that are not valid according to the HTTP specifications are escaped. We are planning to deprecate this behaviour, so now a deprecation message will be emitted in this case. In the future, invalid cookie keys may not be accepted. ([#2191](https://github.com/rack/rack/pull/2191), [@ioquatix]) -- `Rack::Logger` is deprecated. ([#2197](https://github.com/rack/rack/pull/2197), [@ioquatix]) -- Add fallback lookup and deprecation warning for obsolete status symbols. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn](https://github.com/wtn)) -- Deprecate `Rack::Request#values_at`, use `request.params.values_at` instead ([#2183](https://github.com/rack/rack/pull/2183), [@ioquatix]) - -### Removed - -- Remove deprecated `Rack::Auth::Digest` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::Cascade::NotFound` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::Chunked` with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::File`, use `Rack::Files` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::QueryParser` `key_space_limit` parameter with no replacement. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::Response#header`, use `Rack::Response#headers` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated cookie methods from `Rack::Utils`: `add_cookie_to_header`, `make_delete_cookie_header`, `add_remove_cookie_to_header`. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::Utils::HeaderHash`. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove deprecated `Rack::VERSION`, `Rack::VERSION_STRING`, `Rack.version`, use `Rack.release` instead. ([#1966](https://github.com/rack/rack/pull/1966), [@ioquatix]) -- Remove non-standard status codes 306, 509, & 510 and update descriptions for 413, 422, & 451. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn](https://github.com/wtn)) -- Remove any dependency on `transfer-encoding: chunked`. ([#2195](https://github.com/rack/rack/pull/2195), [@ioquatix]) -- Remove deprecated `Rack::Request#[]`, use `request.params[key]` instead ([#2183](https://github.com/rack/rack/pull/2183), [@ioquatix]) - -### Fixed - -- In `Rack::Files`, ignore the `Range` header if served file is 0 bytes. ([#2159](https://github.com/rack/rack/pull/2159), [@zarqman]) - -## [3.0.11] - 2024-05-10 - -- Backport #2062 to 3-0-stable: Do not allow `BodyProxy` to respond to `to_str`, make `to_ary` call close . ([#2062](https://github.com/rack/rack/pull/2062), [@jeremyevans](https://github.com/jeremyevans)) - -## [3.0.10] - 2024-03-21 - -- Backport #2104 to 3-0-stable: Return empty when parsing a multi-part POST with only one end delimiter. ([#2164](https://github.com/rack/rack/pull/2164), [@JoeDupuis](https://github.com/JoeDupuis)) - -## [3.0.9.1] - 2024-02-21 - -### Security - -* [CVE-2024-26146] Fixed ReDoS in Accept header parsing -* [CVE-2024-25126] Fixed ReDoS in Content Type header parsing -* [CVE-2024-26141] Reject Range headers which are too large - -[CVE-2024-26146]: https://github.com/advisories/GHSA-54rr-7fvw-6x8f -[CVE-2024-25126]: https://github.com/advisories/GHSA-22f2-v57c-j9cx -[CVE-2024-26141]: https://github.com/advisories/GHSA-xj5v-6v4g-jfw6 - -## [3.0.9] - 2024-01-31 - -- Fix incorrect content-length header that was emitted when `Rack::Response#write` was used in some situations. ([#2150](https://github.com/rack/rack/pull/2150), [@mattbrictson](https://github.com/mattbrictson)) - -## [3.0.8] - 2023-06-14 - -- Fix some unused variable verbose warnings. ([#2084](https://github.com/rack/rack/pull/2084), [@jeremyevans], [@skipkayhil](https://github.com/skipkayhil)) - -## [3.0.7] - 2023-03-16 - -- Make query parameters without `=` have `nil` values. ([#2059](https://github.com/rack/rack/pull/2059), [@jeremyevans]) - -## [3.0.6.1] - 2023-03-13 - -### Security - -- [CVE-2023-27539] Avoid ReDoS in header parsing - -## [3.0.6] - 2023-03-13 - -- Add `QueryParser#missing_value` for handling missing values + tests. ([#2052](https://github.com/rack/rack/pull/2052), [@ioquatix]) - -## [3.0.5] - 2023-03-13 - -- Split form/query parsing into two steps. ([#2038](https://github.com/rack/rack/pull/2038), [@matthewd](https://github.com/matthewd)) - -## [3.0.4.2] - 2023-03-02 - -### Security - -- [CVE-2023-27530] Introduce multipart_total_part_limit to limit total parts - -## [3.0.4.1] - 2023-01-17 - -### Security - -- [CVE-2022-44571] Fix ReDoS vulnerability in multipart parser -- [CVE-2022-44570] Fix ReDoS in Rack::Utils.get_byte_ranges -- [CVE-2022-44572] Forbid control characters in attributes (also ReDoS) - -## [3.0.4] - 2023-01-17 - -- `Rack::Request#POST` should consistently raise errors. Cache errors that occur when invoking `Rack::Request#POST` so they can be raised again later. ([#2010](https://github.com/rack/rack/pull/2010), [@ioquatix]) -- Fix `Rack::Lint` error message for `HTTP_CONTENT_TYPE` and `HTTP_CONTENT_LENGTH`. ([#2007](https://github.com/rack/rack/pull/2007), [@byroot](https://github.com/byroot)) -- Extend `Rack::MethodOverride` to handle `QueryParser::ParamsTooDeepError` error. ([#2006](https://github.com/rack/rack/pull/2006), [@byroot](https://github.com/byroot)) - -## [3.0.3] - 2022-12-27 - -### Fixed - -- `Rack::URLMap` uses non-deprecated form of `Regexp.new`. ([#1998](https://github.com/rack/rack/pull/1998), [@weizheheng](https://github.com/weizheheng)) - -## [3.0.2] - 2022-12-05 - -### Fixed - -- `Utils.build_nested_query` URL-encodes nested field names including the square brackets. -- Allow `Rack::Response` to pass through streaming bodies. ([#1993](https://github.com/rack/rack/pull/1993), [@ioquatix]) - -## [3.0.1] - 2022-11-18 - -### Fixed - -- `MethodOverride` does not look for an override if a request does not include form/parseable data. -- `Rack::Lint::Wrapper` correctly handles `respond_to?` with `to_ary`, `each`, `call` and `to_path`, forwarding to the body. ([#1981](https://github.com/rack/rack/pull/1981), [@ioquatix]) - -## [3.0.0] - 2022-09-06 - -- No changes - -## [3.0.0.rc1] - 2022-09-04 - -### SPEC Changes - -- Stream argument must implement `<<` https://github.com/rack/rack/pull/1959 -- `close` may be called on `rack.input` https://github.com/rack/rack/pull/1956 -- `rack.response_finished` may be used for executing code after the response has been finished https://github.com/rack/rack/pull/1952 - -## [3.0.0.beta1] - 2022-08-08 - -### Security - -- Do not use semicolon as GET parameter separator. ([#1733](https://github.com/rack/rack/pull/1733), [@jeremyevans]) - -### SPEC Changes - -- Response array must now be non-frozen. -- Response `status` must now be an integer greater than or equal to 100. -- Response `headers` must now be an unfrozen hash. -- Response header keys can no longer include uppercase characters. -- Response header values can be an `Array` to handle multiple values (and no longer supports `\n` encoded headers). -- Response body can now respond to `#call` (streaming body) instead of `#each` (enumerable body), for the equivalent of response hijacking in previous versions. -- Middleware must no longer call `#each` on the body, but they can call `#to_ary` on the body if it responds to `#to_ary`. -- `rack.input` is no longer required to be rewindable. -- `rack.multithread`/`rack.multiprocess`/`rack.run_once`/`rack.version` are no longer required environment keys. -- `SERVER_PROTOCOL` is now a required environment key, matching the HTTP protocol used in the request. -- `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. -- `rack.hijack_io` has been removed completely. -- `rack.response_finished` is an optional environment key which contains an array of callable objects that must accept `#call(env, status, headers, error)` and are invoked after the response is finished (either successfully or unsuccessfully). -- It is okay to call `#close` on `rack.input` to indicate that you no longer need or care about the input. -- The stream argument supplied to the streaming body and hijack must support `#<<` for writing output. - -### Removed - -- Remove `rack.multithread`/`rack.multiprocess`/`rack.run_once`. These variables generally come too late to be useful. ([#1720](https://github.com/rack/rack/pull/1720), [@ioquatix], [@jeremyevans])) -- Remove deprecated Rack::Request::SCHEME_WHITELIST. ([@jeremyevans]) -- Remove internal cookie deletion using pattern matching, there are very few practical cases where it would be useful and browsers handle it correctly without us doing anything special. ([#1844](https://github.com/rack/rack/pull/1844), [@ioquatix]) -- Remove `rack.version` as it comes too late to be useful. ([#1938](https://github.com/rack/rack/pull/1938), [@ioquatix]) -- Extract `rackup` command, `Rack::Server`, `Rack::Handler`, `Rack::Lobster` and related code into a separate gem. ([#1937](https://github.com/rack/rack/pull/1937), [@ioquatix]) - -### Added - -- `Rack::Headers` added to support lower-case header keys. ([@jeremyevans]) -- `Rack::Utils#set_cookie_header` now supports `escape_key: false` to avoid key escaping. ([@jeremyevans]) -- `Rack::RewindableInput` supports size. ([@ahorek](https://github.com/ahorek)) -- `Rack::RewindableInput::Middleware` added for making `rack.input` rewindable. ([@jeremyevans]) -- The RFC 7239 Forwarded header is now supported and considered by default when looking for information on forwarding, falling back to the X-Forwarded-* headers. `Rack::Request.forwarded_priority` accessor has been added for configuring the priority of which header to check. ([#1423](https://github.com/rack/rack/issues/1423), [@jeremyevans]) -- Allow response headers to contain array of values. ([#1598](https://github.com/rack/rack/issues/1598), [@ioquatix]) -- Support callable body for explicit streaming support and clarify streaming response body behaviour. ([#1745](https://github.com/rack/rack/pull/1745), [@ioquatix], [#1748](https://github.com/rack/rack/pull/1748), [@wjordan]) -- Allow `Rack::Builder#run` to take a block instead of an argument. ([#1942](https://github.com/rack/rack/pull/1942), [@ioquatix]) -- Add `rack.response_finished` to `Rack::Lint`. ([#1802](https://github.com/rack/rack/pull/1802), [@BlakeWilliams], [#1952](https://github.com/rack/rack/pull/1952), [@ioquatix]) -- The stream argument must implement `#<<`. ([#1959](https://github.com/rack/rack/pull/1959), [@ioquatix]) - -### Changed - -- BREAKING CHANGE: Require `status` to be an Integer. ([#1662](https://github.com/rack/rack/pull/1662), [@olleolleolle](https://github.com/olleolleolle)) -- BREAKING CHANGE: Query parsing now treats parameters without `=` as having the empty string value instead of nil value, to conform to the URL spec. ([#1696](https://github.com/rack/rack/issues/1696), [@jeremyevans]) -- Relax validations around `Rack::Request#host` and `Rack::Request#hostname`. ([#1606](https://github.com/rack/rack/issues/1606), [@pvande](https://github.com/pvande)) -- Removed antiquated handlers: FCGI, LSWS, SCGI, Thin. ([#1658](https://github.com/rack/rack/pull/1658), [@ioquatix]) -- Removed options from `Rack::Builder.parse_file` and `Rack::Builder.load_file`. ([#1663](https://github.com/rack/rack/pull/1663), [@ioquatix]) -- `Rack::HTTP_VERSION` has been removed and the `HTTP_VERSION` env setting is no longer set in the CGI and Webrick handlers. ([#970](https://github.com/rack/rack/issues/970), [@jeremyevans]) -- `Rack::Request#[]` and `#[]=` now warn even in non-verbose mode. ([#1277](https://github.com/rack/rack/issues/1277), [@jeremyevans]) -- Decrease default allowed parameter recursion level from 100 to 32. ([#1640](https://github.com/rack/rack/issues/1640), [@jeremyevans]) -- Attempting to parse a multipart response with an empty body now raises Rack::Multipart::EmptyContentError. ([#1603](https://github.com/rack/rack/issues/1603), [@jeremyevans]) -- `Rack::Utils.secure_compare` uses OpenSSL's faster implementation if available. ([#1711](https://github.com/rack/rack/pull/1711), [@bdewater](https://github.com/bdewater)) -- `Rack::Request#POST` now caches an empty hash if input content type is not parseable. ([#749](https://github.com/rack/rack/pull/749), [@jeremyevans]) -- BREAKING CHANGE: Updated `trusted_proxy?` to match full 127.0.0.0/8 network. ([#1781](https://github.com/rack/rack/pull/1781), [@snbloch](https://github.com/snbloch)) -- Explicitly deprecate `Rack::File` which was an alias for `Rack::Files`. ([#1811](https://github.com/rack/rack/pull/1720), [@ioquatix]). -- Moved `Rack::Session` into [separate gem](https://github.com/rack/rack-session). ([#1805](https://github.com/rack/rack/pull/1805), [@ioquatix]) -- `rackup -D` option to daemonizes no longer changes the working directory to the root. ([#1813](https://github.com/rack/rack/pull/1813), [@jeremyevans]) -- The `x-forwarded-proto` header is now considered before the `x-forwarded-scheme` header for determining the forwarded protocol. `Rack::Request.x_forwarded_proto_priority` accessor has been added for configuring the priority of which header to check. ([#1809](https://github.com/rack/rack/issues/1809), [@jeremyevans]) -- `Rack::Request.forwarded_authority` (and methods that call it, such as `host`) now returns the last authority in the forwarded header, instead of the first, as earlier forwarded authorities can be forged by clients. This restores the Rack 2.1 behavior. ([#1829](https://github.com/rack/rack/issues/1809), [@jeremyevans]) -- Use lower case cookie attributes when creating cookies, and fold cookie attributes to lower case when reading cookies (specifically impacting `secure` and `httponly` attributes). ([#1849](https://github.com/rack/rack/pull/1849), [@ioquatix]) -- The response array must now be mutable (non-frozen) so middleware can modify it without allocating a new Array,therefore reducing object allocations. ([#1887](https://github.com/rack/rack/pull/1887), [#1927](https://github.com/rack/rack/pull/1927), [@amatsuda], [@ioquatix]) -- `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. `rack.hijack_io` is no longer required/specified. ([#1939](https://github.com/rack/rack/pull/1939), [@ioquatix]) -- Allow calling close on `rack.input`. ([#1956](https://github.com/rack/rack/pull/1956), [@ioquatix]) - -### Fixed - -- Make Rack::MockResponse handle non-hash headers. ([#1629](https://github.com/rack/rack/issues/1629), [@jeremyevans]) -- TempfileReaper now deletes temp files if application raises an exception. ([#1679](https://github.com/rack/rack/issues/1679), [@jeremyevans]) -- Handle cookies with values that end in '=' ([#1645](https://github.com/rack/rack/pull/1645), [@lukaso](https://github.com/lukaso)) -- Make `Rack::NullLogger` respond to `#fatal!` [@jeremyevans]) -- Fix multipart filename generation for filenames that contain spaces. Encode spaces as "%20" instead of "+" which will be decoded properly by the multipart parser. ([#1736](https://github.com/rack/rack/pull/1645), [@muirdm](https://github.com/muirdm)) -- `Rack::Request#scheme` returns `ws` or `wss` when one of the `X-Forwarded-Scheme` / `X-Forwarded-Proto` headers is set to `ws` or `wss`, respectively. ([#1730](https://github.com/rack/rack/issues/1730), [@erwanst](https://github.com/erwanst)) - -## [2.2.4] - 2022-06-30 - -- Better support for lower case headers in `Rack::ETag` middleware. ([#1919](https://github.com/rack/rack/pull/1919), [@ioquatix](https://github.com/ioquatix)) -- Use custom exception on params too deep error. ([#1838](https://github.com/rack/rack/pull/1838), [@simi](https://github.com/simi)) - -## [2.2.3.1] - 2022-05-27 - -### Security - -- [CVE-2022-30123] Fix shell escaping issue in Common Logger -- [CVE-2022-30122] Restrict parsing of broken MIME attachments - -## [2.2.3] - 2020-06-15 - -### Security - -- [[CVE-2020-8184](https://nvd.nist.gov/vuln/detail/CVE-2020-8184)] Do not allow percent-encoded cookie name to override existing cookie names. BREAKING CHANGE: Accessing cookie names that require URL encoding with decoded name no longer works. ([@fletchto99](https://github.com/fletchto99)) - -## [2.2.2] - 2020-02-11 - -### Fixed - -- Fix incorrect `Rack::Request#host` value. ([#1591](https://github.com/rack/rack/pull/1591), [@ioquatix]) -- Revert `Rack::Handler::Thin` implementation. ([#1583](https://github.com/rack/rack/pull/1583), [@jeremyevans]) -- Double assignment is still needed to prevent an "unused variable" warning. ([#1589](https://github.com/rack/rack/pull/1589), [@kamipo](https://github.com/kamipo)) -- Fix to handle same_site option for session pool. ([#1587](https://github.com/rack/rack/pull/1587), [@kamipo](https://github.com/kamipo)) - -## [2.2.1] - 2020-02-09 - -### Fixed - -- Rework `Rack::Request#ip` to handle empty `forwarded_for`. ([#1577](https://github.com/rack/rack/pull/1577), [@ioquatix]) - -## [2.2.0] - 2020-02-08 - -### SPEC Changes - -- `rack.session` request environment entry must respond to `to_hash` and return unfrozen Hash. ([@jeremyevans]) -- Request environment cannot be frozen. ([@jeremyevans]) -- CGI values in the request environment with non-ASCII characters must use ASCII-8BIT encoding. ([@jeremyevans]) -- Improve SPEC/lint relating to SERVER_NAME, SERVER_PORT and HTTP_HOST. ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix]) - -### Added - -- `rackup` supports multiple `-r` options and will require all arguments. ([@jeremyevans]) -- `Server` supports an array of paths to require for the `:require` option. ([@khotta](https://github.com/khotta)) -- `Files` supports multipart range requests. ([@fatkodima](https://github.com/fatkodima)) -- `Multipart::UploadedFile` supports an IO-like object instead of using the filesystem, using `:filename` and `:io` options. ([@jeremyevans]) -- `Multipart::UploadedFile` supports keyword arguments `:path`, `:content_type`, and `:binary` in addition to positional arguments. ([@jeremyevans]) -- `Static` supports a `:cascade` option for calling the app if there is no matching file. ([@jeremyevans]) -- `Session::Abstract::SessionHash#dig`. ([@jeremyevans]) -- `Response.[]` and `MockResponse.[]` for creating instances using status, headers, and body. ([@ioquatix]) -- Convenient cache and content type methods for `Rack::Response`. ([#1555](https://github.com/rack/rack/pull/1555), [@ioquatix]) - -### Changed - -- `Request#params` no longer rescues EOFError. ([@jeremyevans]) -- `Directory` uses a streaming approach, significantly improving time to first byte for large directories. ([@jeremyevans]) -- `Directory` no longer includes a Parent directory link in the root directory index. ([@jeremyevans]) -- `QueryParser#parse_nested_query` uses original backtrace when reraising exception with new class. ([@jeremyevans]) -- `ConditionalGet` follows RFC 7232 precedence if both If-None-Match and If-Modified-Since headers are provided. ([@jeremyevans]) -- `.ru` files supports the `frozen-string-literal` magic comment. ([@eregon](https://github.com/eregon)) -- Rely on autoload to load constants instead of requiring internal files, make sure to require 'rack' and not just 'rack/...'. ([@jeremyevans]) -- BREAKING CHANGE: `Etag` will continue sending ETag even if the response should not be cached. Streaming no longer works without a workaround, see [#1619](https://github.com/rack/rack/issues/1619#issuecomment-848460528). ([@henm](https://github.com/henm)) -- `Request#host_with_port` no longer includes a colon for a missing or empty port. ([@AlexWayfer](https://github.com/AlexWayfer)) -- All handlers uses keywords arguments instead of an options hash argument. ([@ioquatix]) -- `Files` handling of range requests no longer return a body that supports `to_path`, to ensure range requests are handled correctly. ([@jeremyevans]) -- `Multipart::Generator` only includes `Content-Length` for files with paths, and `Content-Disposition` `filename` if the `UploadedFile` instance has one. ([@jeremyevans]) -- `Request#ssl?` is true for the `wss` scheme (secure websockets). ([@jeremyevans]) -- `Rack::HeaderHash` is memoized by default. ([#1549](https://github.com/rack/rack/pull/1549), [@ioquatix]) -- `Rack::Directory` allow directory traversal inside root directory. ([#1417](https://github.com/rack/rack/pull/1417), [@ThomasSevestre](https://github.com/ThomasSevestre)) -- Sort encodings by server preference. ([#1184](https://github.com/rack/rack/pull/1184), [@ioquatix], [@wjordan](https://github.com/wjordan)) -- Rework host/hostname/authority implementation in `Rack::Request`. `#host` and `#host_with_port` have been changed to correctly return IPv6 addresses formatted with square brackets, as defined by [RFC3986](https://tools.ietf.org/html/rfc3986#section-3.2.2). ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix]) -- `Rack::Builder` parsing options on first `#\` line is deprecated. ([#1574](https://github.com/rack/rack/pull/1574), [@ioquatix]) - -### Removed - -- `Directory#path` as it was not used and always returned nil. ([@jeremyevans]) -- `BodyProxy#each` as it was only needed to work around a bug in Ruby <1.9.3. ([@jeremyevans]) -- `URLMap::INFINITY` and `URLMap::NEGATIVE_INFINITY`, in favor of `Float::INFINITY`. ([@ch1c0t](https://github.com/ch1c0t)) -- Deprecation of `Rack::File`. It will be deprecated again in rack 2.2 or 3.0. ([@rafaelfranca](https://github.com/rafaelfranca)) -- Support for Ruby 2.2 as it is well past EOL. ([@ioquatix]) -- Remove `Rack::Files#response_body` as the implementation was broken. ([#1153](https://github.com/rack/rack/pull/1153), [@ioquatix]) -- Remove `SERVER_ADDR` which was never part of the original SPEC. ([#1573](https://github.com/rack/rack/pull/1573), [@ioquatix]) - -### Fixed - -- `Directory` correctly handles root paths containing glob metacharacters. ([@jeremyevans]) -- `Cascade` uses a new response object for each call if initialized with no apps. ([@jeremyevans]) -- `BodyProxy` correctly delegates keyword arguments to the body object on Ruby 2.7+. ([@jeremyevans]) -- `BodyProxy#method` correctly handles methods delegated to the body object. ([@jeremyevans]) -- `Request#host` and `Request#host_with_port` handle IPv6 addresses correctly. ([@AlexWayfer](https://github.com/AlexWayfer)) -- `Lint` checks when response hijacking that `rack.hijack` is called with a valid object. ([@jeremyevans]) -- `Response#write` correctly updates `Content-Length` if initialized with a body. ([@jeremyevans]) -- `CommonLogger` includes `SCRIPT_NAME` when logging. ([@Erol](https://github.com/Erol)) -- `Utils.parse_nested_query` correctly handles empty queries, using an empty instance of the params class instead of a hash. ([@jeremyevans]) -- `Directory` correctly escapes paths in links. ([@yous](https://github.com/yous)) -- `Request#delete_cookie` and related `Utils` methods handle `:domain` and `:path` options in same call. ([@jeremyevans]) -- `Request#delete_cookie` and related `Utils` methods do an exact match on `:domain` and `:path` options. ([@jeremyevans]) -- `Static` no longer adds headers when a gzipped file request has a 304 response. ([@chooh](https://github.com/chooh)) -- `ContentLength` sets `Content-Length` response header even for bodies not responding to `to_ary`. ([@jeremyevans]) -- Thin handler supports options passed directly to `Thin::Controllers::Controller`. ([@jeremyevans]) -- WEBrick handler no longer ignores `:BindAddress` option. ([@jeremyevans]) -- `ShowExceptions` handles invalid POST data. ([@jeremyevans]) -- Basic authentication requires a password, even if the password is empty. ([@jeremyevans]) -- `Lint` checks response is array with 3 elements, per SPEC. ([@jeremyevans]) -- Support for using `:SSLEnable` option when using WEBrick handler. (Gregor Melhorn) -- Close response body after buffering it when buffering. ([@ioquatix]) -- Only accept `;` as delimiter when parsing cookies. ([@mrageh](https://github.com/mrageh)) -- `Utils::HeaderHash#clear` clears the name mapping as well. ([@raxoft](https://github.com/raxoft)) -- Support for passing `nil` `Rack::Files.new`, which notably fixes Rails' current `ActiveStorage::FileServer` implementation. ([@ioquatix]) - -### Documentation - -- CHANGELOG updates. ([@aupajo](https://github.com/aupajo)) -- Added [CONTRIBUTING](CONTRIBUTING.md). ([@dblock](https://github.com/dblock)) - -## [2.0.9] - 2020-02-08 - -- Handle case where session id key is requested but missing ([@jeremyevans]) -- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans]) -- Add support for `SameSite=None` cookie value. ([@hennikul](https://github.com/hennikul)) - -## [2.1.2] - 2020-01-27 - -- Fix multipart parser for some files to prevent denial of service ([@aiomaster](https://github.com/aiomaster)) -- Fix `Rack::Builder#use` with keyword arguments ([@kamipo](https://github.com/kamipo)) -- Skip deflating in Rack::Deflater if Content-Length is 0 ([@jeremyevans]) -- Remove `SessionHash#transform_keys`, no longer needed ([@pavel](https://github.com/pavel)) -- Add to_hash to wrap Hash and Session classes ([@oleh-demyanyuk](https://github.com/oleh-demyanyuk)) -- Handle case where session id key is requested but missing ([@jeremyevans]) - -## [2.1.1] - 2020-01-12 - -- Remove `Rack::Chunked` from `Rack::Server` default middleware. ([#1475](https://github.com/rack/rack/pull/1475), [@ioquatix]) -- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans]) - -## [2.1.0] - 2020-01-10 - -### Added - -- Add support for `SameSite=None` cookie value. ([@hennikul](https://github.com/hennikul)) -- Add trailer headers. ([@eileencodes](https://github.com/eileencodes)) -- Add MIME Types for video streaming. ([@styd](https://github.com/styd)) -- Add MIME Type for WASM. ([@buildrtech](https://github.com/buildrtech)) -- Add `Early Hints(103)` to status codes. ([@egtra](https://github.com/egtra)) -- Add `Too Early(425)` to status codes. ([@y-yagi]((https://github.com/y-yagi))) -- Add `Bandwidth Limit Exceeded(509)` to status codes. ([@CJKinni](https://github.com/CJKinni)) -- Add method for custom `ip_filter`. ([@svcastaneda](https://github.com/svcastaneda)) -- Add boot-time profiling capabilities to `rackup`. ([@tenderlove](https://github.com/tenderlove)) -- Add multi mapping support for `X-Accel-Mappings` header. ([@yoshuki](https://github.com/yoshuki)) -- Add `sync: false` option to `Rack::Deflater`. (Eric Wong) -- Add `Builder#freeze_app` to freeze application and all middleware instances. ([@jeremyevans]) -- Add API to extract cookies from `Rack::MockResponse`. ([@petercline](https://github.com/petercline)) - -### Changed - -- Don't propagate nil values from middleware. ([@ioquatix]) -- Lazily initialize the response body and only buffer it if required. ([@ioquatix]) -- Fix deflater zlib buffer errors on empty body part. ([@felixbuenemann](https://github.com/felixbuenemann)) -- Set `X-Accel-Redirect` to percent-encoded path. ([@diskkid](https://github.com/diskkid)) -- Remove unnecessary buffer growing when parsing multipart. ([@tainoe](https://github.com/tainoe)) -- Expand the root path in `Rack::Static` upon initialization. ([@rosenfeld](https://github.com/rosenfeld)) -- Make `ShowExceptions` work with binary data. ([@axyjo](https://github.com/axyjo)) -- Use buffer string when parsing multipart requests. ([@janko-m](https://github.com/janko-m)) -- Support optional UTF-8 Byte Order Mark (BOM) in config.ru. ([@mikegee](https://github.com/mikegee)) -- Handle `X-Forwarded-For` with optional port. ([@dpritchett](https://github.com/dpritchett)) -- Use `Time#httpdate` format for Expires, as proposed by RFC 7231. ([@nanaya](https://github.com/nanaya)) -- Make `Utils.status_code` raise an error when the status symbol is invalid instead of `500`. ([@adambutler](https://github.com/adambutler)) -- Rename `Request::SCHEME_WHITELIST` to `Request::ALLOWED_SCHEMES`. -- Make `Multipart::Parser.get_filename` accept files with `+` in their name. ([@lucaskanashiro](https://github.com/lucaskanashiro)) -- Add Falcon to the default handler fallbacks. ([@ioquatix]) -- Update codebase to avoid string mutations in preparation for `frozen_string_literals`. ([@pat](https://github.com/pat)) -- Change `MockRequest#env_for` to rely on the input optionally responding to `#size` instead of `#length`. ([@janko](https://github.com/janko)) -- Rename `Rack::File` -> `Rack::Files` and add deprecation notice. ([@postmodern](https://github.com/postmodern)) -- Prefer Base64 “strict encoding” for Base64 cookies. ([@ioquatix]) - -### Removed - -- BREAKING CHANGE: Remove `to_ary` from Response ([@tenderlove](https://github.com/tenderlove)) -- Deprecate `Rack::Session::Memcache` in favor of `Rack::Session::Dalli` from dalli gem ([@fatkodima](https://github.com/fatkodima)) - -### Fixed - -- Eliminate warnings for Ruby 2.7. ([@osamtimizer](https://github.com/osamtimizer])) - -### Documentation - -- Update broken example in `Session::Abstract::ID` documentation. ([tonytonyjan](https://github.com/tonytonyjan)) -- Add Padrino to the list of frameworks implementing Rack. ([@wikimatze](https://github.com/wikimatze)) -- Remove Mongrel from the suggested server options in the help output. ([@tricknotes](https://github.com/tricknotes)) -- Replace `HISTORY.md` and `NEWS.md` with `CHANGELOG.md`. ([@twitnithegirl](https://github.com/twitnithegirl)) -- CHANGELOG updates. ([@drenmi](https://github.com/Drenmi), [@p8](https://github.com/p8)) - -## [2.0.8] - 2019-12-08 - -### Security - -- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) - -## [1.6.12] - 2019-12-08 - -### Security - -- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) - -## [2.0.7] - 2019-04-02 - -### Fixed - -- Remove calls to `#eof?` on Rack input in `Multipart::Parser`, as this breaks the specification. ([@matthewd](https://github.com/matthewd)) -- Preserve forwarded IP addresses for trusted proxy chains. ([@SamSaffron](https://github.com/SamSaffron)) - -## [2.0.6] - 2018-11-05 - -### Fixed - -- [[CVE-2018-16470](https://nvd.nist.gov/vuln/detail/CVE-2018-16470)] Reduce buffer size of `Multipart::Parser` to avoid pathological parsing. ([@tenderlove](https://github.com/tenderlove)) -- Fix a call to a non-existing method `#accepts_html` in the `ShowExceptions` middleware. ([@tomelm](https://github.com/tomelm)) -- [[CVE-2018-16471](https://nvd.nist.gov/vuln/detail/CVE-2018-16471)] Whitelist HTTP and HTTPS schemes in `Request#scheme` to prevent a possible XSS attack. ([@PatrickTulskie](https://github.com/PatrickTulskie)) - -## [2.0.5] - 2018-04-23 - -### Fixed - -- Record errors originating from invalid UTF8 in `MethodOverride` middleware instead of breaking. ([@mclark](https://github.com/mclark)) - -## [2.0.4] - 2018-01-31 - -### Changed - -- Ensure the `Lock` middleware passes the original `env` object. ([@lugray](https://github.com/lugray)) -- Improve performance of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) -- Increase buffer size in `Multipart::Parser` for better performance. ([@jkowens](https://github.com/jkowens)) -- Reduce memory usage of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) -- Replace ConcurrentRuby dependency with native `Queue`. ([@devmchakan](https://github.com/devmchakan)) - -### Fixed - -- Require the correct digest algorithm in the `ETag` middleware. ([@matthewd](https://github.com/matthewd)) - -### Documentation - -- Update homepage links to use SSL. ([@hugoabonizio](https://github.com/hugoabonizio)) - -## [2.0.3] - 2017-05-15 - -### Changed - -- Ensure `env` values are ASCII 8-bit encoded. ([@eileencodes](https://github.com/eileencodes)) - -### Fixed - -- Prevent exceptions when a class with mixins inherits from `Session::Abstract::ID`. ([@jnraine](https://github.com/jnraine)) - -## [2.0.2] - 2017-05-08 - -### Added - -- Allow `Session::Abstract::SessionHash#fetch` to accept a block with a default value. ([@yannvanhalewyn](https://github.com/yannvanhalewyn)) -- Add `Builder#freeze_app` to freeze application and all middleware. ([@jeremyevans]) - -### Changed - -- Freeze default session options to avoid accidental mutation. ([@kirs](https://github.com/kirs)) -- Detect partial hijack without hash headers. ([@devmchakan](https://github.com/devmchakan)) -- Update tests to use MiniTest 6 matchers. ([@tonytonyjan](https://github.com/tonytonyjan)) -- Allow 205 Reset Content responses to set a Content-Length, as RFC 7231 proposes setting this to 0. ([@devmchakan](https://github.com/devmchakan)) - -### Fixed - -- Handle `NULL` bytes in multipart filenames. ([@casperisfine](https://github.com/casperisfine)) -- Remove warnings due to miscapitalized global. ([@ioquatix]) -- Prevent exceptions caused by a race condition on multi-threaded servers. ([@sophiedeziel](https://github.com/sophiedeziel)) -- Add RDoc as an explicit dependency for `doc` group. ([@tonytonyjan](https://github.com/tonytonyjan)) -- Record errors originating from `Multipart::Parser` in the `MethodOverride` middleware instead of letting them bubble up. ([@carlzulauf](https://github.com/carlzulauf)) -- Remove remaining use of removed `Utils#bytesize` method from the `File` middleware. ([@brauliomartinezlm](https://github.com/brauliomartinezlm)) - -### Removed - -- Remove `deflate` encoding support to reduce caching overhead. ([@devmchakan](https://github.com/devmchakan)) - -### Documentation - -- Update broken example in `Deflater` documentation. ([@mwpastore](https://github.com/mwpastore)) - -## [2.0.1] - 2016-06-30 - -### Changed - -- Remove JSON as an explicit dependency. ([@mperham](https://github.com/mperham)) - - -# History/News Archive -Items below this line are from the previously maintained HISTORY.md and NEWS.md files. - -## [2.0.0.rc1] 2016-05-06 -- Rack::Session::Abstract::ID is deprecated. Please change to use Rack::Session::Abstract::Persisted - -## [2.0.0.alpha] 2015-12-04 -- First-party "SameSite" cookies. Browsers omit SameSite cookies from third-party requests, closing the door on many CSRF attacks. -- Pass `same_site: true` (or `:strict`) to enable: response.set_cookie 'foo', value: 'bar', same_site: true or `same_site: :lax` to use Lax enforcement: response.set_cookie 'foo', value: 'bar', same_site: :lax -- Based on version 7 of the Same-site Cookies internet draft: - https://tools.ietf.org/html/draft-west-first-party-cookies-07 -- Thanks to Ben Toews (@mastahyeti) and Bob Long (@bobjflong) for updating to drafts 5 and 7. -- Add `Rack::Events` middleware for adding event based middleware: middleware that does not care about the response body, but only cares about doing work at particular points in the request / response lifecycle. -- Add `Rack::Request#authority` to calculate the authority under which the response is being made (this will be handy for h2 pushes). -- Add `Rack::Response::Helpers#cache_control` and `cache_control=`. Use this for setting cache control headers on your response objects. -- Add `Rack::Response::Helpers#etag` and `etag=`. Use this for setting etag values on the response. -- Introduce `Rack::Response::Helpers#add_header` to add a value to a multi-valued response header. Implemented in terms of other `Response#*_header` methods, so it's available to any response-like class that includes the `Helpers` module. -- Add `Rack::Request#add_header` to match. -- `Rack::Session::Abstract::ID` IS DEPRECATED. Please switch to `Rack::Session::Abstract::Persisted`. `Rack::Session::Abstract::Persisted` uses a request object rather than the `env` hash. -- Pull `ENV` access inside the request object in to a module. This will help with legacy Request objects that are ENV based but don't want to inherit from Rack::Request -- Move most methods on the `Rack::Request` to a module `Rack::Request::Helpers` and use public API to get values from the request object. This enables users to mix `Rack::Request::Helpers` in to their own objects so they can implement `(get|set|fetch|each)_header` as they see fit (for example a proxy object). -- Files and directories with + in the name are served correctly. Rather than unescaping paths like a form, we unescape with a URI parser using `Rack::Utils.unescape_path`. Fixes #265 -- Tempfiles are automatically closed in the case that there were too - many posted. -- Added methods for manipulating response headers that don't assume - they're stored as a Hash. Response-like classes may include the - Rack::Response::Helpers module if they define these methods: - - Rack::Response#has_header? - - Rack::Response#get_header - - Rack::Response#set_header - - Rack::Response#delete_header -- Introduce Util.get_byte_ranges that will parse the value of the HTTP_RANGE string passed to it without depending on the `env` hash. `byte_ranges` is deprecated in favor of this method. -- Change Session internals to use Request objects for looking up session information. This allows us to only allocate one request object when dealing with session objects (rather than doing it every time we need to manipulate cookies, etc). -- Add `Rack::Request#initialize_copy` so that the env is duped when the request gets duped. -- Added methods for manipulating request specific data. This includes - data set as CGI parameters, and just any arbitrary data the user wants - to associate with a particular request. New methods: - - Rack::Request#has_header? - - Rack::Request#get_header - - Rack::Request#fetch_header - - Rack::Request#each_header - - Rack::Request#set_header - - Rack::Request#delete_header -- lib/rack/utils.rb: add a method for constructing "delete" cookie - headers. This allows us to construct cookie headers without depending - on the side effects of mutating a hash. -- Prevent extremely deep parameters from being parsed. CVE-2015-3225 - -## [1.6.1] 2015-05-06 - - Fix CVE-2014-9490, denial of service attack in OkJson - - Use a monotonic time for Rack::Runtime, if available - - RACK_MULTIPART_LIMIT changed to RACK_MULTIPART_PART_LIMIT (RACK_MULTIPART_LIMIT is deprecated and will be removed in 1.7.0) - -## [1.5.3] 2015-05-06 - - Fix CVE-2014-9490, denial of service attack in OkJson - - Backport bug fixes to 1.5 series - -## [1.6.0] 2014-01-18 - - Response#unauthorized? helper - - Deflater now accepts an options hash to control compression on a per-request level - - Builder#warmup method for app preloading - - Request#accept_language method to extract HTTP_ACCEPT_LANGUAGE - - Add quiet mode of rack server, rackup --quiet - - Update HTTP Status Codes to RFC 7231 - - Less strict header name validation according to RFC 2616 - - SPEC updated to specify headers conform to RFC7230 specification - - Etag correctly marks etags as weak - - Request#port supports multiple x-http-forwarded-proto values - - Utils#multipart_part_limit configures the maximum number of parts a request can contain - - Default host to localhost when in development mode - - Various bugfixes and performance improvements - -## [1.5.2] 2013-02-07 - - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie - - Fix CVE-2013-0262, symlink path traversal in Rack::File - - Add various methods to Session for enhanced Rails compatibility - - Request#trusted_proxy? now only matches whole strings - - Add JSON cookie coder, to be default in Rack 1.6+ due to security concerns - - URLMap host matching in environments that don't set the Host header fixed - - Fix a race condition that could result in overwritten pidfiles - - Various documentation additions - -## [1.4.5] 2013-02-07 - - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie - - Fix CVE-2013-0262, symlink path traversal in Rack::File - -## [1.1.6, 1.2.8, 1.3.10] 2013-02-07 - - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie - -## [1.5.1] 2013-01-28 - - Rack::Lint check_hijack now conforms to other parts of SPEC - - Added hash-like methods to Abstract::ID::SessionHash for compatibility - - Various documentation corrections - -## [1.5.0] 2013-01-21 - - Introduced hijack SPEC, for before-response and after-response hijacking - - SessionHash is no longer a Hash subclass - - Rack::File cache_control parameter is removed, in place of headers options - - Rack::Auth::AbstractRequest#scheme now yields strings, not symbols - - Rack::Utils cookie functions now format expires in RFC 2822 format - - Rack::File now has a default mime type - - rackup -b 'run Rack::Files.new(".")', option provides command line configs - - Rack::Deflater will no longer double encode bodies - - Rack::Mime#match? provides convenience for Accept header matching - - Rack::Utils#q_values provides splitting for Accept headers - - Rack::Utils#best_q_match provides a helper for Accept headers - - Rack::Handler.pick provides convenience for finding available servers - - Puma added to the list of default servers (preferred over Webrick) - - Various middleware now correctly close body when replacing it - - Rack::Request#params is no longer persistent with only GET params - - Rack::Request#update_param and #delete_param provide persistent operations - - Rack::Request#trusted_proxy? now returns true for local unix sockets - - Rack::Response no longer forces Content-Types - - Rack::Sendfile provides local mapping configuration options - - Rack::Utils#rfc2109 provides old netscape style time output - - Updated HTTP status codes - - Ruby 1.8.6 likely no longer passes tests, and is no longer fully supported - -## [1.4.4, 1.3.9, 1.2.7, 1.1.5] 2013-01-13 - - [SEC] Rack::Auth::AbstractRequest no longer symbolizes arbitrary strings - - Fixed erroneous test case in the 1.3.x series - -## [1.4.3] 2013-01-07 - - Security: Prevent unbounded reads in large multipart boundaries - -## [1.3.8] 2013-01-07 - - Security: Prevent unbounded reads in large multipart boundaries - -## [1.4.2] 2013-01-06 - - Add warnings when users do not provide a session secret - - Fix parsing performance for unquoted filenames - - Updated URI backports - - Fix URI backport version matching, and silence constant warnings - - Correct parameter parsing with empty values - - Correct rackup '-I' flag, to allow multiple uses - - Correct rackup pidfile handling - - Report rackup line numbers correctly - - Fix request loops caused by non-stale nonces with time limits - - Fix reloader on Windows - - Prevent infinite recursions from Response#to_ary - - Various middleware better conforms to the body close specification - - Updated language for the body close specification - - Additional notes regarding ECMA escape compatibility issues - - Fix the parsing of multiple ranges in range headers - - Prevent errors from empty parameter keys - - Added PATCH verb to Rack::Request - - Various documentation updates - - Fix session merge semantics (fixes rack-test) - - Rack::Static :index can now handle multiple directories - - All tests now utilize Rack::Lint (special thanks to Lars Gierth) - - Rack::File cache_control parameter is now deprecated, and removed by 1.5 - - Correct Rack::Directory script name escaping - - Rack::Static supports header rules for sophisticated configurations - - Multipart parsing now works without a Content-Length header - - New logos courtesy of Zachary Scott! - - Rack::BodyProxy now explicitly defines #each, useful for C extensions - - Cookies that are not URI escaped no longer cause exceptions - -## [1.3.7] 2013-01-06 - - Add warnings when users do not provide a session secret - - Fix parsing performance for unquoted filenames - - Updated URI backports - - Fix URI backport version matching, and silence constant warnings - - Correct parameter parsing with empty values - - Correct rackup '-I' flag, to allow multiple uses - - Correct rackup pidfile handling - - Report rackup line numbers correctly - - Fix request loops caused by non-stale nonces with time limits - - Fix reloader on Windows - - Prevent infinite recursions from Response#to_ary - - Various middleware better conforms to the body close specification - - Updated language for the body close specification - - Additional notes regarding ECMA escape compatibility issues - - Fix the parsing of multiple ranges in range headers - -## [1.2.6] 2013-01-06 - - Add warnings when users do not provide a session secret - - Fix parsing performance for unquoted filenames - -## [1.1.4] 2013-01-06 - - Add warnings when users do not provide a session secret - -## [1.4.1] 2012-01-22 - - Alter the keyspace limit calculations to reduce issues with nested params - - Add a workaround for multipart parsing where files contain unescaped "%" - - Added Rack::Response::Helpers#method_not_allowed? (code 405) - - Rack::File now returns 404 for illegal directory traversals - - Rack::File now returns 405 for illegal methods (non HEAD/GET) - - Rack::Cascade now catches 405 by default, as well as 404 - - Cookies missing '--' no longer cause an exception to be raised - - Various style changes and documentation spelling errors - - Rack::BodyProxy always ensures to execute its block - - Additional test coverage around cookies and secrets - - Rack::Session::Cookie can now be supplied either secret or old_secret - - Tests are no longer dependent on set order - - Rack::Static no longer defaults to serving index files - - Rack.release was fixed - -## [1.4.0] 2011-12-28 - - Ruby 1.8.6 support has officially been dropped. Not all tests pass. - - Raise sane error messages for broken config.ru - - Allow combining run and map in a config.ru - - Rack::ContentType will not set Content-Type for responses without a body - - Status code 205 does not send a response body - - Rack::Response::Helpers will not rely on instance variables - - Rack::Utils.build_query no longer outputs '=' for nil query values - - Various mime types added - - Rack::MockRequest now supports HEAD - - Rack::Directory now supports files that contain RFC3986 reserved chars - - Rack::File now only supports GET and HEAD requests - - Rack::Server#start now passes the block to Rack::Handler::#run - - Rack::Static now supports an index option - - Added the Teapot status code - - rackup now defaults to Thin instead of Mongrel (if installed) - - Support added for HTTP_X_FORWARDED_SCHEME - - Numerous bug fixes, including many fixes for new and alternate rubies - -## [1.1.3] 2011-12-28 - - Security fix. http://www.ocert.org/advisories/ocert-2011-003.html - Further information here: http://jruby.org/2011/12/27/jruby-1-6-5-1 - -## [1.3.5] 2011-10-17 - - Fix annoying warnings caused by the backport in 1.3.4 - -## [1.3.4] 2011-10-01 - - Backport security fix from 1.9.3, also fixes some roundtrip issues in URI - - Small documentation update - - Fix an issue where BodyProxy could cause an infinite recursion - - Add some supporting files for travis-ci - -## [1.2.4] 2011-09-16 - - Fix a bug with MRI regex engine to prevent XSS by malformed unicode - -## [1.3.3] 2011-09-16 - - Fix bug with broken query parameters in Rack::ShowExceptions - - Rack::Request#cookies no longer swallows exceptions on broken input - - Prevents XSS attacks enabled by bug in Ruby 1.8's regexp engine - - Rack::ConditionalGet handles broken If-Modified-Since helpers - -## [1.3.2] 2011-07-16 - - Fix for Rails and rack-test, Rack::Utils#escape calls to_s - -## [1.3.1] 2011-07-13 - - Fix 1.9.1 support - - Fix JRuby support - - Properly handle $KCODE in Rack::Utils.escape - - Make method_missing/respond_to behavior consistent for Rack::Lock, - Rack::Auth::Digest::Request and Rack::Multipart::UploadedFile - - Reenable passing rack.session to session middleware - - Rack::CommonLogger handles streaming responses correctly - - Rack::MockResponse calls close on the body object - - Fix a DOS vector from MRI stdlib backport - -## [1.2.3] 2011-05-22 - - Pulled in relevant bug fixes from 1.3 - - Fixed 1.8.6 support - -## [1.3.0] 2011-05-22 - - Various performance optimizations - - Various multipart fixes - - Various multipart refactors - - Infinite loop fix for multipart - - Test coverage for Rack::Server returns - - Allow files with '..', but not path components that are '..' - - rackup accepts handler-specific options on the command line - - Request#params no longer merges POST into GET (but returns the same) - - Use URI.encode_www_form_component instead. Use core methods for escaping. - - Allow multi-line comments in the config file - - Bug L#94 reported by Nikolai Lugovoi, query parameter unescaping. - - Rack::Response now deletes Content-Length when appropriate - - Rack::Deflater now supports streaming - - Improved Rack::Handler loading and searching - - Support for the PATCH verb - - env['rack.session.options'] now contains session options - - Cookies respect renew - - Session middleware uses SecureRandom.hex - -## [1.2.2, 1.1.2] 2011-03-13 - - Security fix in Rack::Auth::Digest::MD5: when authenticator - returned nil, permission was granted on empty password. - -## [1.2.1] 2010-06-15 - - Make CGI handler rewindable - - Rename spec/ to test/ to not conflict with SPEC on lesser - operating systems - -## [1.2.0] 2010-06-13 - - Removed Camping adapter: Camping 2.0 supports Rack as-is - - Removed parsing of quoted values - - Add Request.trace? and Request.options? - - Add mime-type for .webm and .htc - - Fix HTTP_X_FORWARDED_FOR - - Various multipart fixes - - Switch test suite to bacon - -## [1.1.0] 2010-01-03 - - Moved Auth::OpenID to rack-contrib. - - SPEC change that relaxes Lint slightly to allow subclasses of the - required types - - SPEC change to document rack.input binary mode in greater detail - - SPEC define optional rack.logger specification - - File servers support X-Cascade header - - Imported Config middleware - - Imported ETag middleware - - Imported Runtime middleware - - Imported Sendfile middleware - - New Logger and NullLogger middlewares - - Added mime type for .ogv and .manifest. - - Don't squeeze PATH_INFO slashes - - Use Content-Type to determine POST params parsing - - Update Rack::Utils::HTTP_STATUS_CODES hash - - Add status code lookup utility - - Response should call #to_i on the status - - Add Request#user_agent - - Request#host knows about forwarded host - - Return an empty string for Request#host if HTTP_HOST and - SERVER_NAME are both missing - - Allow MockRequest to accept hash params - - Optimizations to HeaderHash - - Refactored rackup into Rack::Server - - Added Utils.build_nested_query to complement Utils.parse_nested_query - - Added Utils::Multipart.build_multipart to complement - Utils::Multipart.parse_multipart - - Extracted set and delete cookie helpers into Utils so they can be - used outside Response - - Extract parse_query and parse_multipart in Request so subclasses - can change their behavior - - Enforce binary encoding in RewindableInput - - Set correct external_encoding for handlers that don't use RewindableInput - -## [1.0.1] 2009-10-18 - - Bump remainder of rack.versions. - - Support the pure Ruby FCGI implementation. - - Fix for form names containing "=": split first then unescape components - - Fixes the handling of the filename parameter with semicolons in names. - - Add anchor to nested params parsing regexp to prevent stack overflows - - Use more compatible gzip write api instead of "<<". - - Make sure that Reloader doesn't break when executed via ruby -e - - Make sure WEBrick respects the :Host option - - Many Ruby 1.9 fixes. - -## [1.0.0] 2009-04-25 - - SPEC change: Rack::VERSION has been pushed to [1,0]. - - SPEC change: header values must be Strings now, split on "\n". - - SPEC change: Content-Length can be missing, in this case chunked transfer - encoding is used. - - SPEC change: rack.input must be rewindable and support reading into - a buffer, wrap with Rack::RewindableInput if it isn't. - - SPEC change: rack.session is now specified. - - SPEC change: Bodies can now additionally respond to #to_path with - a filename to be served. - - NOTE: String bodies break in 1.9, use an Array consisting of a - single String instead. - - New middleware Rack::Lock. - - New middleware Rack::ContentType. - - Rack::Reloader has been rewritten. - - Major update to Rack::Auth::OpenID. - - Support for nested parameter parsing in Rack::Response. - - Support for redirects in Rack::Response. - - HttpOnly cookie support in Rack::Response. - - The Rakefile has been rewritten. - - Many bugfixes and small improvements. - -## [0.9.1] 2009-01-09 - - Fix directory traversal exploits in Rack::File and Rack::Directory. - -## [0.9] 2009-01-06 - - Rack is now managed by the Rack Core Team. - - Rack::Lint is stricter and follows the HTTP RFCs more closely. - - Added ConditionalGet middleware. - - Added ContentLength middleware. - - Added Deflater middleware. - - Added Head middleware. - - Added MethodOverride middleware. - - Rack::Mime now provides popular MIME-types and their extension. - - Mongrel Header now streams. - - Added Thin handler. - - Official support for swiftiplied Mongrel. - - Secure cookies. - - Made HeaderHash case-preserving. - - Many bugfixes and small improvements. - -## [0.4] 2008-08-21 - - New middleware, Rack::Deflater, by Christoffer Sawicki. - - OpenID authentication now needs ruby-openid 2. - - New Memcache sessions, by blink. - - Explicit EventedMongrel handler, by Joshua Peek - - Rack::Reloader is not loaded in rackup development mode. - - rackup can daemonize with -D. - - Many bugfixes, especially for pool sessions, URLMap, thread safety - and tempfile handling. - - Improved tests. - - Rack moved to Git. - -## [0.3] 2008-02-26 - - LiteSpeed handler, by Adrian Madrid. - - SCGI handler, by Jeremy Evans. - - Pool sessions, by blink. - - OpenID authentication, by blink. - - :Port and :File options for opening FastCGI sockets, by blink. - - Last-Modified HTTP header for Rack::File, by blink. - - Rack::Builder#use now accepts blocks, by Corey Jewett. - (See example/protectedlobster.ru) - - HTTP status 201 can contain a Content-Type and a body now. - - Many bugfixes, especially related to Cookie handling. - -## [0.2] 2007-05-16 - - HTTP Basic authentication. - - Cookie Sessions. - - Static file handler. - - Improved Rack::Request. - - Improved Rack::Response. - - Added Rack::ShowStatus, for better default error messages. - - Bug fixes in the Camping adapter. - - Removed Rails adapter, was too alpha. - -## [0.1] 2007-03-03 - -[@ioquatix]: https://github.com/ioquatix "Samuel Williams" -[@jeremyevans]: https://github.com/jeremyevans "Jeremy Evans" -[@amatsuda]: https://github.com/amatsuda "Akira Matsuda" -[@wjordan]: https://github.com/wjordan "Will Jordan" -[@BlakeWilliams]: https://github.com/BlakeWilliams "Blake Williams" -[@davidstosik]: https://github.com/davidstosik "David Stosik" diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CONTRIBUTING.md b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CONTRIBUTING.md deleted file mode 100644 index a95263d..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/CONTRIBUTING.md +++ /dev/null @@ -1,144 +0,0 @@ -# Contributing to Rack - -Rack is work of [hundreds of -contributors](https://github.com/rack/rack/graphs/contributors). You're -encouraged to submit [pull requests](https://github.com/rack/rack/pulls) and -[propose features and discuss issues](https://github.com/rack/rack/issues). - -## Backports - -Only security patches are ideal for backporting to non-main release versions. If -you're not sure if your bug fix is backportable, you should open a discussion to -discuss it first. - -The [Security Policy] documents which release versions will receive security -backports. - -## Fork the Project - -Fork the [project on GitHub](https://github.com/rack/rack) and check out your -copy. - -``` -git clone https://github.com/(your-github-username)/rack.git -cd rack -git remote add upstream https://github.com/rack/rack.git -``` - -## Create a Topic Branch - -Make sure your fork is up-to-date and create a topic branch for your feature or -bug fix. - -``` -git checkout main -git pull upstream main -git checkout -b my-feature-branch -``` - -## Running All Tests - -Install all dependencies. - -``` -bundle install -``` - -Run all tests. - -``` -rake test -``` - -## Write Tests - -Try to write a test that reproduces the problem you're trying to fix or -describes a feature that you want to build. - -We definitely appreciate pull requests that highlight or reproduce a problem, -even without a fix. - -## Write Code - -Implement your feature or bug fix. - -Make sure that all tests pass: - -``` -bundle exec rake test -``` - -## Write Documentation - -Document any external behavior in the [README](README.md). - -## Update Changelog - -Add a line to [CHANGELOG](CHANGELOG.md). - -## Commit Changes - -Make sure git knows your name and email address: - -``` -git config --global user.name "Your Name" -git config --global user.email "contributor@example.com" -``` - -Writing good commit logs is important. A commit log should describe what changed -and why. - -``` -git add ... -git commit -``` - -## Push - -``` -git push origin my-feature-branch -``` - -## Make a Pull Request - -Go to your fork of rack on GitHub and select your feature branch. Click the -'Pull Request' button and fill out the form. Pull requests are usually -reviewed within a few days. - -## Rebase - -If you've been working on a change for a while, rebase with upstream/main. - -``` -git fetch upstream -git rebase upstream/main -git push origin my-feature-branch -f -``` - -## Make Required Changes - -Amend your previous commit and force push the changes. - -``` -git commit --amend -git push origin my-feature-branch -f -``` - -## Check on Your Pull Request - -Go back to your pull request after a few minutes and see whether it passed -tests with GitHub Actions. Everything should look green, otherwise fix issues and -amend your commit as described above. - -## Be Patient - -It's likely that your change will not be merged and that the nitpicky -maintainers will ask you to do more, or fix seemingly benign problems. Hang in -there! - -## Thank You - -Please do know that we really appreciate and value your time and work. We love -you, really. - -[Security Policy]: SECURITY.md diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/MIT-LICENSE b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/MIT-LICENSE deleted file mode 100644 index fb33b7f..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/MIT-LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -The MIT License (MIT) - -Copyright (C) 2007-2021 Leah Neukirchen - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL -THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/README.md b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/README.md deleted file mode 100644 index 3a197b1..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/README.md +++ /dev/null @@ -1,328 +0,0 @@ -# ![Rack](contrib/logo.webp) - -Rack provides a minimal, modular, and adaptable interface for developing web -applications in Ruby. By wrapping HTTP requests and responses in the simplest -way possible, it unifies and distills the bridge between web servers, web -frameworks, and web application into a single method call. - -The exact details of this are described in the [Rack Specification], which all -Rack applications should conform to. - -## Version support - -| Version | Support | -|----------|------------------------------------| -| 3.0.x | Bug fixes and security patches. | -| 2.2.x | Security patches only. | -| <= 2.1.x | End of support. | - -Please see the [Security Policy] for more information. - -## Rack 3.0 - -This is the latest version of Rack. It contains API improvements but also some -breaking changes. Please check the [Upgrade Guide](UPGRADE-GUIDE.md) for more -details about migrating servers, middlewares and applications designed for Rack 2 -to Rack 3. For detailed information on specific changes, check the [Change Log](CHANGELOG.md). - -## Rack 2.2 - -This version of Rack is receiving security patches only, and effort should be -made to move to Rack 3. - -Starting in Ruby 3.4 the `base64` dependency will no longer be a default gem, -and may cause a warning or error about `base64` being missing. To correct this, -add `base64` as a dependency to your project. - -## Installation - -Add the rack gem to your application bundle, or follow the instructions provided -by a [supported web framework](#supported-web-frameworks): - -```bash -# Install it generally: -$ gem install rack - -# or, add it to your current application gemfile: -$ bundle add rack -``` - -If you need features from `Rack::Session` or `bin/rackup` please add those gems separately. - -```bash -$ gem install rack-session rackup -``` - -## Usage - -Create a file called `config.ru` with the following contents: - -```ruby -run do |env| - [200, {}, ["Hello World"]] -end -``` - -Run this using the rackup gem or another [supported web -server](#supported-web-servers). - -```bash -$ gem install rackup -$ rackup -$ curl http://localhost:9292 -Hello World -``` - -## Supported web servers - -Rack is supported by a wide range of servers, including: - -* [Agoo](https://github.com/ohler55/agoo) -* [Falcon](https://github.com/socketry/falcon) -* [Iodine](https://github.com/boazsegev/iodine) -* [NGINX Unit](https://unit.nginx.org/) -* [Phusion Passenger](https://www.phusionpassenger.com/) (which is mod_rack for - Apache and for nginx) -* [Puma](https://puma.io/) -* [Thin](https://github.com/macournoyer/thin) -* [Unicorn](https://yhbt.net/unicorn/) -* [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) -* [Lamby](https://lamby.custominktech.com) (for AWS Lambda) - -You will need to consult the server documentation to find out what features and -limitations they may have. In general, any valid Rack app will run the same on -all these servers, without changing anything. - -### Rackup - -Rack provides a separate gem, [rackup](https://github.com/rack/rackup) which is -a generic interface for running a Rack application on supported servers, which -include `WEBRick`, `Puma`, `Falcon` and others. - -## Supported web frameworks - -These frameworks and many others support the [Rack Specification]: - -* [Camping](https://github.com/camping/camping) -* [Hanami](https://hanamirb.org/) -* [Ramaze](https://github.com/ramaze/ramaze) -* [Padrino](https://padrinorb.com/) -* [Roda](https://github.com/jeremyevans/roda) -* [Ruby on Rails](https://rubyonrails.org/) -* [Rum](https://github.com/leahneukirchen/rum) -* [Sinatra](https://sinatrarb.com/) -* [Utopia](https://github.com/socketry/utopia) -* [WABuR](https://github.com/ohler55/wabur) - -## Available middleware shipped with Rack - -Between the server and the framework, Rack can be customized to your -applications needs using middleware. Rack itself ships with the following -middleware: - -* `Rack::CommonLogger` for creating Apache-style logfiles. -* `Rack::ConditionalGet` for returning [Not - Modified](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) - responses when the response has not changed. -* `Rack::Config` for modifying the environment before processing the request. -* `Rack::ContentLength` for setting a `content-length` header based on body - size. -* `Rack::ContentType` for setting a default `content-type` header for responses. -* `Rack::Deflater` for compressing responses with gzip. -* `Rack::ETag` for setting `etag` header on bodies that can be buffered. -* `Rack::Events` for providing easy hooks when a request is received and when - the response is sent. -* `Rack::Files` for serving static files. -* `Rack::Head` for returning an empty body for HEAD requests. -* `Rack::Lint` for checking conformance to the [Rack Specification]. -* `Rack::Lock` for serializing requests using a mutex. -* `Rack::Logger` for setting a logger to handle logging errors. -* `Rack::MethodOverride` for modifying the request method based on a submitted - parameter. -* `Rack::Recursive` for including data from other paths in the application, and - for performing internal redirects. -* `Rack::Reloader` for reloading files if they have been modified. -* `Rack::Runtime` for including a response header with the time taken to process - the request. -* `Rack::Sendfile` for working with web servers that can use optimized file - serving for file system paths. -* `Rack::ShowException` for catching unhandled exceptions and presenting them in - a nice and helpful way with clickable backtrace. -* `Rack::ShowStatus` for using nice error pages for empty client error - responses. -* `Rack::Static` for more configurable serving of static files. -* `Rack::TempfileReaper` for removing temporary files creating during a request. - -All these components use the same interface, which is described in detail in the -[Rack Specification]. These optional components can be used in any way you wish. - -### Convenience interfaces - -If you want to develop outside of existing frameworks, implement your own ones, -or develop middleware, Rack provides many helpers to create Rack applications -quickly and without doing the same web stuff all over: - -* `Rack::Request` which also provides query string parsing and multipart - handling. -* `Rack::Response` for convenient generation of HTTP replies and cookie - handling. -* `Rack::MockRequest` and `Rack::MockResponse` for efficient and quick testing - of Rack application without real HTTP round-trips. -* `Rack::Cascade` for trying additional Rack applications if an application - returns a not found or method not supported response. -* `Rack::Directory` for serving files under a given directory, with directory - indexes. -* `Rack::MediaType` for parsing content-type headers. -* `Rack::Mime` for determining content-type based on file extension. -* `Rack::RewindableInput` for making any IO object rewindable, using a temporary - file buffer. -* `Rack::URLMap` to route to multiple applications inside the same process. - -## Configuration - -Rack exposes several configuration parameters to control various features of the -implementation. - -### `param_depth_limit` - -```ruby -Rack::Utils.param_depth_limit = 32 # default -``` - -The maximum amount of nesting allowed in parameters. For example, if set to 3, -this query string would be allowed: - -``` -?a[b][c]=d -``` - -but this query string would not be allowed: - -``` -?a[b][c][d]=e -``` - -Limiting the depth prevents a possible stack overflow when parsing parameters. - -### `multipart_file_limit` - -```ruby -Rack::Utils.multipart_file_limit = 128 # default -``` - -The maximum number of parts with a filename a request can contain. Accepting -too many parts can lead to the server running out of file handles. - -The default is 128, which means that a single request can't upload more than 128 -files at once. Set to 0 for no limit. - -Can also be set via the `RACK_MULTIPART_FILE_LIMIT` environment variable. - -(This is also aliased as `multipart_part_limit` and `RACK_MULTIPART_PART_LIMIT` for compatibility) - - -### `multipart_total_part_limit` - -The maximum total number of parts a request can contain of any type, including -both file and non-file form fields. - -The default is 4096, which means that a single request can't contain more than -4096 parts. - -Set to 0 for no limit. - -Can also be set via the `RACK_MULTIPART_TOTAL_PART_LIMIT` environment variable. - - -## Changelog - -See [CHANGELOG.md](CHANGELOG.md). - -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md) for specific details about how to make a -contribution to Rack. - -Please post bugs, suggestions and patches to [GitHub -Issues](https://github.com/rack/rack/issues). - -Please check our [Security Policy](https://github.com/rack/rack/security/policy) -for responsible disclosure and security bug reporting process. Due to wide usage -of the library, it is strongly preferred that we manage timing in order to -provide viable patches at the time of disclosure. Your assistance in this matter -is greatly appreciated. - -## See Also - -### `rack-contrib` - -The plethora of useful middleware created the need for a project that collects -fresh Rack middleware. `rack-contrib` includes a variety of add-on components -for Rack and it is easy to contribute new modules. - -* https://github.com/rack/rack-contrib - -### `rack-session` - -Provides convenient session management for Rack. - -* https://github.com/rack/rack-session - -## Thanks - -The Rack Core Team, consisting of - -* Aaron Patterson [tenderlove](https://github.com/tenderlove) -* Samuel Williams [ioquatix](https://github.com/ioquatix) -* Jeremy Evans [jeremyevans](https://github.com/jeremyevans) -* Eileen Uchitelle [eileencodes](https://github.com/eileencodes) -* Matthew Draper [matthewd](https://github.com/matthewd) -* Rafael França [rafaelfranca](https://github.com/rafaelfranca) - -and the Rack Alumni - -* Ryan Tomayko [rtomayko](https://github.com/rtomayko) -* Scytrin dai Kinthra [scytrin](https://github.com/scytrin) -* Leah Neukirchen [leahneukirchen](https://github.com/leahneukirchen) -* James Tucker [raggi](https://github.com/raggi) -* Josh Peek [josh](https://github.com/josh) -* José Valim [josevalim](https://github.com/josevalim) -* Michael Fellinger [manveru](https://github.com/manveru) -* Santiago Pastorino [spastorino](https://github.com/spastorino) -* Konstantin Haase [rkh](https://github.com/rkh) - -would like to thank: - -* Adrian Madrid, for the LiteSpeed handler. -* Christoffer Sawicki, for the first Rails adapter and `Rack::Deflater`. -* Tim Fletcher, for the HTTP authentication code. -* Luc Heinrich for the Cookie sessions, the static file handler and bugfixes. -* Armin Ronacher, for the logo and racktools. -* Alex Beregszaszi, Alexander Kahn, Anil Wadghule, Aredridel, Ben Alpert, Dan - Kubb, Daniel Roethlisberger, Matt Todd, Tom Robinson, Phil Hagelberg, S. Brent - Faulkner, Bosko Milekic, Daniel Rodríguez Troitiño, Genki Takiuchi, Geoffrey - Grosenbach, Julien Sanchez, Kamal Fariz Mahyuddin, Masayoshi Takahashi, - Patrick Aljordm, Mig, Kazuhiro Nishiyama, Jon Bardin, Konstantin Haase, Larry - Siden, Matias Korhonen, Sam Ruby, Simon Chiang, Tim Connor, Timur Batyrshin, - and Zach Brock for bug fixing and other improvements. -* Eric Wong, Hongli Lai, Jeremy Kemper for their continuous support and API - improvements. -* Yehuda Katz and Carl Lerche for refactoring rackup. -* Brian Candler, for `Rack::ContentType`. -* Graham Batty, for improved handler loading. -* Stephen Bannasch, for bug reports and documentation. -* Gary Wright, for proposing a better `Rack::Response` interface. -* Jonathan Buch, for improvements regarding `Rack::Response`. -* Armin Röhrl, for tracking down bugs in the Cookie generator. -* Alexander Kellett for testing the Gem and reviewing the announcement. -* Marcus Rückert, for help with configuring and debugging lighttpd. -* The WSGI team for the well-done and documented work they've done and Rack - builds up on. -* All bug reporters and patch contributors not mentioned above. - -## License - -Rack is released under the [MIT License](MIT-LICENSE). - -[Rack Specification]: SPEC.rdoc -[Security Policy]: SECURITY.md diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/SPEC.rdoc b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/SPEC.rdoc deleted file mode 100644 index ed5d982..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/SPEC.rdoc +++ /dev/null @@ -1,365 +0,0 @@ -This specification aims to formalize the Rack protocol. You -can (and should) use Rack::Lint to enforce it. - -When you develop middleware, be sure to add a Lint before and -after to catch all mistakes. - -= Rack applications - -A Rack application is a Ruby object (not a class) that -responds to +call+. -It takes exactly one argument, the *environment* -and returns a non-frozen Array of exactly three values: -The *status*, -the *headers*, -and the *body*. - -== The Environment - -The environment must be an unfrozen instance of Hash that includes -CGI-like headers. The Rack application is free to modify the -environment. - -The environment is required to include these variables -(adopted from {PEP 333}[https://peps.python.org/pep-0333/]), except when they'd be empty, but see -below. -REQUEST_METHOD:: The HTTP request method, such as - "GET" or "POST". This cannot ever - be an empty string, and so is - always required. -SCRIPT_NAME:: The initial portion of the request - URL's "path" that corresponds to the - application object, so that the - application knows its virtual - "location". This may be an empty - string, if the application corresponds - to the "root" of the server. -PATH_INFO:: The remainder of the request URL's - "path", designating the virtual - "location" of the request's target - within the application. This may be an - empty string, if the request URL targets - the application root and does not have a - trailing slash. This value may be - percent-encoded when originating from - a URL. -QUERY_STRING:: The portion of the request URL that - follows the ?, if any. May be - empty, but is always required! -SERVER_NAME:: When combined with SCRIPT_NAME and - PATH_INFO, these variables can be - used to complete the URL. Note, however, - that HTTP_HOST, if present, - should be used in preference to - SERVER_NAME for reconstructing - the request URL. - SERVER_NAME can never be an empty - string, and so is always required. -SERVER_PORT:: An optional +Integer+ which is the port the - server is running on. Should be specified if - the server is running on a non-standard port. -SERVER_PROTOCOL:: A string representing the HTTP version used - for the request. -HTTP_ Variables:: Variables corresponding to the - client-supplied HTTP request - headers (i.e., variables whose - names begin with HTTP_). The - presence or absence of these - variables should correspond with - the presence or absence of the - appropriate HTTP header in the - request. See - {RFC3875 section 4.1.18}[https://tools.ietf.org/html/rfc3875#section-4.1.18] - for specific behavior. -In addition to this, the Rack environment must include these -Rack-specific variables: -rack.url_scheme:: +http+ or +https+, depending on the - request URL. -rack.input:: See below, the input stream. -rack.errors:: See below, the error stream. -rack.hijack?:: See below, if present and true, indicates - that the server supports partial hijacking. -rack.hijack:: See below, if present, an object responding - to +call+ that is used to perform a full - hijack. -rack.protocol:: An optional +Array+ of +String+, containing - the protocols advertised by the client in - the +upgrade+ header (HTTP/1) or the - +:protocol+ pseudo-header (HTTP/2). -Additional environment specifications have approved to -standardized middleware APIs. None of these are required to -be implemented by the server. -rack.session:: A hash-like interface for storing - request session data. - The store must implement: - store(key, value) (aliased as []=); - fetch(key, default = nil) (aliased as []); - delete(key); - clear; - to_hash (returning unfrozen Hash instance); -rack.logger:: A common object interface for logging messages. - The object must implement: - info(message, &block) - debug(message, &block) - warn(message, &block) - error(message, &block) - fatal(message, &block) -rack.multipart.buffer_size:: An Integer hint to the multipart parser as to what chunk size to use for reads and writes. -rack.multipart.tempfile_factory:: An object responding to #call with two arguments, the filename and content_type given for the multipart form field, and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate the tempfile for each multipart form file upload field, rather than the default class of Tempfile. -The server or the application can store their own data in the -environment, too. The keys must contain at least one dot, -and should be prefixed uniquely. The prefix rack. -is reserved for use with the Rack core distribution and other -accepted specifications and must not be used otherwise. - -The SERVER_PORT must be an Integer if set. -The SERVER_NAME must be a valid authority as defined by RFC7540. -The HTTP_HOST must be a valid authority as defined by RFC7540. -The SERVER_PROTOCOL must match the regexp HTTP/\d(\.\d)?. -The environment must not contain the keys -HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH -(use the versions without HTTP_). -The CGI keys (named without a period) must have String values. -If the string values for CGI keys contain non-ASCII characters, -they should use ASCII-8BIT encoding. -There are the following restrictions: -* rack.url_scheme must either be +http+ or +https+. -* There may be a valid input stream in rack.input. -* There must be a valid error stream in rack.errors. -* There may be a valid hijack callback in rack.hijack -* There may be a valid early hints callback in rack.early_hints -* The REQUEST_METHOD must be a valid token. -* The SCRIPT_NAME, if non-empty, must start with / -* The PATH_INFO, if provided, must be a valid request target or an empty string. - * Only OPTIONS requests may have PATH_INFO set to * (asterisk-form). - * Only CONNECT requests may have PATH_INFO set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. - * CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form). - * Otherwise, PATH_INFO must start with a / and must not include a fragment part starting with '#' (origin-form). -* The CONTENT_LENGTH, if given, must consist of digits only. -* One of SCRIPT_NAME or PATH_INFO must be - set. PATH_INFO should be / if - SCRIPT_NAME is empty. - SCRIPT_NAME never should be /, but instead be empty. -rack.response_finished:: An array of callables run by the server after the response has been -processed. This would typically be invoked after sending the response to the client, but it could also be -invoked if an error occurs while generating the response or sending the response; in that case, the error -argument will be a subclass of +Exception+. -The callables are invoked with +env, status, headers, error+ arguments and should not raise any -exceptions. They should be invoked in reverse order of registration. - -=== The Input Stream - -The input stream is an IO-like object which contains the raw HTTP -POST data. -When applicable, its external encoding must be "ASCII-8BIT" and it -must be opened in binary mode. -The input stream must respond to +gets+, +each+, and +read+. -* +gets+ must be called without arguments and return a string, - or +nil+ on EOF. -* +read+ behaves like IO#read. - Its signature is read([length, [buffer]]). - - If given, +length+ must be a non-negative Integer (>= 0) or +nil+, - and +buffer+ must be a String and may not be nil. - - If +length+ is given and not nil, then this method reads at most - +length+ bytes from the input stream. - - If +length+ is not given or nil, then this method reads - all data until EOF. - - When EOF is reached, this method returns nil if +length+ is given - and not nil, or "" if +length+ is not given or is nil. - - If +buffer+ is given, then the read data will be placed - into +buffer+ instead of a newly created String object. -* +each+ must be called without arguments and only yield Strings. -* +close+ can be called on the input stream to indicate that - any remaining input is not needed. - -=== The Error Stream - -The error stream must respond to +puts+, +write+ and +flush+. -* +puts+ must be called with a single argument that responds to +to_s+. -* +write+ must be called with a single argument that is a String. -* +flush+ must be called without arguments and must be called - in order to make the error appear for sure. -* +close+ must never be called on the error stream. - -=== Hijacking - -The hijacking interfaces provides a means for an application to take -control of the HTTP connection. There are two distinct hijack -interfaces: full hijacking where the application takes over the raw -connection, and partial hijacking where the application takes over -just the response body stream. In both cases, the application is -responsible for closing the hijacked stream. - -Full hijacking only works with HTTP/1. Partial hijacking is functionally -equivalent to streaming bodies, and is still optionally supported for -backwards compatibility with older Rack versions. - -==== Full Hijack - -Full hijack is used to completely take over an HTTP/1 connection. It -occurs before any headers are written and causes the request to -ignores any response generated by the application. - -It is intended to be used when applications need access to raw HTTP/1 -connection. - -If +rack.hijack+ is present in +env+, it must respond to +call+ -and return an +IO+ instance which can be used to read and write -to the underlying connection using HTTP/1 semantics and -formatting. - -==== Partial Hijack - -Partial hijack is used for bi-directional streaming of the request and -response body. It occurs after the status and headers are written by -the server and causes the server to ignore the Body of the response. - -It is intended to be used when applications need bi-directional -streaming. - -If +rack.hijack?+ is present in +env+ and truthy, -an application may set the special response header +rack.hijack+ -to an object that responds to +call+, -accepting a +stream+ argument. - -After the response status and headers have been sent, this hijack -callback will be invoked with a +stream+ argument which follows the -same interface as outlined in "Streaming Body". Servers must -ignore the +body+ part of the response tuple when the -+rack.hijack+ response header is present. Using an empty +Array+ -instance is recommended. - -The special response header +rack.hijack+ must only be set -if the request +env+ has a truthy +rack.hijack?+. - -=== Early Hints - -The application or any middleware may call the rack.early_hints -with an object which would be valid as the headers of a Rack response. - -If rack.early_hints is present, it must respond to #call. -If rack.early_hints is called, it must be called with -valid Rack response headers. - -== The Response - -=== The Status - -This is an HTTP status. It must be an Integer greater than or equal to -100. - -=== The Headers - -The headers must be a unfrozen Hash. -The header keys must be Strings. -Special headers starting "rack." are for communicating with the -server, and must not be sent back to the client. -The header must not contain a +Status+ key. -Header keys must conform to RFC7230 token specification, i.e. cannot -contain non-printable ASCII, DQUOTE or "(),/:;<=>?@[\]{}". -Header keys must not contain uppercase ASCII characters (A-Z). -Header values must be either a String instance, -or an Array of String instances, -such that each String instance must not contain characters below 037. - -==== The +content-type+ Header - -There must not be a content-type header key when the +Status+ is 1xx, -204, or 304. - -==== The +content-length+ Header - -There must not be a content-length header key when the -+Status+ is 1xx, 204, or 304. - -==== The +rack.protocol+ Header - -If the +rack.protocol+ header is present, it must be a +String+, and -must be one of the values from the +rack.protocol+ array from the -environment. - -Setting this value informs the server that it should perform a -connection upgrade. In HTTP/1, this is done using the +upgrade+ -header. In HTTP/2, this is done by accepting the request. - -=== The Body - -The Body is typically an +Array+ of +String+ instances, an enumerable -that yields +String+ instances, a +Proc+ instance, or a File-like -object. - -The Body must respond to +each+ or +call+. It may optionally respond -to +to_path+ or +to_ary+. A Body that responds to +each+ is considered -to be an Enumerable Body. A Body that responds to +call+ is considered -to be a Streaming Body. - -A Body that responds to both +each+ and +call+ must be treated as an -Enumerable Body, not a Streaming Body. If it responds to +each+, you -must call +each+ and not +call+. If the Body doesn't respond to -+each+, then you can assume it responds to +call+. - -The Body must either be consumed or returned. The Body is consumed by -optionally calling either +each+ or +call+. -Then, if the Body responds to +close+, it must be called to release -any resources associated with the generation of the body. -In other words, +close+ must always be called at least once; typically -after the web server has sent the response to the client, but also in -cases where the Rack application makes internal/virtual requests and -discards the response. - - -After calling +close+, the Body is considered closed and should not -be consumed again. -If the original Body is replaced by a new Body, the new Body must -also consume the original Body by calling +close+ if possible. - -If the Body responds to +to_path+, it must return a +String+ -path for the local file system whose contents are identical -to that produced by calling +each+; this may be used by the -server as an alternative, possibly more efficient way to -transport the response. The +to_path+ method does not consume -the body. - -==== Enumerable Body - -The Enumerable Body must respond to +each+. -It must only be called once. -It must not be called after being closed, -and must only yield String values. - -Middleware must not call +each+ directly on the Body. -Instead, middleware can return a new Body that calls +each+ on the -original Body, yielding at least once per iteration. - -If the Body responds to +to_ary+, it must return an +Array+ whose -contents are identical to that produced by calling +each+. -Middleware may call +to_ary+ directly on the Body and return a new -Body in its place. In other words, middleware can only process the -Body directly if it responds to +to_ary+. If the Body responds to both -+to_ary+ and +close+, its implementation of +to_ary+ must call -+close+. - -==== Streaming Body - -The Streaming Body must respond to +call+. -It must only be called once. -It must not be called after being closed. -It takes a +stream+ argument. - -The +stream+ argument must implement: -read, write, <<, flush, close, close_read, close_write, closed? - -The semantics of these IO methods must be a best effort match to -those of a normal Ruby IO or Socket object, using standard arguments -and raising standard exceptions. Servers are encouraged to simply -pass on real IO objects, although it is recognized that this approach -is not directly compatible with HTTP/2. - -== Thanks -Some parts of this specification are adopted from {PEP 333 – Python Web Server Gateway Interface v1.0}[https://peps.python.org/pep-0333/] -I'd like to thank everyone involved in that effort. diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack.rb deleted file mode 100644 index 6021248..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack.rb +++ /dev/null @@ -1,66 +0,0 @@ -# socket-patch: patched rack-3.1.8 (spike marker) -# frozen_string_literal: true - -# Copyright (C) 2007-2019 Leah Neukirchen -# -# Rack is freely distributable under the terms of an MIT-style license. -# See MIT-LICENSE or https://opensource.org/licenses/MIT. - -# The Rack main module, serving as a namespace for all core Rack -# modules and classes. -# -# All modules meant for use in your application are autoloaded here, -# so it should be enough just to require 'rack' in your code. - -require_relative 'rack/version' -require_relative 'rack/constants' - -module Rack - autoload :BadRequest, "rack/bad_request" - autoload :BodyProxy, "rack/body_proxy" - autoload :Builder, "rack/builder" - autoload :Cascade, "rack/cascade" - autoload :CommonLogger, "rack/common_logger" - autoload :ConditionalGet, "rack/conditional_get" - autoload :Config, "rack/config" - autoload :ContentLength, "rack/content_length" - autoload :ContentType, "rack/content_type" - autoload :Deflater, "rack/deflater" - autoload :Directory, "rack/directory" - autoload :ETag, "rack/etag" - autoload :Events, "rack/events" - autoload :Files, "rack/files" - autoload :ForwardRequest, "rack/recursive" - autoload :Head, "rack/head" - autoload :Headers, "rack/headers" - autoload :Lint, "rack/lint" - autoload :Lock, "rack/lock" - autoload :Logger, "rack/logger" - autoload :MediaType, "rack/media_type" - autoload :MethodOverride, "rack/method_override" - autoload :Mime, "rack/mime" - autoload :MockRequest, "rack/mock_request" - autoload :MockResponse, "rack/mock_response" - autoload :Multipart, "rack/multipart" - autoload :NullLogger, "rack/null_logger" - autoload :QueryParser, "rack/query_parser" - autoload :Recursive, "rack/recursive" - autoload :Reloader, "rack/reloader" - autoload :Request, "rack/request" - autoload :Response, "rack/response" - autoload :RewindableInput, "rack/rewindable_input" - autoload :Runtime, "rack/runtime" - autoload :Sendfile, "rack/sendfile" - autoload :ShowExceptions, "rack/show_exceptions" - autoload :ShowStatus, "rack/show_status" - autoload :Static, "rack/static" - autoload :TempfileReaper, "rack/tempfile_reaper" - autoload :URLMap, "rack/urlmap" - autoload :Utils, "rack/utils" - - module Auth - autoload :Basic, "rack/auth/basic" - autoload :AbstractHandler, "rack/auth/abstract/handler" - autoload :AbstractRequest, "rack/auth/abstract/request" - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb deleted file mode 100644 index 4731ee8..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/handler.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../constants' - -module Rack - module Auth - # Rack::Auth::AbstractHandler implements common authentication functionality. - # - # +realm+ should be set for all handlers. - - class AbstractHandler - - attr_accessor :realm - - def initialize(app, realm = nil, &authenticator) - @app, @realm, @authenticator = app, realm, authenticator - end - - - private - - def unauthorized(www_authenticate = challenge) - return [ 401, - { CONTENT_TYPE => 'text/plain', - CONTENT_LENGTH => '0', - 'www-authenticate' => www_authenticate.to_s }, - [] - ] - end - - def bad_request - return [ 400, - { CONTENT_TYPE => 'text/plain', - CONTENT_LENGTH => '0' }, - [] - ] - end - - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb deleted file mode 100644 index f872331..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/abstract/request.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../request' - -module Rack - module Auth - class AbstractRequest - - def initialize(env) - @env = env - end - - def request - @request ||= Request.new(@env) - end - - def provided? - !authorization_key.nil? && valid? - end - - def valid? - !@env[authorization_key].nil? - end - - def parts - @parts ||= @env[authorization_key].split(' ', 2) - end - - def scheme - @scheme ||= parts.first&.downcase - end - - def params - @params ||= parts.last - end - - - private - - AUTHORIZATION_KEYS = ['HTTP_AUTHORIZATION', 'X-HTTP_AUTHORIZATION', 'X_HTTP_AUTHORIZATION'] - - def authorization_key - @authorization_key ||= AUTHORIZATION_KEYS.detect { |key| @env.has_key?(key) } - end - - end - - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/basic.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/basic.rb deleted file mode 100644 index 67ffc49..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/auth/basic.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require_relative 'abstract/handler' -require_relative 'abstract/request' - -module Rack - module Auth - # Rack::Auth::Basic implements HTTP Basic Authentication, as per RFC 2617. - # - # Initialize with the Rack application that you want protecting, - # and a block that checks if a username and password pair are valid. - - class Basic < AbstractHandler - - def call(env) - auth = Basic::Request.new(env) - - return unauthorized unless auth.provided? - - return bad_request unless auth.basic? - - if valid?(auth) - env['REMOTE_USER'] = auth.username - - return @app.call(env) - end - - unauthorized - end - - - private - - def challenge - 'Basic realm="%s"' % realm - end - - def valid?(auth) - @authenticator.call(*auth.credentials) - end - - class Request < Auth::AbstractRequest - def basic? - "basic" == scheme && credentials.length == 2 - end - - def credentials - @credentials ||= params.unpack1('m').split(':', 2) - end - - def username - credentials.first - end - end - - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/bad_request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/bad_request.rb deleted file mode 100644 index 8eaa94e..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/bad_request.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Represents a 400 Bad Request error when input data fails to meet the - # requirements. - module BadRequest - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/body_proxy.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/body_proxy.rb deleted file mode 100644 index 7291579..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/body_proxy.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Proxy for response bodies allowing calling a block when - # the response body is closed (after the response has been fully - # sent to the client). - class BodyProxy - # Set the response body to wrap, and the block to call when the - # response has been fully sent. - def initialize(body, &block) - @body = body - @block = block - @closed = false - end - - # Return whether the wrapped body responds to the method. - def respond_to_missing?(method_name, include_all = false) - case method_name - when :to_str - false - else - super or @body.respond_to?(method_name, include_all) - end - end - - # If not already closed, close the wrapped body and - # then call the block the proxy was initialized with. - def close - return if @closed - @closed = true - begin - @body.close if @body.respond_to?(:close) - ensure - @block.call - end - end - - # Whether the proxy is closed. The proxy starts as not closed, - # and becomes closed on the first call to close. - def closed? - @closed - end - - # Delegate missing methods to the wrapped body. - def method_missing(method_name, *args, &block) - case method_name - when :to_str - super - when :to_ary - begin - @body.__send__(method_name, *args, &block) - ensure - close - end - else - @body.__send__(method_name, *args, &block) - end - end - # :nocov: - ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) - # :nocov: - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/builder.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/builder.rb deleted file mode 100644 index 9faeffb..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/builder.rb +++ /dev/null @@ -1,290 +0,0 @@ -# frozen_string_literal: true - -require_relative 'urlmap' - -module Rack; end -Rack::BUILDER_TOPLEVEL_BINDING = ->(builder){builder.instance_eval{binding}} - -module Rack - # Rack::Builder provides a domain-specific language (DSL) to construct Rack - # applications. It is primarily used to parse +config.ru+ files which - # instantiate several middleware and a final application which are hosted - # by a Rack-compatible web server. - # - # Example: - # - # app = Rack::Builder.new do - # use Rack::CommonLogger - # map "/ok" do - # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } - # end - # end - # - # run app - # - # Or - # - # app = Rack::Builder.app do - # use Rack::CommonLogger - # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } - # end - # - # run app - # - # +use+ adds middleware to the stack, +run+ dispatches to an application. - # You can use +map+ to construct a Rack::URLMap in a convenient way. - class Builder - - # https://stackoverflow.com/questions/2223882/whats-the-difference-between-utf-8-and-utf-8-without-bom - UTF_8_BOM = '\xef\xbb\xbf' - - # Parse the given config file to get a Rack application. - # - # If the config file ends in +.ru+, it is treated as a - # rackup file and the contents will be treated as if - # specified inside a Rack::Builder block. - # - # If the config file does not end in +.ru+, it is - # required and Rack will use the basename of the file - # to guess which constant will be the Rack application to run. - # - # Examples: - # - # Rack::Builder.parse_file('config.ru') - # # Rack application built using Rack::Builder.new - # - # Rack::Builder.parse_file('app.rb') - # # requires app.rb, which can be anywhere in Ruby's - # # load path. After requiring, assumes App constant - # # is a Rack application - # - # Rack::Builder.parse_file('./my_app.rb') - # # requires ./my_app.rb, which should be in the - # # process's current directory. After requiring, - # # assumes MyApp constant is a Rack application - def self.parse_file(path, **options) - if path.end_with?('.ru') - return self.load_file(path, **options) - else - require path - return Object.const_get(::File.basename(path, '.rb').split('_').map(&:capitalize).join('')) - end - end - - # Load the given file as a rackup file, treating the - # contents as if specified inside a Rack::Builder block. - # - # Ignores content in the file after +__END__+, so that - # use of +__END__+ will not result in a syntax error. - # - # Example config.ru file: - # - # $ cat config.ru - # - # use Rack::ContentLength - # require './app.rb' - # run App - def self.load_file(path, **options) - config = ::File.read(path) - config.slice!(/\A#{UTF_8_BOM}/) if config.encoding == Encoding::UTF_8 - - if config[/^#\\(.*)/] - fail "Parsing options from the first comment line is no longer supported: #{path}" - end - - config.sub!(/^__END__\n.*\Z/m, '') - - return new_from_string(config, path, **options) - end - - # Evaluate the given +builder_script+ string in the context of - # a Rack::Builder block, returning a Rack application. - def self.new_from_string(builder_script, path = "(rackup)", **options) - builder = self.new(**options) - - # We want to build a variant of TOPLEVEL_BINDING with self as a Rack::Builder instance. - # We cannot use instance_eval(String) as that would resolve constants differently. - binding = BUILDER_TOPLEVEL_BINDING.call(builder) - eval(builder_script, binding, path) - - return builder.to_app - end - - # Initialize a new Rack::Builder instance. +default_app+ specifies the - # default application if +run+ is not called later. If a block - # is given, it is evaluated in the context of the instance. - def initialize(default_app = nil, **options, &block) - @use = [] - @map = nil - @run = default_app - @warmup = nil - @freeze_app = false - @options = options - - instance_eval(&block) if block_given? - end - - # Any options provided to the Rack::Builder instance at initialization. - # These options can be server-specific. Some general options are: - # - # * +:isolation+: One of +process+, +thread+ or +fiber+. The execution - # isolation model to use. - attr :options - - # Create a new Rack::Builder instance and return the Rack application - # generated from it. - def self.app(default_app = nil, &block) - self.new(default_app, &block).to_app - end - - # Specifies middleware to use in a stack. - # - # class Middleware - # def initialize(app) - # @app = app - # end - # - # def call(env) - # env["rack.some_header"] = "setting an example" - # @app.call(env) - # end - # end - # - # use Middleware - # run lambda { |env| [200, { "content-type" => "text/plain" }, ["OK"]] } - # - # All requests through to this application will first be processed by the middleware class. - # The +call+ method in this example sets an additional environment key which then can be - # referenced in the application if required. - def use(middleware, *args, &block) - if @map - mapping, @map = @map, nil - @use << proc { |app| generate_map(app, mapping) } - end - @use << proc { |app| middleware.new(app, *args, &block) } - end - # :nocov: - ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true) - # :nocov: - - # Takes a block or argument that is an object that responds to #call and - # returns a Rack response. - # - # You can use a block: - # - # run do |env| - # [200, { "content-type" => "text/plain" }, ["Hello World!"]] - # end - # - # You can also provide a lambda: - # - # run lambda { |env| [200, { "content-type" => "text/plain" }, ["OK"]] } - # - # You can also provide a class instance: - # - # class Heartbeat - # def call(env) - # [200, { "content-type" => "text/plain" }, ["OK"]] - # end - # end - # - # run Heartbeat.new - # - def run(app = nil, &block) - raise ArgumentError, "Both app and block given!" if app && block_given? - - @run = app || block - end - - # Takes a lambda or block that is used to warm-up the application. This block is called - # before the Rack application is returned by to_app. - # - # warmup do |app| - # client = Rack::MockRequest.new(app) - # client.get('/') - # end - # - # use SomeMiddleware - # run MyApp - def warmup(prc = nil, &block) - @warmup = prc || block - end - - # Creates a route within the application. Routes under the mapped path will be sent to - # the Rack application specified by run inside the block. Other requests will be sent to the - # default application specified by run outside the block. - # - # class App - # def call(env) - # [200, {'content-type' => 'text/plain'}, ["Hello World"]] - # end - # end - # - # class Heartbeat - # def call(env) - # [200, { "content-type" => "text/plain" }, ["OK"]] - # end - # end - # - # app = Rack::Builder.app do - # map '/heartbeat' do - # run Heartbeat.new - # end - # run App.new - # end - # - # run app - # - # The +use+ method can also be used inside the block to specify middleware to run under a specific path: - # - # app = Rack::Builder.app do - # map '/heartbeat' do - # use Middleware - # run Heartbeat.new - # end - # run App.new - # end - # - # This example includes a piece of middleware which will run before +/heartbeat+ requests hit +Heartbeat+. - # - # Note that providing a +path+ of +/+ will ignore any default application given in a +run+ statement - # outside the block. - def map(path, &block) - @map ||= {} - @map[path] = block - end - - # Freeze the app (set using run) and all middleware instances when building the application - # in to_app. - def freeze_app - @freeze_app = true - end - - # Return the Rack application generated by this instance. - def to_app - app = @map ? generate_map(@run, @map) : @run - fail "missing run or map statement" unless app - app.freeze if @freeze_app - app = @use.reverse.inject(app) { |a, e| e[a].tap { |x| x.freeze if @freeze_app } } - @warmup.call(app) if @warmup - app - end - - # Call the Rack application generated by this builder instance. Note that - # this rebuilds the Rack application and runs the warmup code (if any) - # every time it is called, so it should not be used if performance is important. - def call(env) - to_app.call(env) - end - - private - - # Generate a URLMap instance by generating new Rack applications for each - # map block in this instance. - def generate_map(default_app, mapping) - mapped = default_app ? { '/' => default_app } : {} - mapping.each { |r, b| mapped[r] = self.class.new(default_app, &b).to_app } - URLMap.new(mapped) - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/cascade.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/cascade.rb deleted file mode 100644 index 9c952fd..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/cascade.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' - -module Rack - # Rack::Cascade tries a request on several apps, and returns the - # first response that is not 404 or 405 (or in a list of configured - # status codes). If all applications tried return one of the configured - # status codes, return the last response. - - class Cascade - # An array of applications to try in order. - attr_reader :apps - - # Set the apps to send requests to, and what statuses result in - # cascading. Arguments: - # - # apps: An enumerable of rack applications. - # cascade_for: The statuses to use cascading for. If a response is received - # from an app, the next app is tried. - def initialize(apps, cascade_for = [404, 405]) - @apps = [] - apps.each { |app| add app } - - @cascade_for = {} - [*cascade_for].each { |status| @cascade_for[status] = true } - end - - # Call each app in order. If the responses uses a status that requires - # cascading, try the next app. If all responses require cascading, - # return the response from the last app. - def call(env) - return [404, { CONTENT_TYPE => "text/plain" }, []] if @apps.empty? - result = nil - last_body = nil - - @apps.each do |app| - # The SPEC says that the body must be closed after it has been iterated - # by the server, or if it is replaced by a middleware action. Cascade - # replaces the body each time a cascade happens. It is assumed that nil - # does not respond to close, otherwise the previous application body - # will be closed. The final application body will not be closed, as it - # will be passed to the server as a result. - last_body.close if last_body.respond_to? :close - - result = app.call(env) - return result unless @cascade_for.include?(result[0].to_i) - last_body = result[2] - end - - result - end - - # Append an app to the list of apps to cascade. This app will - # be tried last. - def add(app) - @apps << app - end - - # Whether the given app is one of the apps to cascade to. - def include?(app) - @apps.include?(app) - end - - alias_method :<<, :add - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/common_logger.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/common_logger.rb deleted file mode 100644 index 2feb067..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/common_logger.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' -require_relative 'body_proxy' -require_relative 'request' - -module Rack - # Rack::CommonLogger forwards every request to the given +app+, and - # logs a line in the - # {Apache common log format}[http://httpd.apache.org/docs/1.3/logs.html#common] - # to the configured logger. - class CommonLogger - # Common Log Format: http://httpd.apache.org/docs/1.3/logs.html#common - # - # lilith.local - - [07/Aug/2006 23:58:02 -0400] "GET / HTTP/1.1" 500 - - # - # %{%s - %s [%s] "%s %s%s %s" %d %s\n} % - # - # The actual format is slightly different than the above due to the - # separation of SCRIPT_NAME and PATH_INFO, and because the elapsed - # time in seconds is included at the end. - FORMAT = %{%s - %s [%s] "%s %s%s%s %s" %d %s %0.4f\n} - - # +logger+ can be any object that supports the +write+ or +<<+ methods, - # which includes the standard library Logger. These methods are called - # with a single string argument, the log message. - # If +logger+ is nil, CommonLogger will fall back env['rack.errors']. - def initialize(app, logger = nil) - @app = app - @logger = logger - end - - # Log all requests in common_log format after a response has been - # returned. Note that if the app raises an exception, the request - # will not be logged, so if exception handling middleware are used, - # they should be loaded after this middleware. Additionally, because - # the logging happens after the request body has been fully sent, any - # exceptions raised during the sending of the response body will - # cause the request not to be logged. - def call(env) - began_at = Utils.clock_time - status, headers, body = response = @app.call(env) - - response[2] = BodyProxy.new(body) { log(env, status, headers, began_at) } - response - end - - private - - # Log the request to the configured logger. - def log(env, status, response_headers, began_at) - request = Rack::Request.new(env) - length = extract_content_length(response_headers) - - msg = sprintf(FORMAT, - request.ip || "-", - request.get_header("REMOTE_USER") || "-", - Time.now.strftime("%d/%b/%Y:%H:%M:%S %z"), - request.request_method, - request.script_name, - request.path_info, - request.query_string.empty? ? "" : "?#{request.query_string}", - request.get_header(SERVER_PROTOCOL), - status.to_s[0..3], - length, - Utils.clock_time - began_at) - - msg.gsub!(/[^[:print:]\n]/) { |c| sprintf("\\x%x", c.ord) } - - logger = @logger || request.get_header(RACK_ERRORS) - # Standard library logger doesn't support write but it supports << which actually - # calls to write on the log device without formatting - if logger.respond_to?(:write) - logger.write(msg) - else - logger << msg - end - end - - # Attempt to determine the content length for the response to - # include it in the logged data. - def extract_content_length(headers) - value = headers[CONTENT_LENGTH] - !value || value.to_s == '0' ? '-' : value - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/conditional_get.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/conditional_get.rb deleted file mode 100644 index c3b334a..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/conditional_get.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' -require_relative 'body_proxy' - -module Rack - - # Middleware that enables conditional GET using if-none-match and - # if-modified-since. The application should set either or both of the - # last-modified or etag response headers according to RFC 2616. When - # either of the conditions is met, the response body is set to be zero - # length and the response status is set to 304 Not Modified. - # - # Applications that defer response body generation until the body's each - # message is received will avoid response body generation completely when - # a conditional GET matches. - # - # Adapted from Michael Klishin's Merb implementation: - # https://github.com/wycats/merb/blob/master/merb-core/lib/merb-core/rack/middleware/conditional_get.rb - class ConditionalGet - def initialize(app) - @app = app - end - - # Return empty 304 response if the response has not been - # modified since the last request. - def call(env) - case env[REQUEST_METHOD] - when "GET", "HEAD" - status, headers, body = response = @app.call(env) - - if status == 200 && fresh?(env, headers) - response[0] = 304 - headers.delete(CONTENT_TYPE) - headers.delete(CONTENT_LENGTH) - response[2] = Rack::BodyProxy.new([]) do - body.close if body.respond_to?(:close) - end - end - response - else - @app.call(env) - end - end - - private - - # Return whether the response has not been modified since the - # last request. - def fresh?(env, headers) - # if-none-match has priority over if-modified-since per RFC 7232 - if none_match = env['HTTP_IF_NONE_MATCH'] - etag_matches?(none_match, headers) - elsif (modified_since = env['HTTP_IF_MODIFIED_SINCE']) && (modified_since = to_rfc2822(modified_since)) - modified_since?(modified_since, headers) - end - end - - # Whether the etag response header matches the if-none-match request header. - # If so, the request has not been modified. - def etag_matches?(none_match, headers) - headers[ETAG] == none_match - end - - # Whether the last-modified response header matches the if-modified-since - # request header. If so, the request has not been modified. - def modified_since?(modified_since, headers) - last_modified = to_rfc2822(headers['last-modified']) and - modified_since >= last_modified - end - - # Return a Time object for the given string (which should be in RFC2822 - # format), or nil if the string cannot be parsed. - def to_rfc2822(since) - # shortest possible valid date is the obsolete: 1 Nov 97 09:55 A - # anything shorter is invalid, this avoids exceptions for common cases - # most common being the empty string - if since && since.length >= 16 - # NOTE: there is no trivial way to write this in a non exception way - # _rfc2822 returns a hash but is not that usable - Time.rfc2822(since) rescue nil - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/config.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/config.rb deleted file mode 100644 index 41f6f7d..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/config.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Rack::Config modifies the environment using the block given during - # initialization. - # - # Example: - # use Rack::Config do |env| - # env['my-key'] = 'some-value' - # end - class Config - def initialize(app, &block) - @app = app - @block = block - end - - def call(env) - @block.call(env) - @app.call(env) - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/constants.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/constants.rb deleted file mode 100644 index e9b6e10..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/constants.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Request env keys - HTTP_HOST = 'HTTP_HOST' - HTTP_PORT = 'HTTP_PORT' - HTTPS = 'HTTPS' - PATH_INFO = 'PATH_INFO' - REQUEST_METHOD = 'REQUEST_METHOD' - REQUEST_PATH = 'REQUEST_PATH' - SCRIPT_NAME = 'SCRIPT_NAME' - QUERY_STRING = 'QUERY_STRING' - SERVER_PROTOCOL = 'SERVER_PROTOCOL' - SERVER_NAME = 'SERVER_NAME' - SERVER_PORT = 'SERVER_PORT' - HTTP_COOKIE = 'HTTP_COOKIE' - - # Response Header Keys - CACHE_CONTROL = 'cache-control' - CONTENT_LENGTH = 'content-length' - CONTENT_TYPE = 'content-type' - ETAG = 'etag' - EXPIRES = 'expires' - SET_COOKIE = 'set-cookie' - TRANSFER_ENCODING = 'transfer-encoding' - - # HTTP method verbs - GET = 'GET' - POST = 'POST' - PUT = 'PUT' - PATCH = 'PATCH' - DELETE = 'DELETE' - HEAD = 'HEAD' - OPTIONS = 'OPTIONS' - CONNECT = 'CONNECT' - LINK = 'LINK' - UNLINK = 'UNLINK' - TRACE = 'TRACE' - - # Rack environment variables - RACK_VERSION = 'rack.version' - RACK_TEMPFILES = 'rack.tempfiles' - RACK_EARLY_HINTS = 'rack.early_hints' - RACK_ERRORS = 'rack.errors' - RACK_LOGGER = 'rack.logger' - RACK_INPUT = 'rack.input' - RACK_SESSION = 'rack.session' - RACK_SESSION_OPTIONS = 'rack.session.options' - RACK_SHOWSTATUS_DETAIL = 'rack.showstatus.detail' - RACK_URL_SCHEME = 'rack.url_scheme' - RACK_HIJACK = 'rack.hijack' - RACK_IS_HIJACK = 'rack.hijack?' - RACK_RECURSIVE_INCLUDE = 'rack.recursive.include' - RACK_MULTIPART_BUFFER_SIZE = 'rack.multipart.buffer_size' - RACK_MULTIPART_TEMPFILE_FACTORY = 'rack.multipart.tempfile_factory' - RACK_RESPONSE_FINISHED = 'rack.response_finished' - RACK_REQUEST_FORM_INPUT = 'rack.request.form_input' - RACK_REQUEST_FORM_HASH = 'rack.request.form_hash' - RACK_REQUEST_FORM_PAIRS = 'rack.request.form_pairs' - RACK_REQUEST_FORM_VARS = 'rack.request.form_vars' - RACK_REQUEST_FORM_ERROR = 'rack.request.form_error' - RACK_REQUEST_COOKIE_HASH = 'rack.request.cookie_hash' - RACK_REQUEST_COOKIE_STRING = 'rack.request.cookie_string' - RACK_REQUEST_QUERY_HASH = 'rack.request.query_hash' - RACK_REQUEST_QUERY_STRING = 'rack.request.query_string' - RACK_METHODOVERRIDE_ORIGINAL_METHOD = 'rack.methodoverride.original_method' -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_length.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_length.rb deleted file mode 100644 index cbac93a..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_length.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' - -module Rack - - # Sets the content-length header on responses that do not specify - # a content-length or transfer-encoding header. Note that this - # does not fix responses that have an invalid content-length - # header specified. - class ContentLength - include Rack::Utils - - def initialize(app) - @app = app - end - - def call(env) - status, headers, body = response = @app.call(env) - - if !STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) && - !headers[CONTENT_LENGTH] && - !headers[TRANSFER_ENCODING] && - body.respond_to?(:to_ary) - - response[2] = body = body.to_ary - headers[CONTENT_LENGTH] = body.sum(&:bytesize).to_s - end - - response - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_type.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_type.rb deleted file mode 100644 index 19f0782..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/content_type.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' - -module Rack - - # Sets the content-type header on responses which don't have one. - # - # Builder Usage: - # use Rack::ContentType, "text/plain" - # - # When no content type argument is provided, "text/html" is the - # default. - class ContentType - include Rack::Utils - - def initialize(app, content_type = "text/html") - @app = app - @content_type = content_type - end - - def call(env) - status, headers, _ = response = @app.call(env) - - unless STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) - headers[CONTENT_TYPE] ||= @content_type - end - - response - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/deflater.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/deflater.rb deleted file mode 100644 index cc01c32..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/deflater.rb +++ /dev/null @@ -1,158 +0,0 @@ -# frozen_string_literal: true - -require "zlib" -require "time" # for Time.httpdate - -require_relative 'constants' -require_relative 'utils' -require_relative 'request' -require_relative 'body_proxy' - -module Rack - # This middleware enables content encoding of http responses, - # usually for purposes of compression. - # - # Currently supported encodings: - # - # * gzip - # * identity (no transformation) - # - # This middleware automatically detects when encoding is supported - # and allowed. For example no encoding is made when a cache - # directive of 'no-transform' is present, when the response status - # code is one that doesn't allow an entity body, or when the body - # is empty. - # - # Note that despite the name, Deflater does not support the +deflate+ - # encoding. - class Deflater - # Creates Rack::Deflater middleware. Options: - # - # :if :: a lambda enabling / disabling deflation based on returned boolean value - # (e.g use Rack::Deflater, :if => lambda { |*, body| sum=0; body.each { |i| sum += i.length }; sum > 512 }). - # However, be aware that calling `body.each` inside the block will break cases where `body.each` is not idempotent, - # such as when it is an +IO+ instance. - # :include :: a list of content types that should be compressed. By default, all content types are compressed. - # :sync :: determines if the stream is going to be flushed after every chunk. Flushing after every chunk reduces - # latency for time-sensitive streaming applications, but hurts compression and throughput. - # Defaults to +true+. - def initialize(app, options = {}) - @app = app - @condition = options[:if] - @compressible_types = options[:include] - @sync = options.fetch(:sync, true) - end - - def call(env) - status, headers, body = response = @app.call(env) - - unless should_deflate?(env, status, headers, body) - return response - end - - request = Request.new(env) - - encoding = Utils.select_best_encoding(%w(gzip identity), - request.accept_encoding) - - # Set the Vary HTTP header. - vary = headers["vary"].to_s.split(",").map(&:strip) - unless vary.include?("*") || vary.any?{|v| v.downcase == 'accept-encoding'} - headers["vary"] = vary.push("Accept-Encoding").join(",") - end - - case encoding - when "gzip" - headers['content-encoding'] = "gzip" - headers.delete(CONTENT_LENGTH) - mtime = headers["last-modified"] - mtime = Time.httpdate(mtime).to_i if mtime - response[2] = GzipStream.new(body, mtime, @sync) - response - when "identity" - response - else # when nil - # Only possible encoding values here are 'gzip', 'identity', and nil - message = "An acceptable encoding for the requested resource #{request.fullpath} could not be found." - bp = Rack::BodyProxy.new([message]) { body.close if body.respond_to?(:close) } - [406, { CONTENT_TYPE => "text/plain", CONTENT_LENGTH => message.length.to_s }, bp] - end - end - - # Body class used for gzip encoded responses. - class GzipStream - - BUFFER_LENGTH = 128 * 1_024 - - # Initialize the gzip stream. Arguments: - # body :: Response body to compress with gzip - # mtime :: The modification time of the body, used to set the - # modification time in the gzip header. - # sync :: Whether to flush each gzip chunk as soon as it is ready. - def initialize(body, mtime, sync) - @body = body - @mtime = mtime - @sync = sync - end - - # Yield gzip compressed strings to the given block. - def each(&block) - @writer = block - gzip = ::Zlib::GzipWriter.new(self) - gzip.mtime = @mtime if @mtime - # @body.each is equivalent to @body.gets (slow) - if @body.is_a? ::File # XXX: Should probably be ::IO - while part = @body.read(BUFFER_LENGTH) - gzip.write(part) - gzip.flush if @sync - end - else - @body.each { |part| - # Skip empty strings, as they would result in no output, - # and flushing empty parts would raise Zlib::BufError. - next if part.empty? - gzip.write(part) - gzip.flush if @sync - } - end - ensure - gzip.finish - end - - # Call the block passed to #each with the gzipped data. - def write(data) - @writer.call(data) - end - - # Close the original body if possible. - def close - @body.close if @body.respond_to?(:close) - end - end - - private - - # Whether the body should be compressed. - def should_deflate?(env, status, headers, body) - # Skip compressing empty entity body responses and responses with - # no-transform set. - if Utils::STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) || - /\bno-transform\b/.match?(headers[CACHE_CONTROL].to_s) || - headers['content-encoding']&.!~(/\bidentity\b/) - return false - end - - # Skip if @compressible_types are given and does not include request's content type - return false if @compressible_types && !(headers.has_key?(CONTENT_TYPE) && @compressible_types.include?(headers[CONTENT_TYPE][/[^;]*/])) - - # Skip if @condition lambda is given and evaluates to false - return false if @condition && !@condition.call(env, status, headers, body) - - # No point in compressing empty body, also handles usage with - # Rack::Sendfile. - return false if headers[CONTENT_LENGTH] == '0' - - true - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/directory.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/directory.rb deleted file mode 100644 index 089623f..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/directory.rb +++ /dev/null @@ -1,205 +0,0 @@ -# frozen_string_literal: true - -require 'time' - -require_relative 'constants' -require_relative 'utils' -require_relative 'head' -require_relative 'mime' -require_relative 'files' - -module Rack - # Rack::Directory serves entries below the +root+ given, according to the - # path info of the Rack request. If a directory is found, the file's contents - # will be presented in an html based index. If a file is found, the env will - # be passed to the specified +app+. - # - # If +app+ is not specified, a Rack::Files of the same +root+ will be used. - - class Directory - DIR_FILE = "%s%s%s%s\n" - DIR_PAGE_HEADER = <<-PAGE - - %s - - - -

%s

-
- - - - - - - - PAGE - DIR_PAGE_FOOTER = <<-PAGE -
NameSizeTypeLast Modified
-
- - PAGE - - # Body class for directory entries, showing an index page with links - # to each file. - class DirectoryBody < Struct.new(:root, :path, :files) - # Yield strings for each part of the directory entry - def each - show_path = Utils.escape_html(path.sub(/^#{root}/, '')) - yield(DIR_PAGE_HEADER % [ show_path, show_path ]) - - unless path.chomp('/') == root - yield(DIR_FILE % DIR_FILE_escape(files.call('..'))) - end - - Dir.foreach(path) do |basename| - next if basename.start_with?('.') - next unless f = files.call(basename) - yield(DIR_FILE % DIR_FILE_escape(f)) - end - - yield(DIR_PAGE_FOOTER) - end - - private - - # Escape each element in the array of html strings. - def DIR_FILE_escape(htmls) - htmls.map { |e| Utils.escape_html(e) } - end - end - - # The root of the directory hierarchy. Only requests for files and - # directories inside of the root directory are supported. - attr_reader :root - - # Set the root directory and application for serving files. - def initialize(root, app = nil) - @root = ::File.expand_path(root) - @app = app || Files.new(@root) - @head = Head.new(method(:get)) - end - - def call(env) - # strip body if this is a HEAD call - @head.call env - end - - # Internals of request handling. Similar to call but does - # not remove body for HEAD requests. - def get(env) - script_name = env[SCRIPT_NAME] - path_info = Utils.unescape_path(env[PATH_INFO]) - - if client_error_response = check_bad_request(path_info) || check_forbidden(path_info) - client_error_response - else - path = ::File.join(@root, path_info) - list_path(env, path, path_info, script_name) - end - end - - # Rack response to use for requests with invalid paths, or nil if path is valid. - def check_bad_request(path_info) - return if Utils.valid_path?(path_info) - - body = "Bad Request\n" - [400, { CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => body.bytesize.to_s, - "x-cascade" => "pass" }, [body]] - end - - # Rack response to use for requests with paths outside the root, or nil if path is inside the root. - def check_forbidden(path_info) - return unless path_info.include? ".." - return if ::File.expand_path(::File.join(@root, path_info)).start_with?(@root) - - body = "Forbidden\n" - [403, { CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => body.bytesize.to_s, - "x-cascade" => "pass" }, [body]] - end - - # Rack response to use for directories under the root. - def list_directory(path_info, path, script_name) - url_head = (script_name.split('/') + path_info.split('/')).map do |part| - Utils.escape_path part - end - - # Globbing not safe as path could contain glob metacharacters - body = DirectoryBody.new(@root, path, ->(basename) do - stat = stat(::File.join(path, basename)) - next unless stat - - url = ::File.join(*url_head + [Utils.escape_path(basename)]) - mtime = stat.mtime.httpdate - if stat.directory? - type = 'directory' - size = '-' - url << '/' - if basename == '..' - basename = 'Parent Directory' - else - basename << '/' - end - else - type = Mime.mime_type(::File.extname(basename)) - size = filesize_format(stat.size) - end - - [ url, basename, size, type, mtime ] - end) - - [ 200, { CONTENT_TYPE => 'text/html; charset=utf-8' }, body ] - end - - # File::Stat for the given path, but return nil for missing/bad entries. - def stat(path) - ::File.stat(path) - rescue Errno::ENOENT, Errno::ELOOP - return nil - end - - # Rack response to use for files and directories under the root. - # Unreadable and non-file, non-directory entries will get a 404 response. - def list_path(env, path, path_info, script_name) - if (stat = stat(path)) && stat.readable? - return @app.call(env) if stat.file? - return list_directory(path_info, path, script_name) if stat.directory? - end - - entity_not_found(path_info) - end - - # Rack response to use for unreadable and non-file, non-directory entries. - def entity_not_found(path_info) - body = "Entity not found: #{path_info}\n" - [404, { CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => body.bytesize.to_s, - "x-cascade" => "pass" }, [body]] - end - - # Stolen from Ramaze - FILESIZE_FORMAT = [ - ['%.1fT', 1 << 40], - ['%.1fG', 1 << 30], - ['%.1fM', 1 << 20], - ['%.1fK', 1 << 10], - ] - - # Provide human readable file sizes - def filesize_format(int) - FILESIZE_FORMAT.each do |format, size| - return format % (int.to_f / size) if int >= size - end - - "#{int}B" - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/etag.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/etag.rb deleted file mode 100644 index fa78b47..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/etag.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -require 'digest/sha2' - -require_relative 'constants' -require_relative 'utils' - -module Rack - # Automatically sets the etag header on all String bodies. - # - # The etag header is skipped if etag or last-modified headers are sent or if - # a sendfile body (body.responds_to :to_path) is given (since such cases - # should be handled by apache/nginx). - # - # On initialization, you can pass two parameters: a cache-control directive - # used when etag is absent and a directive when it is present. The first - # defaults to nil, while the second defaults to "max-age=0, private, must-revalidate" - class ETag - ETAG_STRING = Rack::ETAG - DEFAULT_CACHE_CONTROL = "max-age=0, private, must-revalidate" - - def initialize(app, no_cache_control = nil, cache_control = DEFAULT_CACHE_CONTROL) - @app = app - @cache_control = cache_control - @no_cache_control = no_cache_control - end - - def call(env) - status, headers, body = response = @app.call(env) - - if etag_status?(status) && body.respond_to?(:to_ary) && !skip_caching?(headers) - body = body.to_ary - digest = digest_body(body) - headers[ETAG_STRING] = %(W/"#{digest}") if digest - end - - unless headers[CACHE_CONTROL] - if digest - headers[CACHE_CONTROL] = @cache_control if @cache_control - else - headers[CACHE_CONTROL] = @no_cache_control if @no_cache_control - end - end - - response - end - - private - - def etag_status?(status) - status == 200 || status == 201 - end - - def skip_caching?(headers) - headers.key?(ETAG_STRING) || headers.key?('last-modified') - end - - def digest_body(body) - digest = nil - - body.each do |part| - (digest ||= Digest::SHA256.new) << part unless part.empty? - end - - digest && digest.hexdigest.byteslice(0,32) - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/events.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/events.rb deleted file mode 100644 index c7bb201..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/events.rb +++ /dev/null @@ -1,157 +0,0 @@ -# frozen_string_literal: true - -require_relative 'body_proxy' -require_relative 'request' -require_relative 'response' - -module Rack - ### This middleware provides hooks to certain places in the request / - # response lifecycle. This is so that middleware that don't need to filter - # the response data can safely leave it alone and not have to send messages - # down the traditional "rack stack". - # - # The events are: - # - # * on_start(request, response) - # - # This event is sent at the start of the request, before the next - # middleware in the chain is called. This method is called with a request - # object, and a response object. Right now, the response object is always - # nil, but in the future it may actually be a real response object. - # - # * on_commit(request, response) - # - # The response has been committed. The application has returned, but the - # response has not been sent to the webserver yet. This method is always - # called with a request object and the response object. The response - # object is constructed from the rack triple that the application returned. - # Changes may still be made to the response object at this point. - # - # * on_send(request, response) - # - # The webserver has started iterating over the response body and presumably - # has started sending data over the wire. This method is always called with - # a request object and the response object. The response object is - # constructed from the rack triple that the application returned. Changes - # SHOULD NOT be made to the response object as the webserver has already - # started sending data. Any mutations will likely result in an exception. - # - # * on_finish(request, response) - # - # The webserver has closed the response, and all data has been written to - # the response socket. The request and response object should both be - # read-only at this point. The body MAY NOT be available on the response - # object as it may have been flushed to the socket. - # - # * on_error(request, response, error) - # - # An exception has occurred in the application or an `on_commit` event. - # This method will get the request, the response (if available) and the - # exception that was raised. - # - # ## Order - # - # `on_start` is called on the handlers in the order that they were passed to - # the constructor. `on_commit`, on_send`, `on_finish`, and `on_error` are - # called in the reverse order. `on_finish` handlers are called inside an - # `ensure` block, so they are guaranteed to be called even if something - # raises an exception. If something raises an exception in a `on_finish` - # method, then nothing is guaranteed. - - class Events - module Abstract - def on_start(req, res) - end - - def on_commit(req, res) - end - - def on_send(req, res) - end - - def on_finish(req, res) - end - - def on_error(req, res, e) - end - end - - class EventedBodyProxy < Rack::BodyProxy # :nodoc: - attr_reader :request, :response - - def initialize(body, request, response, handlers, &block) - super(body, &block) - @request = request - @response = response - @handlers = handlers - end - - def each - @handlers.reverse_each { |handler| handler.on_send request, response } - super - end - end - - class BufferedResponse < Rack::Response::Raw # :nodoc: - attr_reader :body - - def initialize(status, headers, body) - super(status, headers) - @body = body - end - - def to_a; [status, headers, body]; end - end - - def initialize(app, handlers) - @app = app - @handlers = handlers - end - - def call(env) - request = make_request env - on_start request, nil - - begin - status, headers, body = @app.call request.env - response = make_response status, headers, body - on_commit request, response - rescue StandardError => e - on_error request, response, e - on_finish request, response - raise - end - - body = EventedBodyProxy.new(body, request, response, @handlers) do - on_finish request, response - end - [response.status, response.headers, body] - end - - private - - def on_error(request, response, e) - @handlers.reverse_each { |handler| handler.on_error request, response, e } - end - - def on_commit(request, response) - @handlers.reverse_each { |handler| handler.on_commit request, response } - end - - def on_start(request, response) - @handlers.each { |handler| handler.on_start request, nil } - end - - def on_finish(request, response) - @handlers.reverse_each { |handler| handler.on_finish request, response } - end - - def make_request(env) - Rack::Request.new env - end - - def make_response(status, headers, body) - BufferedResponse.new status, headers, body - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/files.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/files.rb deleted file mode 100644 index 5b8353f..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/files.rb +++ /dev/null @@ -1,216 +0,0 @@ -# frozen_string_literal: true - -require 'time' - -require_relative 'constants' -require_relative 'head' -require_relative 'utils' -require_relative 'request' -require_relative 'mime' - -module Rack - # Rack::Files serves files below the +root+ directory given, according to the - # path info of the Rack request. - # e.g. when Rack::Files.new("/etc") is used, you can access 'passwd' file - # as http://localhost:9292/passwd - # - # Handlers can detect if bodies are a Rack::Files, and use mechanisms - # like sendfile on the +path+. - - class Files - ALLOWED_VERBS = %w[GET HEAD OPTIONS] - ALLOW_HEADER = ALLOWED_VERBS.join(', ') - MULTIPART_BOUNDARY = 'AaB03x' - - attr_reader :root - - def initialize(root, headers = {}, default_mime = 'text/plain') - @root = (::File.expand_path(root) if root) - @headers = headers - @default_mime = default_mime - @head = Rack::Head.new(lambda { |env| get env }) - end - - def call(env) - # HEAD requests drop the response body, including 4xx error messages. - @head.call env - end - - def get(env) - request = Rack::Request.new env - unless ALLOWED_VERBS.include? request.request_method - return fail(405, "Method Not Allowed", { 'allow' => ALLOW_HEADER }) - end - - path_info = Utils.unescape_path request.path_info - return fail(400, "Bad Request") unless Utils.valid_path?(path_info) - - clean_path_info = Utils.clean_path_info(path_info) - path = ::File.join(@root, clean_path_info) - - available = begin - ::File.file?(path) && ::File.readable?(path) - rescue SystemCallError - # Not sure in what conditions this exception can occur, but this - # is a safe way to handle such an error. - # :nocov: - false - # :nocov: - end - - if available - serving(request, path) - else - fail(404, "File not found: #{path_info}") - end - end - - def serving(request, path) - if request.options? - return [200, { 'allow' => ALLOW_HEADER, CONTENT_LENGTH => '0' }, []] - end - last_modified = ::File.mtime(path).httpdate - return [304, {}, []] if request.get_header('HTTP_IF_MODIFIED_SINCE') == last_modified - - headers = { "last-modified" => last_modified } - mime_type = mime_type path, @default_mime - headers[CONTENT_TYPE] = mime_type if mime_type - - # Set custom headers - headers.merge!(@headers) if @headers - - status = 200 - size = filesize path - - ranges = Rack::Utils.get_byte_ranges(request.get_header('HTTP_RANGE'), size) - if ranges.nil? - # No ranges: - ranges = [0..size - 1] - elsif ranges.empty? - # Unsatisfiable. Return error, and file size: - response = fail(416, "Byte range unsatisfiable") - response[1]["content-range"] = "bytes */#{size}" - return response - else - # Partial content - partial_content = true - - if ranges.size == 1 - range = ranges[0] - headers["content-range"] = "bytes #{range.begin}-#{range.end}/#{size}" - else - headers[CONTENT_TYPE] = "multipart/byteranges; boundary=#{MULTIPART_BOUNDARY}" - end - - status = 206 - body = BaseIterator.new(path, ranges, mime_type: mime_type, size: size) - size = body.bytesize - end - - headers[CONTENT_LENGTH] = size.to_s - - if request.head? - body = [] - elsif !partial_content - body = Iterator.new(path, ranges, mime_type: mime_type, size: size) - end - - [status, headers, body] - end - - class BaseIterator - attr_reader :path, :ranges, :options - - def initialize(path, ranges, options) - @path = path - @ranges = ranges - @options = options - end - - def each - ::File.open(path, "rb") do |file| - ranges.each do |range| - yield multipart_heading(range) if multipart? - - each_range_part(file, range) do |part| - yield part - end - end - - yield "\r\n--#{MULTIPART_BOUNDARY}--\r\n" if multipart? - end - end - - def bytesize - size = ranges.inject(0) do |sum, range| - sum += multipart_heading(range).bytesize if multipart? - sum += range.size - end - size += "\r\n--#{MULTIPART_BOUNDARY}--\r\n".bytesize if multipart? - size - end - - def close; end - - private - - def multipart? - ranges.size > 1 - end - - def multipart_heading(range) -<<-EOF -\r ---#{MULTIPART_BOUNDARY}\r -content-type: #{options[:mime_type]}\r -content-range: bytes #{range.begin}-#{range.end}/#{options[:size]}\r -\r -EOF - end - - def each_range_part(file, range) - file.seek(range.begin) - remaining_len = range.end - range.begin + 1 - while remaining_len > 0 - part = file.read([8192, remaining_len].min) - break unless part - remaining_len -= part.length - - yield part - end - end - end - - class Iterator < BaseIterator - alias :to_path :path - end - - private - - def fail(status, body, headers = {}) - body += "\n" - - [ - status, - { - CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => body.size.to_s, - "x-cascade" => "pass" - }.merge!(headers), - [body] - ] - end - - # The MIME type for the contents of the file located at @path - def mime_type(path, default_mime) - Mime.mime_type(::File.extname(path), default_mime) - end - - def filesize(path) - # We check via File::size? whether this file provides size info - # via stat (e.g. /proc files often don't), otherwise we have to - # figure it out by reading the whole file into memory. - ::File.size?(path) || ::File.read(path).bytesize - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/head.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/head.rb deleted file mode 100644 index c1c430f..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/head.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'body_proxy' - -module Rack - # Rack::Head returns an empty body for all HEAD requests. It leaves - # all other requests unchanged. - class Head - def initialize(app) - @app = app - end - - def call(env) - _, _, body = response = @app.call(env) - - if env[REQUEST_METHOD] == HEAD - response[2] = Rack::BodyProxy.new([]) do - body.close if body.respond_to? :close - end - end - - response - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/headers.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/headers.rb deleted file mode 100644 index cedf3a8..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/headers.rb +++ /dev/null @@ -1,238 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Rack::Headers is a Hash subclass that downcases all keys. It's designed - # to be used by rack applications that don't implement the Rack 3 SPEC - # (by using non-lowercase response header keys), automatically handling - # the downcasing of keys. - class Headers < Hash - KNOWN_HEADERS = {} - %w( - Accept-CH - Accept-Patch - Accept-Ranges - Access-Control-Allow-Credentials - Access-Control-Allow-Headers - Access-Control-Allow-Methods - Access-Control-Allow-Origin - Access-Control-Expose-Headers - Access-Control-Max-Age - Age - Allow - Alt-Svc - Cache-Control - Connection - Content-Disposition - Content-Encoding - Content-Language - Content-Length - Content-Location - Content-MD5 - Content-Range - Content-Security-Policy - Content-Security-Policy-Report-Only - Content-Type - Date - Delta-Base - ETag - Expect-CT - Expires - Feature-Policy - IM - Last-Modified - Link - Location - NEL - P3P - Permissions-Policy - Pragma - Preference-Applied - Proxy-Authenticate - Public-Key-Pins - Referrer-Policy - Refresh - Report-To - Retry-After - Server - Set-Cookie - Status - Strict-Transport-Security - Timing-Allow-Origin - Tk - Trailer - Transfer-Encoding - Upgrade - Vary - Via - WWW-Authenticate - Warning - X-Cascade - X-Content-Duration - X-Content-Security-Policy - X-Content-Type-Options - X-Correlation-ID - X-Correlation-Id - X-Download-Options - X-Frame-Options - X-Permitted-Cross-Domain-Policies - X-Powered-By - X-Redirect-By - X-Request-ID - X-Request-Id - X-Runtime - X-UA-Compatible - X-WebKit-CS - X-XSS-Protection - ).each do |str| - downcased = str.downcase.freeze - KNOWN_HEADERS[str] = KNOWN_HEADERS[downcased] = downcased - end - - def self.[](*items) - if items.length % 2 != 0 - if items.length == 1 && items.first.is_a?(Hash) - new.merge!(items.first) - else - raise ArgumentError, "odd number of arguments for Rack::Headers" - end - else - hash = new - loop do - break if items.length == 0 - key = items.shift - value = items.shift - hash[key] = value - end - hash - end - end - - def [](key) - super(downcase_key(key)) - end - - def []=(key, value) - super(KNOWN_HEADERS[key] || key.downcase.freeze, value) - end - alias store []= - - def assoc(key) - super(downcase_key(key)) - end - - def compare_by_identity - raise TypeError, "Rack::Headers cannot compare by identity, use regular Hash" - end - - def delete(key) - super(downcase_key(key)) - end - - def dig(key, *a) - super(downcase_key(key), *a) - end - - def fetch(key, *default, &block) - key = downcase_key(key) - super - end - - def fetch_values(*a) - super(*a.map!{|key| downcase_key(key)}) - end - - def has_key?(key) - super(downcase_key(key)) - end - alias include? has_key? - alias key? has_key? - alias member? has_key? - - def invert - hash = self.class.new - each{|key, value| hash[value] = key} - hash - end - - def merge(hash, &block) - dup.merge!(hash, &block) - end - - def reject(&block) - hash = dup - hash.reject!(&block) - hash - end - - def replace(hash) - clear - update(hash) - end - - def select(&block) - hash = dup - hash.select!(&block) - hash - end - - def to_proc - lambda{|x| self[x]} - end - - def transform_values(&block) - dup.transform_values!(&block) - end - - def update(hash, &block) - hash.each do |key, value| - self[key] = if block_given? && include?(key) - block.call(key, self[key], value) - else - value - end - end - self - end - alias merge! update - - def values_at(*keys) - keys.map{|key| self[key]} - end - - # :nocov: - if RUBY_VERSION >= '2.5' - # :nocov: - def slice(*a) - h = self.class.new - a.each{|k| h[k] = self[k] if has_key?(k)} - h - end - - def transform_keys(&block) - dup.transform_keys!(&block) - end - - def transform_keys! - hash = self.class.new - each do |k, v| - hash[yield k] = v - end - replace(hash) - end - end - - # :nocov: - if RUBY_VERSION >= '3.0' - # :nocov: - def except(*a) - super(*a.map!{|key| downcase_key(key)}) - end - end - - private - - def downcase_key(key) - key.is_a?(String) ? KNOWN_HEADERS[key] || key.downcase : key - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lint.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lint.rb deleted file mode 100644 index 4f36c2e..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lint.rb +++ /dev/null @@ -1,991 +0,0 @@ -# frozen_string_literal: true - -require 'forwardable' -require 'uri' - -require_relative 'constants' -require_relative 'utils' - -module Rack - # Rack::Lint validates your application and the requests and - # responses according to the Rack spec. - - class Lint - REQUEST_PATH_ORIGIN_FORM = /\A\/[^#]*\z/ - REQUEST_PATH_ABSOLUTE_FORM = /\A#{Utils::URI_PARSER.make_regexp}\z/ - REQUEST_PATH_AUTHORITY_FORM = /\A[^\/:]+:\d+\z/ - REQUEST_PATH_ASTERISK_FORM = '*' - - def initialize(app) - @app = app - end - - # :stopdoc: - - class LintError < RuntimeError; end - # AUTHORS: n.b. The trailing whitespace between paragraphs is important and - # should not be removed. The whitespace creates paragraphs in the RDoc - # output. - # - ## This specification aims to formalize the Rack protocol. You - ## can (and should) use Rack::Lint to enforce it. - ## - ## When you develop middleware, be sure to add a Lint before and - ## after to catch all mistakes. - ## - ## = Rack applications - ## - ## A Rack application is a Ruby object (not a class) that - ## responds to +call+. - def call(env = nil) - Wrapper.new(@app, env).response - end - - class Wrapper - def initialize(app, env) - @app = app - @env = env - @response = nil - @head_request = false - - @status = nil - @headers = nil - @body = nil - @invoked = nil - @content_length = nil - @closed = false - @size = 0 - end - - def response - ## It takes exactly one argument, the *environment* - raise LintError, "No env given" unless @env - check_environment(@env) - - ## and returns a non-frozen Array of exactly three values: - @response = @app.call(@env) - raise LintError, "response is not an Array, but #{@response.class}" unless @response.kind_of? Array - raise LintError, "response is frozen" if @response.frozen? - raise LintError, "response array has #{@response.size} elements instead of 3" unless @response.size == 3 - - @status, @headers, @body = @response - ## The *status*, - check_status(@status) - - ## the *headers*, - check_headers(@headers) - - hijack_proc = check_hijack_response(@headers, @env) - if hijack_proc - @headers[RACK_HIJACK] = hijack_proc - end - - ## and the *body*. - check_content_type_header(@status, @headers) - check_content_length_header(@status, @headers) - check_rack_protocol_header(@status, @headers) - @head_request = @env[REQUEST_METHOD] == HEAD - - @lint = (@env['rack.lint'] ||= []) << self - - if (@env['rack.lint.body_iteration'] ||= 0) > 0 - raise LintError, "Middleware must not call #each directly" - end - - return [@status, @headers, self] - end - - ## - ## == The Environment - ## - def check_environment(env) - ## The environment must be an unfrozen instance of Hash that includes - ## CGI-like headers. The Rack application is free to modify the - ## environment. - raise LintError, "env #{env.inspect} is not a Hash, but #{env.class}" unless env.kind_of? Hash - raise LintError, "env should not be frozen, but is" if env.frozen? - - ## - ## The environment is required to include these variables - ## (adopted from {PEP 333}[https://peps.python.org/pep-0333/]), except when they'd be empty, but see - ## below. - - ## REQUEST_METHOD:: The HTTP request method, such as - ## "GET" or "POST". This cannot ever - ## be an empty string, and so is - ## always required. - - ## SCRIPT_NAME:: The initial portion of the request - ## URL's "path" that corresponds to the - ## application object, so that the - ## application knows its virtual - ## "location". This may be an empty - ## string, if the application corresponds - ## to the "root" of the server. - - ## PATH_INFO:: The remainder of the request URL's - ## "path", designating the virtual - ## "location" of the request's target - ## within the application. This may be an - ## empty string, if the request URL targets - ## the application root and does not have a - ## trailing slash. This value may be - ## percent-encoded when originating from - ## a URL. - - ## QUERY_STRING:: The portion of the request URL that - ## follows the ?, if any. May be - ## empty, but is always required! - - ## SERVER_NAME:: When combined with SCRIPT_NAME and - ## PATH_INFO, these variables can be - ## used to complete the URL. Note, however, - ## that HTTP_HOST, if present, - ## should be used in preference to - ## SERVER_NAME for reconstructing - ## the request URL. - ## SERVER_NAME can never be an empty - ## string, and so is always required. - - ## SERVER_PORT:: An optional +Integer+ which is the port the - ## server is running on. Should be specified if - ## the server is running on a non-standard port. - - ## SERVER_PROTOCOL:: A string representing the HTTP version used - ## for the request. - - ## HTTP_ Variables:: Variables corresponding to the - ## client-supplied HTTP request - ## headers (i.e., variables whose - ## names begin with HTTP_). The - ## presence or absence of these - ## variables should correspond with - ## the presence or absence of the - ## appropriate HTTP header in the - ## request. See - ## {RFC3875 section 4.1.18}[https://tools.ietf.org/html/rfc3875#section-4.1.18] - ## for specific behavior. - - ## In addition to this, the Rack environment must include these - ## Rack-specific variables: - - ## rack.url_scheme:: +http+ or +https+, depending on the - ## request URL. - - ## rack.input:: See below, the input stream. - - ## rack.errors:: See below, the error stream. - - ## rack.hijack?:: See below, if present and true, indicates - ## that the server supports partial hijacking. - - ## rack.hijack:: See below, if present, an object responding - ## to +call+ that is used to perform a full - ## hijack. - - ## rack.protocol:: An optional +Array+ of +String+, containing - ## the protocols advertised by the client in - ## the +upgrade+ header (HTTP/1) or the - ## +:protocol+ pseudo-header (HTTP/2). - if protocols = @env['rack.protocol'] - unless protocols.is_a?(Array) && protocols.all?{|protocol| protocol.is_a?(String)} - raise LintError, "rack.protocol must be an Array of Strings" - end - end - - ## Additional environment specifications have approved to - ## standardized middleware APIs. None of these are required to - ## be implemented by the server. - - ## rack.session:: A hash-like interface for storing - ## request session data. - ## The store must implement: - if session = env[RACK_SESSION] - ## store(key, value) (aliased as []=); - unless session.respond_to?(:store) && session.respond_to?(:[]=) - raise LintError, "session #{session.inspect} must respond to store and []=" - end - - ## fetch(key, default = nil) (aliased as []); - unless session.respond_to?(:fetch) && session.respond_to?(:[]) - raise LintError, "session #{session.inspect} must respond to fetch and []" - end - - ## delete(key); - unless session.respond_to?(:delete) - raise LintError, "session #{session.inspect} must respond to delete" - end - - ## clear; - unless session.respond_to?(:clear) - raise LintError, "session #{session.inspect} must respond to clear" - end - - ## to_hash (returning unfrozen Hash instance); - unless session.respond_to?(:to_hash) && session.to_hash.kind_of?(Hash) && !session.to_hash.frozen? - raise LintError, "session #{session.inspect} must respond to to_hash and return unfrozen Hash instance" - end - end - - ## rack.logger:: A common object interface for logging messages. - ## The object must implement: - if logger = env[RACK_LOGGER] - ## info(message, &block) - unless logger.respond_to?(:info) - raise LintError, "logger #{logger.inspect} must respond to info" - end - - ## debug(message, &block) - unless logger.respond_to?(:debug) - raise LintError, "logger #{logger.inspect} must respond to debug" - end - - ## warn(message, &block) - unless logger.respond_to?(:warn) - raise LintError, "logger #{logger.inspect} must respond to warn" - end - - ## error(message, &block) - unless logger.respond_to?(:error) - raise LintError, "logger #{logger.inspect} must respond to error" - end - - ## fatal(message, &block) - unless logger.respond_to?(:fatal) - raise LintError, "logger #{logger.inspect} must respond to fatal" - end - end - - ## rack.multipart.buffer_size:: An Integer hint to the multipart parser as to what chunk size to use for reads and writes. - if bufsize = env[RACK_MULTIPART_BUFFER_SIZE] - unless bufsize.is_a?(Integer) && bufsize > 0 - raise LintError, "rack.multipart.buffer_size must be an Integer > 0 if specified" - end - end - - ## rack.multipart.tempfile_factory:: An object responding to #call with two arguments, the filename and content_type given for the multipart form field, and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate the tempfile for each multipart form file upload field, rather than the default class of Tempfile. - if tempfile_factory = env[RACK_MULTIPART_TEMPFILE_FACTORY] - raise LintError, "rack.multipart.tempfile_factory must respond to #call" unless tempfile_factory.respond_to?(:call) - env[RACK_MULTIPART_TEMPFILE_FACTORY] = lambda do |filename, content_type| - io = tempfile_factory.call(filename, content_type) - raise LintError, "rack.multipart.tempfile_factory return value must respond to #<<" unless io.respond_to?(:<<) - io - end - end - - ## The server or the application can store their own data in the - ## environment, too. The keys must contain at least one dot, - ## and should be prefixed uniquely. The prefix rack. - ## is reserved for use with the Rack core distribution and other - ## accepted specifications and must not be used otherwise. - ## - %w[REQUEST_METHOD SERVER_NAME QUERY_STRING SERVER_PROTOCOL rack.errors].each do |header| - raise LintError, "env missing required key #{header}" unless env.include? header - end - - ## The SERVER_PORT must be an Integer if set. - server_port = env["SERVER_PORT"] - unless server_port.nil? || (Integer(server_port) rescue false) - raise LintError, "env[SERVER_PORT] is not an Integer" - end - - ## The SERVER_NAME must be a valid authority as defined by RFC7540. - unless (URI.parse("http://#{env[SERVER_NAME]}/") rescue false) - raise LintError, "#{env[SERVER_NAME]} must be a valid authority" - end - - ## The HTTP_HOST must be a valid authority as defined by RFC7540. - unless (URI.parse("http://#{env[HTTP_HOST]}/") rescue false) - raise LintError, "#{env[HTTP_HOST]} must be a valid authority" - end - - ## The SERVER_PROTOCOL must match the regexp HTTP/\d(\.\d)?. - server_protocol = env['SERVER_PROTOCOL'] - unless %r{HTTP/\d(\.\d)?}.match?(server_protocol) - raise LintError, "env[SERVER_PROTOCOL] does not match HTTP/\\d(\\.\\d)?" - end - - ## The environment must not contain the keys - ## HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH - ## (use the versions without HTTP_). - %w[HTTP_CONTENT_TYPE HTTP_CONTENT_LENGTH].each { |header| - if env.include? header - raise LintError, "env contains #{header}, must use #{header[5..-1]}" - end - } - - ## The CGI keys (named without a period) must have String values. - ## If the string values for CGI keys contain non-ASCII characters, - ## they should use ASCII-8BIT encoding. - env.each { |key, value| - next if key.include? "." # Skip extensions - unless value.kind_of? String - raise LintError, "env variable #{key} has non-string value #{value.inspect}" - end - next if value.encoding == Encoding::ASCII_8BIT - unless value.b !~ /[\x80-\xff]/n - raise LintError, "env variable #{key} has value containing non-ASCII characters and has non-ASCII-8BIT encoding #{value.inspect} encoding: #{value.encoding}" - end - } - - ## There are the following restrictions: - - ## * rack.url_scheme must either be +http+ or +https+. - unless %w[http https].include?(env[RACK_URL_SCHEME]) - raise LintError, "rack.url_scheme unknown: #{env[RACK_URL_SCHEME].inspect}" - end - - ## * There may be a valid input stream in rack.input. - if rack_input = env[RACK_INPUT] - check_input_stream(rack_input) - @env[RACK_INPUT] = InputWrapper.new(rack_input) - end - - ## * There must be a valid error stream in rack.errors. - rack_errors = env[RACK_ERRORS] - check_error_stream(rack_errors) - @env[RACK_ERRORS] = ErrorWrapper.new(rack_errors) - - ## * There may be a valid hijack callback in rack.hijack - check_hijack env - ## * There may be a valid early hints callback in rack.early_hints - check_early_hints env - - ## * The REQUEST_METHOD must be a valid token. - unless env[REQUEST_METHOD] =~ /\A[0-9A-Za-z!\#$%&'*+.^_`|~-]+\z/ - raise LintError, "REQUEST_METHOD unknown: #{env[REQUEST_METHOD].dump}" - end - - ## * The SCRIPT_NAME, if non-empty, must start with / - if env.include?(SCRIPT_NAME) && env[SCRIPT_NAME] != "" && env[SCRIPT_NAME] !~ /\A\// - raise LintError, "SCRIPT_NAME must start with /" - end - - ## * The PATH_INFO, if provided, must be a valid request target or an empty string. - if env.include?(PATH_INFO) - case env[PATH_INFO] - when REQUEST_PATH_ASTERISK_FORM - ## * Only OPTIONS requests may have PATH_INFO set to * (asterisk-form). - unless env[REQUEST_METHOD] == OPTIONS - raise LintError, "Only OPTIONS requests may have PATH_INFO set to '*' (asterisk-form)" - end - when REQUEST_PATH_AUTHORITY_FORM - ## * Only CONNECT requests may have PATH_INFO set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. - unless env[REQUEST_METHOD] == CONNECT - raise LintError, "Only CONNECT requests may have PATH_INFO set to an authority (authority-form)" - end - when REQUEST_PATH_ABSOLUTE_FORM - ## * CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form). - if env[REQUEST_METHOD] == CONNECT || env[REQUEST_METHOD] == OPTIONS - raise LintError, "CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form)" - end - when REQUEST_PATH_ORIGIN_FORM - ## * Otherwise, PATH_INFO must start with a / and must not include a fragment part starting with '#' (origin-form). - when "" - # Empty string is okay. - else - raise LintError, "PATH_INFO must start with a '/' and must not include a fragment part starting with '#' (origin-form)" - end - end - - ## * The CONTENT_LENGTH, if given, must consist of digits only. - if env.include?("CONTENT_LENGTH") && env["CONTENT_LENGTH"] !~ /\A\d+\z/ - raise LintError, "Invalid CONTENT_LENGTH: #{env["CONTENT_LENGTH"]}" - end - - ## * One of SCRIPT_NAME or PATH_INFO must be - ## set. PATH_INFO should be / if - ## SCRIPT_NAME is empty. - unless env[SCRIPT_NAME] || env[PATH_INFO] - raise LintError, "One of SCRIPT_NAME or PATH_INFO must be set (make PATH_INFO '/' if SCRIPT_NAME is empty)" - end - ## SCRIPT_NAME never should be /, but instead be empty. - unless env[SCRIPT_NAME] != "/" - raise LintError, "SCRIPT_NAME cannot be '/', make it '' and PATH_INFO '/'" - end - - ## rack.response_finished:: An array of callables run by the server after the response has been - ## processed. This would typically be invoked after sending the response to the client, but it could also be - ## invoked if an error occurs while generating the response or sending the response; in that case, the error - ## argument will be a subclass of +Exception+. - ## The callables are invoked with +env, status, headers, error+ arguments and should not raise any - ## exceptions. They should be invoked in reverse order of registration. - if callables = env[RACK_RESPONSE_FINISHED] - raise LintError, "rack.response_finished must be an array of callable objects" unless callables.is_a?(Array) - - callables.each do |callable| - raise LintError, "rack.response_finished values must respond to call(env, status, headers, error)" unless callable.respond_to?(:call) - end - end - end - - ## - ## === The Input Stream - ## - ## The input stream is an IO-like object which contains the raw HTTP - ## POST data. - def check_input_stream(input) - ## When applicable, its external encoding must be "ASCII-8BIT" and it - ## must be opened in binary mode. - if input.respond_to?(:external_encoding) && input.external_encoding != Encoding::ASCII_8BIT - raise LintError, "rack.input #{input} does not have ASCII-8BIT as its external encoding" - end - if input.respond_to?(:binmode?) && !input.binmode? - raise LintError, "rack.input #{input} is not opened in binary mode" - end - - ## The input stream must respond to +gets+, +each+, and +read+. - [:gets, :each, :read].each { |method| - unless input.respond_to? method - raise LintError, "rack.input #{input} does not respond to ##{method}" - end - } - end - - class InputWrapper - def initialize(input) - @input = input - end - - ## * +gets+ must be called without arguments and return a string, - ## or +nil+ on EOF. - def gets(*args) - raise LintError, "rack.input#gets called with arguments" unless args.size == 0 - v = @input.gets - unless v.nil? or v.kind_of? String - raise LintError, "rack.input#gets didn't return a String" - end - v - end - - ## * +read+ behaves like IO#read. - ## Its signature is read([length, [buffer]]). - ## - ## If given, +length+ must be a non-negative Integer (>= 0) or +nil+, - ## and +buffer+ must be a String and may not be nil. - ## - ## If +length+ is given and not nil, then this method reads at most - ## +length+ bytes from the input stream. - ## - ## If +length+ is not given or nil, then this method reads - ## all data until EOF. - ## - ## When EOF is reached, this method returns nil if +length+ is given - ## and not nil, or "" if +length+ is not given or is nil. - ## - ## If +buffer+ is given, then the read data will be placed - ## into +buffer+ instead of a newly created String object. - def read(*args) - unless args.size <= 2 - raise LintError, "rack.input#read called with too many arguments" - end - if args.size >= 1 - unless args.first.kind_of?(Integer) || args.first.nil? - raise LintError, "rack.input#read called with non-integer and non-nil length" - end - unless args.first.nil? || args.first >= 0 - raise LintError, "rack.input#read called with a negative length" - end - end - if args.size >= 2 - unless args[1].kind_of?(String) - raise LintError, "rack.input#read called with non-String buffer" - end - end - - v = @input.read(*args) - - unless v.nil? or v.kind_of? String - raise LintError, "rack.input#read didn't return nil or a String" - end - if args[0].nil? - unless !v.nil? - raise LintError, "rack.input#read(nil) returned nil on EOF" - end - end - - v - end - - ## * +each+ must be called without arguments and only yield Strings. - def each(*args) - raise LintError, "rack.input#each called with arguments" unless args.size == 0 - @input.each { |line| - unless line.kind_of? String - raise LintError, "rack.input#each didn't yield a String" - end - yield line - } - end - - ## * +close+ can be called on the input stream to indicate that - ## any remaining input is not needed. - def close(*args) - @input.close(*args) - end - end - - ## - ## === The Error Stream - ## - def check_error_stream(error) - ## The error stream must respond to +puts+, +write+ and +flush+. - [:puts, :write, :flush].each { |method| - unless error.respond_to? method - raise LintError, "rack.error #{error} does not respond to ##{method}" - end - } - end - - class ErrorWrapper - def initialize(error) - @error = error - end - - ## * +puts+ must be called with a single argument that responds to +to_s+. - def puts(str) - @error.puts str - end - - ## * +write+ must be called with a single argument that is a String. - def write(str) - raise LintError, "rack.errors#write not called with a String" unless str.kind_of? String - @error.write str - end - - ## * +flush+ must be called without arguments and must be called - ## in order to make the error appear for sure. - def flush - @error.flush - end - - ## * +close+ must never be called on the error stream. - def close(*args) - raise LintError, "rack.errors#close must not be called" - end - end - - ## - ## === Hijacking - ## - ## The hijacking interfaces provides a means for an application to take - ## control of the HTTP connection. There are two distinct hijack - ## interfaces: full hijacking where the application takes over the raw - ## connection, and partial hijacking where the application takes over - ## just the response body stream. In both cases, the application is - ## responsible for closing the hijacked stream. - ## - ## Full hijacking only works with HTTP/1. Partial hijacking is functionally - ## equivalent to streaming bodies, and is still optionally supported for - ## backwards compatibility with older Rack versions. - ## - ## ==== Full Hijack - ## - ## Full hijack is used to completely take over an HTTP/1 connection. It - ## occurs before any headers are written and causes the request to - ## ignores any response generated by the application. - ## - ## It is intended to be used when applications need access to raw HTTP/1 - ## connection. - ## - def check_hijack(env) - ## If +rack.hijack+ is present in +env+, it must respond to +call+ - if original_hijack = env[RACK_HIJACK] - raise LintError, "rack.hijack must respond to call" unless original_hijack.respond_to?(:call) - - env[RACK_HIJACK] = proc do - io = original_hijack.call - - ## and return an +IO+ instance which can be used to read and write - ## to the underlying connection using HTTP/1 semantics and - ## formatting. - raise LintError, "rack.hijack must return an IO instance" unless io.is_a?(IO) - - io - end - end - end - - ## - ## ==== Partial Hijack - ## - ## Partial hijack is used for bi-directional streaming of the request and - ## response body. It occurs after the status and headers are written by - ## the server and causes the server to ignore the Body of the response. - ## - ## It is intended to be used when applications need bi-directional - ## streaming. - ## - def check_hijack_response(headers, env) - ## If +rack.hijack?+ is present in +env+ and truthy, - if env[RACK_IS_HIJACK] - ## an application may set the special response header +rack.hijack+ - if original_hijack = headers[RACK_HIJACK] - ## to an object that responds to +call+, - unless original_hijack.respond_to?(:call) - raise LintError, 'rack.hijack header must respond to #call' - end - ## accepting a +stream+ argument. - return proc do |io| - original_hijack.call StreamWrapper.new(io) - end - end - ## - ## After the response status and headers have been sent, this hijack - ## callback will be invoked with a +stream+ argument which follows the - ## same interface as outlined in "Streaming Body". Servers must - ## ignore the +body+ part of the response tuple when the - ## +rack.hijack+ response header is present. Using an empty +Array+ - ## instance is recommended. - else - ## - ## The special response header +rack.hijack+ must only be set - ## if the request +env+ has a truthy +rack.hijack?+. - if headers.key?(RACK_HIJACK) - raise LintError, 'rack.hijack header must not be present if server does not support hijacking' - end - end - - nil - end - - ## - ## === Early Hints - ## - ## The application or any middleware may call the rack.early_hints - ## with an object which would be valid as the headers of a Rack response. - def check_early_hints(env) - if env[RACK_EARLY_HINTS] - ## - ## If rack.early_hints is present, it must respond to #call. - unless env[RACK_EARLY_HINTS].respond_to?(:call) - raise LintError, "rack.early_hints must respond to call" - end - - original_callback = env[RACK_EARLY_HINTS] - env[RACK_EARLY_HINTS] = lambda do |headers| - ## If rack.early_hints is called, it must be called with - ## valid Rack response headers. - check_headers(headers) - original_callback.call(headers) - end - end - end - - ## - ## == The Response - ## - ## === The Status - ## - def check_status(status) - ## This is an HTTP status. It must be an Integer greater than or equal to - ## 100. - unless status.is_a?(Integer) && status >= 100 - raise LintError, "Status must be an Integer >=100" - end - end - - ## - ## === The Headers - ## - def check_headers(headers) - ## The headers must be a unfrozen Hash. - unless headers.kind_of?(Hash) - raise LintError, "headers object should be a hash, but isn't (got #{headers.class} as headers)" - end - - if headers.frozen? - raise LintError, "headers object should not be frozen, but is" - end - - headers.each do |key, value| - ## The header keys must be Strings. - unless key.kind_of? String - raise LintError, "header key must be a string, was #{key.class}" - end - - ## Special headers starting "rack." are for communicating with the - ## server, and must not be sent back to the client. - next if key.start_with?("rack.") - - ## The header must not contain a +Status+ key. - raise LintError, "header must not contain status" if key == "status" - ## Header keys must conform to RFC7230 token specification, i.e. cannot - ## contain non-printable ASCII, DQUOTE or "(),/:;<=>?@[\]{}". - raise LintError, "invalid header name: #{key}" if key =~ /[\(\),\/:;<=>\?@\[\\\]{}[:cntrl:]]/ - ## Header keys must not contain uppercase ASCII characters (A-Z). - raise LintError, "uppercase character in header name: #{key}" if key =~ /[A-Z]/ - - ## Header values must be either a String instance, - if value.kind_of?(String) - check_header_value(key, value) - elsif value.kind_of?(Array) - ## or an Array of String instances, - value.each{|value| check_header_value(key, value)} - else - raise LintError, "a header value must be a String or Array of Strings, but the value of '#{key}' is a #{value.class}" - end - end - end - - def check_header_value(key, value) - ## such that each String instance must not contain characters below 037. - if value =~ /[\000-\037]/ - raise LintError, "invalid header value #{key}: #{value.inspect}" - end - end - - ## - ## ==== The +content-type+ Header - ## - def check_content_type_header(status, headers) - headers.each { |key, value| - ## There must not be a content-type header key when the +Status+ is 1xx, - ## 204, or 304. - if key == "content-type" - if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i - raise LintError, "content-type header found in #{status} response, not allowed" - end - return - end - } - end - - ## - ## ==== The +content-length+ Header - ## - def check_content_length_header(status, headers) - headers.each { |key, value| - if key == 'content-length' - ## There must not be a content-length header key when the - ## +Status+ is 1xx, 204, or 304. - if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i - raise LintError, "content-length header found in #{status} response, not allowed" - end - @content_length = value - end - } - end - - def verify_content_length(size) - if @head_request - unless size == 0 - raise LintError, "Response body was given for HEAD request, but should be empty" - end - elsif @content_length - unless @content_length == size.to_s - raise LintError, "content-length header was #{@content_length}, but should be #{size}" - end - end - end - - ## - ## ==== The +rack.protocol+ Header - ## - def check_rack_protocol_header(status, headers) - ## If the +rack.protocol+ header is present, it must be a +String+, and - ## must be one of the values from the +rack.protocol+ array from the - ## environment. - protocol = headers['rack.protocol'] - - if protocol - request_protocols = @env['rack.protocol'] - - if request_protocols.nil? - raise LintError, "rack.protocol header is #{protocol.inspect}, but rack.protocol was not set in request!" - elsif !request_protocols.include?(protocol) - raise LintError, "rack.protocol header is #{protocol.inspect}, but should be one of #{request_protocols.inspect} from the request!" - end - end - end - ## - ## Setting this value informs the server that it should perform a - ## connection upgrade. In HTTP/1, this is done using the +upgrade+ - ## header. In HTTP/2, this is done by accepting the request. - ## - ## === The Body - ## - ## The Body is typically an +Array+ of +String+ instances, an enumerable - ## that yields +String+ instances, a +Proc+ instance, or a File-like - ## object. - ## - ## The Body must respond to +each+ or +call+. It may optionally respond - ## to +to_path+ or +to_ary+. A Body that responds to +each+ is considered - ## to be an Enumerable Body. A Body that responds to +call+ is considered - ## to be a Streaming Body. - ## - ## A Body that responds to both +each+ and +call+ must be treated as an - ## Enumerable Body, not a Streaming Body. If it responds to +each+, you - ## must call +each+ and not +call+. If the Body doesn't respond to - ## +each+, then you can assume it responds to +call+. - ## - ## The Body must either be consumed or returned. The Body is consumed by - ## optionally calling either +each+ or +call+. - ## Then, if the Body responds to +close+, it must be called to release - ## any resources associated with the generation of the body. - ## In other words, +close+ must always be called at least once; typically - ## after the web server has sent the response to the client, but also in - ## cases where the Rack application makes internal/virtual requests and - ## discards the response. - ## - def close - ## - ## After calling +close+, the Body is considered closed and should not - ## be consumed again. - @closed = true - - ## If the original Body is replaced by a new Body, the new Body must - ## also consume the original Body by calling +close+ if possible. - @body.close if @body.respond_to?(:close) - - index = @lint.index(self) - unless @env['rack.lint'][0..index].all? {|lint| lint.instance_variable_get(:@closed)} - raise LintError, "Body has not been closed" - end - end - - def verify_to_path - ## - ## If the Body responds to +to_path+, it must return a +String+ - ## path for the local file system whose contents are identical - ## to that produced by calling +each+; this may be used by the - ## server as an alternative, possibly more efficient way to - ## transport the response. The +to_path+ method does not consume - ## the body. - if @body.respond_to?(:to_path) - unless ::File.exist? @body.to_path - raise LintError, "The file identified by body.to_path does not exist" - end - end - end - - ## - ## ==== Enumerable Body - ## - def each - ## The Enumerable Body must respond to +each+. - raise LintError, "Enumerable Body must respond to each" unless @body.respond_to?(:each) - - ## It must only be called once. - raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? - - ## It must not be called after being closed, - raise LintError, "Response body is already closed" if @closed - - @invoked = :each - - @body.each do |chunk| - ## and must only yield String values. - unless chunk.kind_of? String - raise LintError, "Body yielded non-string value #{chunk.inspect}" - end - - ## - ## Middleware must not call +each+ directly on the Body. - ## Instead, middleware can return a new Body that calls +each+ on the - ## original Body, yielding at least once per iteration. - if @lint[0] == self - @env['rack.lint.body_iteration'] += 1 - else - if (@env['rack.lint.body_iteration'] -= 1) > 0 - raise LintError, "New body must yield at least once per iteration of old body" - end - end - - @size += chunk.bytesize - yield chunk - end - - verify_content_length(@size) - - verify_to_path - end - - BODY_METHODS = {to_ary: true, each: true, call: true, to_path: true} - - def to_path - @body.to_path - end - - def respond_to?(name, *) - if BODY_METHODS.key?(name) - @body.respond_to?(name) - else - super - end - end - - ## - ## If the Body responds to +to_ary+, it must return an +Array+ whose - ## contents are identical to that produced by calling +each+. - ## Middleware may call +to_ary+ directly on the Body and return a new - ## Body in its place. In other words, middleware can only process the - ## Body directly if it responds to +to_ary+. If the Body responds to both - ## +to_ary+ and +close+, its implementation of +to_ary+ must call - ## +close+. - def to_ary - @body.to_ary.tap do |content| - unless content == @body.enum_for.to_a - raise LintError, "#to_ary not identical to contents produced by calling #each" - end - end - ensure - close - end - - ## - ## ==== Streaming Body - ## - def call(stream) - ## The Streaming Body must respond to +call+. - raise LintError, "Streaming Body must respond to call" unless @body.respond_to?(:call) - - ## It must only be called once. - raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? - - ## It must not be called after being closed. - raise LintError, "Response body is already closed" if @closed - - @invoked = :call - - ## It takes a +stream+ argument. - ## - ## The +stream+ argument must implement: - ## read, write, <<, flush, close, close_read, close_write, closed? - ## - @body.call(StreamWrapper.new(stream)) - end - - class StreamWrapper - extend Forwardable - - ## The semantics of these IO methods must be a best effort match to - ## those of a normal Ruby IO or Socket object, using standard arguments - ## and raising standard exceptions. Servers are encouraged to simply - ## pass on real IO objects, although it is recognized that this approach - ## is not directly compatible with HTTP/2. - REQUIRED_METHODS = [ - :read, :write, :<<, :flush, :close, - :close_read, :close_write, :closed? - ] - - def_delegators :@stream, *REQUIRED_METHODS - - def initialize(stream) - @stream = stream - - REQUIRED_METHODS.each do |method_name| - raise LintError, "Stream must respond to #{method_name}" unless stream.respond_to?(method_name) - end - end - end - - # :startdoc: - end - end -end - -## -## == Thanks -## Some parts of this specification are adopted from {PEP 333 – Python Web Server Gateway Interface v1.0}[https://peps.python.org/pep-0333/] -## I'd like to thank everyone involved in that effort. diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lock.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lock.rb deleted file mode 100644 index 342123a..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/lock.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require_relative 'body_proxy' - -module Rack - # Rack::Lock locks every request inside a mutex, so that every request - # will effectively be executed synchronously. - class Lock - def initialize(app, mutex = Mutex.new) - @app, @mutex = app, mutex - end - - def call(env) - @mutex.lock - begin - response = @app.call(env) - returned = response << BodyProxy.new(response.pop) { unlock } - ensure - unlock unless returned - end - end - - private - - def unlock - @mutex.unlock - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/logger.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/logger.rb deleted file mode 100644 index 081212d..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/logger.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'logger' -require_relative 'constants' - -warn "Rack::Logger is deprecated and will be removed in Rack 3.2.", uplevel: 1 - -module Rack - # Sets up rack.logger to write to rack.errors stream - class Logger - def initialize(app, level = ::Logger::INFO) - @app, @level = app, level - end - - def call(env) - logger = ::Logger.new(env[RACK_ERRORS]) - logger.level = @level - - env[RACK_LOGGER] = logger - @app.call(env) - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/media_type.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/media_type.rb deleted file mode 100644 index 7fc1e39..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/media_type.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module Rack - # Rack::MediaType parse media type and parameters out of content_type string - - class MediaType - SPLIT_PATTERN = /[;,]/ - - class << self - # The media type (type/subtype) portion of the CONTENT_TYPE header - # without any media type parameters. e.g., when CONTENT_TYPE is - # "text/plain;charset=utf-8", the media-type is "text/plain". - # - # For more information on the use of media types in HTTP, see: - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 - def type(content_type) - return nil unless content_type - if type = content_type.split(SPLIT_PATTERN, 2).first - type.rstrip! - type.downcase! - type - end - end - - # The media type parameters provided in CONTENT_TYPE as a Hash, or - # an empty Hash if no CONTENT_TYPE or media-type parameters were - # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", - # this method responds with the following Hash: - # { 'charset' => 'utf-8' } - def params(content_type) - return {} if content_type.nil? - - content_type.split(SPLIT_PATTERN)[1..-1].each_with_object({}) do |s, hsh| - s.strip! - k, v = s.split('=', 2) - k.downcase! - hsh[k] = strip_doublequotes(v) - end - end - - private - - def strip_doublequotes(str) - (str.start_with?('"') && str.end_with?('"')) ? str[1..-2] : str - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/method_override.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/method_override.rb deleted file mode 100644 index 6125b19..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/method_override.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'request' -require_relative 'utils' - -module Rack - class MethodOverride - HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK] - - METHOD_OVERRIDE_PARAM_KEY = "_method" - HTTP_METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE" - ALLOWED_METHODS = %w[POST] - - def initialize(app) - @app = app - end - - def call(env) - if allowed_methods.include?(env[REQUEST_METHOD]) - method = method_override(env) - if HTTP_METHODS.include?(method) - env[RACK_METHODOVERRIDE_ORIGINAL_METHOD] = env[REQUEST_METHOD] - env[REQUEST_METHOD] = method - end - end - - @app.call(env) - end - - def method_override(env) - req = Request.new(env) - method = method_override_param(req) || - env[HTTP_METHOD_OVERRIDE_HEADER] - begin - method.to_s.upcase - rescue ArgumentError - env[RACK_ERRORS].puts "Invalid string for method" - end - end - - private - - def allowed_methods - ALLOWED_METHODS - end - - def method_override_param(req) - req.POST[METHOD_OVERRIDE_PARAM_KEY] if req.form_data? || req.parseable_data? - rescue Utils::InvalidParameterError, Utils::ParameterTypeError, QueryParser::ParamsTooDeepError - req.get_header(RACK_ERRORS).puts "Invalid or incomplete POST params" - rescue EOFError - req.get_header(RACK_ERRORS).puts "Bad request content body" - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mime.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mime.rb deleted file mode 100644 index 0272968..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mime.rb +++ /dev/null @@ -1,694 +0,0 @@ -# frozen_string_literal: true - -module Rack - module Mime - # Returns String with mime type if found, otherwise use +fallback+. - # +ext+ should be filename extension in the '.ext' format that - # File.extname(file) returns. - # +fallback+ may be any object - # - # Also see the documentation for MIME_TYPES - # - # Usage: - # Rack::Mime.mime_type('.foo') - # - # This is a shortcut for: - # Rack::Mime::MIME_TYPES.fetch('.foo', 'application/octet-stream') - - def mime_type(ext, fallback = 'application/octet-stream') - MIME_TYPES.fetch(ext.to_s.downcase, fallback) - end - module_function :mime_type - - # Returns true if the given value is a mime match for the given mime match - # specification, false otherwise. - # - # Rack::Mime.match?('text/html', 'text/*') => true - # Rack::Mime.match?('text/plain', '*') => true - # Rack::Mime.match?('text/html', 'application/json') => false - - def match?(value, matcher) - v1, v2 = value.split('/', 2) - m1, m2 = matcher.split('/', 2) - - (m1 == '*' || v1 == m1) && (m2.nil? || m2 == '*' || m2 == v2) - end - module_function :match? - - # List of most common mime-types, selected various sources - # according to their usefulness in a webserving scope for Ruby - # users. - # - # To amend this list with your local mime.types list you can use: - # - # require 'webrick/httputils' - # list = WEBrick::HTTPUtils.load_mime_types('/etc/mime.types') - # Rack::Mime::MIME_TYPES.merge!(list) - # - # N.B. On Ubuntu the mime.types file does not include the leading period, so - # users may need to modify the data before merging into the hash. - - MIME_TYPES = { - ".123" => "application/vnd.lotus-1-2-3", - ".3dml" => "text/vnd.in3d.3dml", - ".3g2" => "video/3gpp2", - ".3gp" => "video/3gpp", - ".a" => "application/octet-stream", - ".acc" => "application/vnd.americandynamics.acc", - ".ace" => "application/x-ace-compressed", - ".acu" => "application/vnd.acucobol", - ".aep" => "application/vnd.audiograph", - ".afp" => "application/vnd.ibm.modcap", - ".ai" => "application/postscript", - ".aif" => "audio/x-aiff", - ".aiff" => "audio/x-aiff", - ".ami" => "application/vnd.amiga.ami", - ".apng" => "image/apng", - ".appcache" => "text/cache-manifest", - ".apr" => "application/vnd.lotus-approach", - ".asc" => "application/pgp-signature", - ".asf" => "video/x-ms-asf", - ".asm" => "text/x-asm", - ".aso" => "application/vnd.accpac.simply.aso", - ".asx" => "video/x-ms-asf", - ".atc" => "application/vnd.acucorp", - ".atom" => "application/atom+xml", - ".atomcat" => "application/atomcat+xml", - ".atomsvc" => "application/atomsvc+xml", - ".atx" => "application/vnd.antix.game-component", - ".au" => "audio/basic", - ".avi" => "video/x-msvideo", - ".avif" => "image/avif", - ".bat" => "application/x-msdownload", - ".bcpio" => "application/x-bcpio", - ".bdm" => "application/vnd.syncml.dm+wbxml", - ".bh2" => "application/vnd.fujitsu.oasysprs", - ".bin" => "application/octet-stream", - ".bmi" => "application/vnd.bmi", - ".bmp" => "image/bmp", - ".box" => "application/vnd.previewsystems.box", - ".btif" => "image/prs.btif", - ".bz" => "application/x-bzip", - ".bz2" => "application/x-bzip2", - ".c" => "text/x-c", - ".c4g" => "application/vnd.clonk.c4group", - ".cab" => "application/vnd.ms-cab-compressed", - ".cc" => "text/x-c", - ".ccxml" => "application/ccxml+xml", - ".cdbcmsg" => "application/vnd.contact.cmsg", - ".cdkey" => "application/vnd.mediastation.cdkey", - ".cdx" => "chemical/x-cdx", - ".cdxml" => "application/vnd.chemdraw+xml", - ".cdy" => "application/vnd.cinderella", - ".cer" => "application/pkix-cert", - ".cgm" => "image/cgm", - ".chat" => "application/x-chat", - ".chm" => "application/vnd.ms-htmlhelp", - ".chrt" => "application/vnd.kde.kchart", - ".cif" => "chemical/x-cif", - ".cii" => "application/vnd.anser-web-certificate-issue-initiation", - ".cil" => "application/vnd.ms-artgalry", - ".cla" => "application/vnd.claymore", - ".class" => "application/octet-stream", - ".clkk" => "application/vnd.crick.clicker.keyboard", - ".clkp" => "application/vnd.crick.clicker.palette", - ".clkt" => "application/vnd.crick.clicker.template", - ".clkw" => "application/vnd.crick.clicker.wordbank", - ".clkx" => "application/vnd.crick.clicker", - ".clp" => "application/x-msclip", - ".cmc" => "application/vnd.cosmocaller", - ".cmdf" => "chemical/x-cmdf", - ".cml" => "chemical/x-cml", - ".cmp" => "application/vnd.yellowriver-custom-menu", - ".cmx" => "image/x-cmx", - ".com" => "application/x-msdownload", - ".conf" => "text/plain", - ".cpio" => "application/x-cpio", - ".cpp" => "text/x-c", - ".cpt" => "application/mac-compactpro", - ".crd" => "application/x-mscardfile", - ".crl" => "application/pkix-crl", - ".crt" => "application/x-x509-ca-cert", - ".csh" => "application/x-csh", - ".csml" => "chemical/x-csml", - ".csp" => "application/vnd.commonspace", - ".css" => "text/css", - ".csv" => "text/csv", - ".curl" => "application/vnd.curl", - ".cww" => "application/prs.cww", - ".cxx" => "text/x-c", - ".daf" => "application/vnd.mobius.daf", - ".davmount" => "application/davmount+xml", - ".dcr" => "application/x-director", - ".dd2" => "application/vnd.oma.dd2+xml", - ".ddd" => "application/vnd.fujixerox.ddd", - ".deb" => "application/x-debian-package", - ".der" => "application/x-x509-ca-cert", - ".dfac" => "application/vnd.dreamfactory", - ".diff" => "text/x-diff", - ".dis" => "application/vnd.mobius.dis", - ".djv" => "image/vnd.djvu", - ".djvu" => "image/vnd.djvu", - ".dll" => "application/x-msdownload", - ".dmg" => "application/octet-stream", - ".dna" => "application/vnd.dna", - ".doc" => "application/msword", - ".docm" => "application/vnd.ms-word.document.macroEnabled.12", - ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".dot" => "application/msword", - ".dotm" => "application/vnd.ms-word.template.macroEnabled.12", - ".dotx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.template", - ".dp" => "application/vnd.osgi.dp", - ".dpg" => "application/vnd.dpgraph", - ".dsc" => "text/prs.lines.tag", - ".dtd" => "application/xml-dtd", - ".dts" => "audio/vnd.dts", - ".dtshd" => "audio/vnd.dts.hd", - ".dv" => "video/x-dv", - ".dvi" => "application/x-dvi", - ".dwf" => "model/vnd.dwf", - ".dwg" => "image/vnd.dwg", - ".dxf" => "image/vnd.dxf", - ".dxp" => "application/vnd.spotfire.dxp", - ".ear" => "application/java-archive", - ".ecelp4800" => "audio/vnd.nuera.ecelp4800", - ".ecelp7470" => "audio/vnd.nuera.ecelp7470", - ".ecelp9600" => "audio/vnd.nuera.ecelp9600", - ".ecma" => "application/ecmascript", - ".edm" => "application/vnd.novadigm.edm", - ".edx" => "application/vnd.novadigm.edx", - ".efif" => "application/vnd.picsel", - ".ei6" => "application/vnd.pg.osasli", - ".eml" => "message/rfc822", - ".eol" => "audio/vnd.digital-winds", - ".eot" => "application/vnd.ms-fontobject", - ".eps" => "application/postscript", - ".es3" => "application/vnd.eszigno3+xml", - ".esf" => "application/vnd.epson.esf", - ".etx" => "text/x-setext", - ".exe" => "application/x-msdownload", - ".ext" => "application/vnd.novadigm.ext", - ".ez" => "application/andrew-inset", - ".ez2" => "application/vnd.ezpix-album", - ".ez3" => "application/vnd.ezpix-package", - ".f" => "text/x-fortran", - ".f77" => "text/x-fortran", - ".f90" => "text/x-fortran", - ".fbs" => "image/vnd.fastbidsheet", - ".fdf" => "application/vnd.fdf", - ".fe_launch" => "application/vnd.denovo.fcselayout-link", - ".fg5" => "application/vnd.fujitsu.oasysgp", - ".fli" => "video/x-fli", - ".flif" => "image/flif", - ".flo" => "application/vnd.micrografx.flo", - ".flv" => "video/x-flv", - ".flw" => "application/vnd.kde.kivio", - ".flx" => "text/vnd.fmi.flexstor", - ".fly" => "text/vnd.fly", - ".fm" => "application/vnd.framemaker", - ".fnc" => "application/vnd.frogans.fnc", - ".for" => "text/x-fortran", - ".fpx" => "image/vnd.fpx", - ".fsc" => "application/vnd.fsc.weblaunch", - ".fst" => "image/vnd.fst", - ".ftc" => "application/vnd.fluxtime.clip", - ".fti" => "application/vnd.anser-web-funds-transfer-initiation", - ".fvt" => "video/vnd.fvt", - ".fzs" => "application/vnd.fuzzysheet", - ".g3" => "image/g3fax", - ".gac" => "application/vnd.groove-account", - ".gdl" => "model/vnd.gdl", - ".gem" => "application/octet-stream", - ".gemspec" => "text/x-script.ruby", - ".ghf" => "application/vnd.groove-help", - ".gif" => "image/gif", - ".gim" => "application/vnd.groove-identity-message", - ".gmx" => "application/vnd.gmx", - ".gph" => "application/vnd.flographit", - ".gqf" => "application/vnd.grafeq", - ".gram" => "application/srgs", - ".grv" => "application/vnd.groove-injector", - ".grxml" => "application/srgs+xml", - ".gtar" => "application/x-gtar", - ".gtm" => "application/vnd.groove-tool-message", - ".gtw" => "model/vnd.gtw", - ".gv" => "text/vnd.graphviz", - ".gz" => "application/x-gzip", - ".h" => "text/x-c", - ".h261" => "video/h261", - ".h263" => "video/h263", - ".h264" => "video/h264", - ".hbci" => "application/vnd.hbci", - ".hdf" => "application/x-hdf", - ".heic" => "image/heic", - ".heics" => "image/heic-sequence", - ".heif" => "image/heif", - ".heifs" => "image/heif-sequence", - ".hh" => "text/x-c", - ".hlp" => "application/winhlp", - ".hpgl" => "application/vnd.hp-hpgl", - ".hpid" => "application/vnd.hp-hpid", - ".hps" => "application/vnd.hp-hps", - ".hqx" => "application/mac-binhex40", - ".htc" => "text/x-component", - ".htke" => "application/vnd.kenameaapp", - ".htm" => "text/html", - ".html" => "text/html", - ".hvd" => "application/vnd.yamaha.hv-dic", - ".hvp" => "application/vnd.yamaha.hv-voice", - ".hvs" => "application/vnd.yamaha.hv-script", - ".icc" => "application/vnd.iccprofile", - ".ice" => "x-conference/x-cooltalk", - ".ico" => "image/vnd.microsoft.icon", - ".ics" => "text/calendar", - ".ief" => "image/ief", - ".ifb" => "text/calendar", - ".ifm" => "application/vnd.shana.informed.formdata", - ".igl" => "application/vnd.igloader", - ".igs" => "model/iges", - ".igx" => "application/vnd.micrografx.igx", - ".iif" => "application/vnd.shana.informed.interchange", - ".imp" => "application/vnd.accpac.simply.imp", - ".ims" => "application/vnd.ms-ims", - ".ipk" => "application/vnd.shana.informed.package", - ".irm" => "application/vnd.ibm.rights-management", - ".irp" => "application/vnd.irepository.package+xml", - ".iso" => "application/octet-stream", - ".itp" => "application/vnd.shana.informed.formtemplate", - ".ivp" => "application/vnd.immervision-ivp", - ".ivu" => "application/vnd.immervision-ivu", - ".jad" => "text/vnd.sun.j2me.app-descriptor", - ".jam" => "application/vnd.jam", - ".jar" => "application/java-archive", - ".java" => "text/x-java-source", - ".jisp" => "application/vnd.jisp", - ".jlt" => "application/vnd.hp-jlyt", - ".jnlp" => "application/x-java-jnlp-file", - ".joda" => "application/vnd.joost.joda-archive", - ".jp2" => "image/jp2", - ".jpeg" => "image/jpeg", - ".jpg" => "image/jpeg", - ".jpgv" => "video/jpeg", - ".jpm" => "video/jpm", - ".js" => "text/javascript", - ".json" => "application/json", - ".karbon" => "application/vnd.kde.karbon", - ".kfo" => "application/vnd.kde.kformula", - ".kia" => "application/vnd.kidspiration", - ".kml" => "application/vnd.google-earth.kml+xml", - ".kmz" => "application/vnd.google-earth.kmz", - ".kne" => "application/vnd.kinar", - ".kon" => "application/vnd.kde.kontour", - ".kpr" => "application/vnd.kde.kpresenter", - ".ksp" => "application/vnd.kde.kspread", - ".ktz" => "application/vnd.kahootz", - ".kwd" => "application/vnd.kde.kword", - ".latex" => "application/x-latex", - ".lbd" => "application/vnd.llamagraphics.life-balance.desktop", - ".lbe" => "application/vnd.llamagraphics.life-balance.exchange+xml", - ".les" => "application/vnd.hhe.lesson-player", - ".link66" => "application/vnd.route66.link66+xml", - ".log" => "text/plain", - ".lostxml" => "application/lost+xml", - ".lrm" => "application/vnd.ms-lrm", - ".ltf" => "application/vnd.frogans.ltf", - ".lvp" => "audio/vnd.lucent.voice", - ".lwp" => "application/vnd.lotus-wordpro", - ".m3u" => "audio/x-mpegurl", - ".m3u8" => "application/x-mpegurl", - ".m4a" => "audio/mp4a-latm", - ".m4v" => "video/mp4", - ".ma" => "application/mathematica", - ".mag" => "application/vnd.ecowin.chart", - ".man" => "text/troff", - ".manifest" => "text/cache-manifest", - ".mathml" => "application/mathml+xml", - ".mbk" => "application/vnd.mobius.mbk", - ".mbox" => "application/mbox", - ".mc1" => "application/vnd.medcalcdata", - ".mcd" => "application/vnd.mcd", - ".mdb" => "application/x-msaccess", - ".mdi" => "image/vnd.ms-modi", - ".mdoc" => "text/troff", - ".me" => "text/troff", - ".mfm" => "application/vnd.mfmp", - ".mgz" => "application/vnd.proteus.magazine", - ".mid" => "audio/midi", - ".midi" => "audio/midi", - ".mif" => "application/vnd.mif", - ".mime" => "message/rfc822", - ".mj2" => "video/mj2", - ".mjs" => "text/javascript", - ".mlp" => "application/vnd.dolby.mlp", - ".mmd" => "application/vnd.chipnuts.karaoke-mmd", - ".mmf" => "application/vnd.smaf", - ".mml" => "application/mathml+xml", - ".mmr" => "image/vnd.fujixerox.edmics-mmr", - ".mng" => "video/x-mng", - ".mny" => "application/x-msmoney", - ".mov" => "video/quicktime", - ".movie" => "video/x-sgi-movie", - ".mp3" => "audio/mpeg", - ".mp4" => "video/mp4", - ".mp4a" => "audio/mp4", - ".mp4s" => "application/mp4", - ".mp4v" => "video/mp4", - ".mpc" => "application/vnd.mophun.certificate", - ".mpd" => "application/dash+xml", - ".mpeg" => "video/mpeg", - ".mpg" => "video/mpeg", - ".mpga" => "audio/mpeg", - ".mpkg" => "application/vnd.apple.installer+xml", - ".mpm" => "application/vnd.blueice.multipass", - ".mpn" => "application/vnd.mophun.application", - ".mpp" => "application/vnd.ms-project", - ".mpy" => "application/vnd.ibm.minipay", - ".mqy" => "application/vnd.mobius.mqy", - ".mrc" => "application/marc", - ".ms" => "text/troff", - ".mscml" => "application/mediaservercontrol+xml", - ".mseq" => "application/vnd.mseq", - ".msf" => "application/vnd.epson.msf", - ".msh" => "model/mesh", - ".msi" => "application/x-msdownload", - ".msl" => "application/vnd.mobius.msl", - ".msty" => "application/vnd.muvee.style", - ".mts" => "model/vnd.mts", - ".mus" => "application/vnd.musician", - ".mvb" => "application/x-msmediaview", - ".mwf" => "application/vnd.mfer", - ".mxf" => "application/mxf", - ".mxl" => "application/vnd.recordare.musicxml", - ".mxml" => "application/xv+xml", - ".mxs" => "application/vnd.triscape.mxs", - ".mxu" => "video/vnd.mpegurl", - ".n" => "application/vnd.nokia.n-gage.symbian.install", - ".nc" => "application/x-netcdf", - ".ngdat" => "application/vnd.nokia.n-gage.data", - ".nlu" => "application/vnd.neurolanguage.nlu", - ".nml" => "application/vnd.enliven", - ".nnd" => "application/vnd.noblenet-directory", - ".nns" => "application/vnd.noblenet-sealer", - ".nnw" => "application/vnd.noblenet-web", - ".npx" => "image/vnd.net-fpx", - ".nsf" => "application/vnd.lotus-notes", - ".oa2" => "application/vnd.fujitsu.oasys2", - ".oa3" => "application/vnd.fujitsu.oasys3", - ".oas" => "application/vnd.fujitsu.oasys", - ".obd" => "application/x-msbinder", - ".oda" => "application/oda", - ".odc" => "application/vnd.oasis.opendocument.chart", - ".odf" => "application/vnd.oasis.opendocument.formula", - ".odg" => "application/vnd.oasis.opendocument.graphics", - ".odi" => "application/vnd.oasis.opendocument.image", - ".odp" => "application/vnd.oasis.opendocument.presentation", - ".ods" => "application/vnd.oasis.opendocument.spreadsheet", - ".odt" => "application/vnd.oasis.opendocument.text", - ".oga" => "audio/ogg", - ".ogg" => "application/ogg", - ".ogv" => "video/ogg", - ".ogx" => "application/ogg", - ".org" => "application/vnd.lotus-organizer", - ".otc" => "application/vnd.oasis.opendocument.chart-template", - ".otf" => "font/otf", - ".otg" => "application/vnd.oasis.opendocument.graphics-template", - ".oth" => "application/vnd.oasis.opendocument.text-web", - ".oti" => "application/vnd.oasis.opendocument.image-template", - ".otm" => "application/vnd.oasis.opendocument.text-master", - ".ots" => "application/vnd.oasis.opendocument.spreadsheet-template", - ".ott" => "application/vnd.oasis.opendocument.text-template", - ".oxt" => "application/vnd.openofficeorg.extension", - ".p" => "text/x-pascal", - ".p10" => "application/pkcs10", - ".p12" => "application/x-pkcs12", - ".p7b" => "application/x-pkcs7-certificates", - ".p7m" => "application/pkcs7-mime", - ".p7r" => "application/x-pkcs7-certreqresp", - ".p7s" => "application/pkcs7-signature", - ".pas" => "text/x-pascal", - ".pbd" => "application/vnd.powerbuilder6", - ".pbm" => "image/x-portable-bitmap", - ".pcl" => "application/vnd.hp-pcl", - ".pclxl" => "application/vnd.hp-pclxl", - ".pcx" => "image/x-pcx", - ".pdb" => "chemical/x-pdb", - ".pdf" => "application/pdf", - ".pem" => "application/x-x509-ca-cert", - ".pfr" => "application/font-tdpfr", - ".pgm" => "image/x-portable-graymap", - ".pgn" => "application/x-chess-pgn", - ".pgp" => "application/pgp-encrypted", - ".pic" => "image/x-pict", - ".pict" => "image/pict", - ".pkg" => "application/octet-stream", - ".pki" => "application/pkixcmp", - ".pkipath" => "application/pkix-pkipath", - ".pl" => "text/x-script.perl", - ".plb" => "application/vnd.3gpp.pic-bw-large", - ".plc" => "application/vnd.mobius.plc", - ".plf" => "application/vnd.pocketlearn", - ".pls" => "application/pls+xml", - ".pm" => "text/x-script.perl-module", - ".pml" => "application/vnd.ctc-posml", - ".png" => "image/png", - ".pnm" => "image/x-portable-anymap", - ".pntg" => "image/x-macpaint", - ".portpkg" => "application/vnd.macports.portpkg", - ".pot" => "application/vnd.ms-powerpoint", - ".potm" => "application/vnd.ms-powerpoint.template.macroEnabled.12", - ".potx" => "application/vnd.openxmlformats-officedocument.presentationml.template", - ".ppa" => "application/vnd.ms-powerpoint", - ".ppam" => "application/vnd.ms-powerpoint.addin.macroEnabled.12", - ".ppd" => "application/vnd.cups-ppd", - ".ppm" => "image/x-portable-pixmap", - ".pps" => "application/vnd.ms-powerpoint", - ".ppsm" => "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", - ".ppsx" => "application/vnd.openxmlformats-officedocument.presentationml.slideshow", - ".ppt" => "application/vnd.ms-powerpoint", - ".pptm" => "application/vnd.ms-powerpoint.presentation.macroEnabled.12", - ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ".prc" => "application/vnd.palm", - ".pre" => "application/vnd.lotus-freelance", - ".prf" => "application/pics-rules", - ".ps" => "application/postscript", - ".psb" => "application/vnd.3gpp.pic-bw-small", - ".psd" => "image/vnd.adobe.photoshop", - ".ptid" => "application/vnd.pvi.ptid1", - ".pub" => "application/x-mspublisher", - ".pvb" => "application/vnd.3gpp.pic-bw-var", - ".pwn" => "application/vnd.3m.post-it-notes", - ".py" => "text/x-script.python", - ".pya" => "audio/vnd.ms-playready.media.pya", - ".pyv" => "video/vnd.ms-playready.media.pyv", - ".qam" => "application/vnd.epson.quickanime", - ".qbo" => "application/vnd.intu.qbo", - ".qfx" => "application/vnd.intu.qfx", - ".qps" => "application/vnd.publishare-delta-tree", - ".qt" => "video/quicktime", - ".qtif" => "image/x-quicktime", - ".qxd" => "application/vnd.quark.quarkxpress", - ".ra" => "audio/x-pn-realaudio", - ".rake" => "text/x-script.ruby", - ".ram" => "audio/x-pn-realaudio", - ".rar" => "application/x-rar-compressed", - ".ras" => "image/x-cmu-raster", - ".rb" => "text/x-script.ruby", - ".rcprofile" => "application/vnd.ipunplugged.rcprofile", - ".rdf" => "application/rdf+xml", - ".rdz" => "application/vnd.data-vision.rdz", - ".rep" => "application/vnd.businessobjects", - ".rgb" => "image/x-rgb", - ".rif" => "application/reginfo+xml", - ".rl" => "application/resource-lists+xml", - ".rlc" => "image/vnd.fujixerox.edmics-rlc", - ".rld" => "application/resource-lists-diff+xml", - ".rm" => "application/vnd.rn-realmedia", - ".rmp" => "audio/x-pn-realaudio-plugin", - ".rms" => "application/vnd.jcp.javame.midlet-rms", - ".rnc" => "application/relax-ng-compact-syntax", - ".roff" => "text/troff", - ".rpm" => "application/x-redhat-package-manager", - ".rpss" => "application/vnd.nokia.radio-presets", - ".rpst" => "application/vnd.nokia.radio-preset", - ".rq" => "application/sparql-query", - ".rs" => "application/rls-services+xml", - ".rsd" => "application/rsd+xml", - ".rss" => "application/rss+xml", - ".rtf" => "application/rtf", - ".rtx" => "text/richtext", - ".ru" => "text/x-script.ruby", - ".s" => "text/x-asm", - ".saf" => "application/vnd.yamaha.smaf-audio", - ".sbml" => "application/sbml+xml", - ".sc" => "application/vnd.ibm.secure-container", - ".scd" => "application/x-msschedule", - ".scm" => "application/vnd.lotus-screencam", - ".scq" => "application/scvp-cv-request", - ".scs" => "application/scvp-cv-response", - ".sdkm" => "application/vnd.solent.sdkm+xml", - ".sdp" => "application/sdp", - ".see" => "application/vnd.seemail", - ".sema" => "application/vnd.sema", - ".semd" => "application/vnd.semd", - ".semf" => "application/vnd.semf", - ".setpay" => "application/set-payment-initiation", - ".setreg" => "application/set-registration-initiation", - ".sfd" => "application/vnd.hydrostatix.sof-data", - ".sfs" => "application/vnd.spotfire.sfs", - ".sgm" => "text/sgml", - ".sgml" => "text/sgml", - ".sh" => "application/x-sh", - ".shar" => "application/x-shar", - ".shf" => "application/shf+xml", - ".sig" => "application/pgp-signature", - ".sit" => "application/x-stuffit", - ".sitx" => "application/x-stuffitx", - ".skp" => "application/vnd.koan", - ".slt" => "application/vnd.epson.salt", - ".smi" => "application/smil+xml", - ".snd" => "audio/basic", - ".so" => "application/octet-stream", - ".spf" => "application/vnd.yamaha.smaf-phrase", - ".spl" => "application/x-futuresplash", - ".spot" => "text/vnd.in3d.spot", - ".spp" => "application/scvp-vp-response", - ".spq" => "application/scvp-vp-request", - ".src" => "application/x-wais-source", - ".srt" => "text/srt", - ".srx" => "application/sparql-results+xml", - ".sse" => "application/vnd.kodak-descriptor", - ".ssf" => "application/vnd.epson.ssf", - ".ssml" => "application/ssml+xml", - ".stf" => "application/vnd.wt.stf", - ".stk" => "application/hyperstudio", - ".str" => "application/vnd.pg.format", - ".sus" => "application/vnd.sus-calendar", - ".sv4cpio" => "application/x-sv4cpio", - ".sv4crc" => "application/x-sv4crc", - ".svd" => "application/vnd.svd", - ".svg" => "image/svg+xml", - ".svgz" => "image/svg+xml", - ".swf" => "application/x-shockwave-flash", - ".swi" => "application/vnd.arastra.swi", - ".t" => "text/troff", - ".tao" => "application/vnd.tao.intent-module-archive", - ".tar" => "application/x-tar", - ".tbz" => "application/x-bzip-compressed-tar", - ".tcap" => "application/vnd.3gpp2.tcap", - ".tcl" => "application/x-tcl", - ".tex" => "application/x-tex", - ".texi" => "application/x-texinfo", - ".texinfo" => "application/x-texinfo", - ".text" => "text/plain", - ".tif" => "image/tiff", - ".tiff" => "image/tiff", - ".tmo" => "application/vnd.tmobile-livetv", - ".torrent" => "application/x-bittorrent", - ".tpl" => "application/vnd.groove-tool-template", - ".tpt" => "application/vnd.trid.tpt", - ".tr" => "text/troff", - ".tra" => "application/vnd.trueapp", - ".trm" => "application/x-msterminal", - ".ts" => "video/mp2t", - ".tsv" => "text/tab-separated-values", - ".ttf" => "font/ttf", - ".twd" => "application/vnd.simtech-mindmapper", - ".txd" => "application/vnd.genomatix.tuxedo", - ".txf" => "application/vnd.mobius.txf", - ".txt" => "text/plain", - ".ufd" => "application/vnd.ufdl", - ".umj" => "application/vnd.umajin", - ".unityweb" => "application/vnd.unity", - ".uoml" => "application/vnd.uoml+xml", - ".uri" => "text/uri-list", - ".ustar" => "application/x-ustar", - ".utz" => "application/vnd.uiq.theme", - ".uu" => "text/x-uuencode", - ".vcd" => "application/x-cdlink", - ".vcf" => "text/x-vcard", - ".vcg" => "application/vnd.groove-vcard", - ".vcs" => "text/x-vcalendar", - ".vcx" => "application/vnd.vcx", - ".vis" => "application/vnd.visionary", - ".viv" => "video/vnd.vivo", - ".vrml" => "model/vrml", - ".vsd" => "application/vnd.visio", - ".vsf" => "application/vnd.vsf", - ".vtt" => "text/vtt", - ".vtu" => "model/vnd.vtu", - ".vxml" => "application/voicexml+xml", - ".war" => "application/java-archive", - ".wasm" => "application/wasm", - ".wav" => "audio/x-wav", - ".wax" => "audio/x-ms-wax", - ".wbmp" => "image/vnd.wap.wbmp", - ".wbs" => "application/vnd.criticaltools.wbs+xml", - ".wbxml" => "application/vnd.wap.wbxml", - ".webm" => "video/webm", - ".webp" => "image/webp", - ".wm" => "video/x-ms-wm", - ".wma" => "audio/x-ms-wma", - ".wmd" => "application/x-ms-wmd", - ".wmf" => "application/x-msmetafile", - ".wml" => "text/vnd.wap.wml", - ".wmlc" => "application/vnd.wap.wmlc", - ".wmls" => "text/vnd.wap.wmlscript", - ".wmlsc" => "application/vnd.wap.wmlscriptc", - ".wmv" => "video/x-ms-wmv", - ".wmx" => "video/x-ms-wmx", - ".wmz" => "application/x-ms-wmz", - ".woff" => "font/woff", - ".woff2" => "font/woff2", - ".wpd" => "application/vnd.wordperfect", - ".wpl" => "application/vnd.ms-wpl", - ".wps" => "application/vnd.ms-works", - ".wqd" => "application/vnd.wqd", - ".wri" => "application/x-mswrite", - ".wrl" => "model/vrml", - ".wsdl" => "application/wsdl+xml", - ".wspolicy" => "application/wspolicy+xml", - ".wtb" => "application/vnd.webturbo", - ".wvx" => "video/x-ms-wvx", - ".x3d" => "application/vnd.hzn-3d-crossword", - ".xar" => "application/vnd.xara", - ".xbd" => "application/vnd.fujixerox.docuworks.binder", - ".xbm" => "image/x-xbitmap", - ".xdm" => "application/vnd.syncml.dm+xml", - ".xdp" => "application/vnd.adobe.xdp+xml", - ".xdw" => "application/vnd.fujixerox.docuworks", - ".xenc" => "application/xenc+xml", - ".xer" => "application/patch-ops-error+xml", - ".xfdf" => "application/vnd.adobe.xfdf", - ".xfdl" => "application/vnd.xfdl", - ".xhtml" => "application/xhtml+xml", - ".xif" => "image/vnd.xiff", - ".xla" => "application/vnd.ms-excel", - ".xlam" => "application/vnd.ms-excel.addin.macroEnabled.12", - ".xls" => "application/vnd.ms-excel", - ".xlsb" => "application/vnd.ms-excel.sheet.binary.macroEnabled.12", - ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".xlsm" => "application/vnd.ms-excel.sheet.macroEnabled.12", - ".xlt" => "application/vnd.ms-excel", - ".xltx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.template", - ".xml" => "application/xml", - ".xo" => "application/vnd.olpc-sugar", - ".xop" => "application/xop+xml", - ".xpm" => "image/x-xpixmap", - ".xpr" => "application/vnd.is-xpr", - ".xps" => "application/vnd.ms-xpsdocument", - ".xpw" => "application/vnd.intercon.formnet", - ".xsl" => "application/xml", - ".xslt" => "application/xslt+xml", - ".xsm" => "application/vnd.syncml+xml", - ".xspf" => "application/xspf+xml", - ".xul" => "application/vnd.mozilla.xul+xml", - ".xwd" => "image/x-xwindowdump", - ".xyz" => "chemical/x-xyz", - ".yaml" => "text/yaml", - ".yml" => "text/yaml", - ".zaz" => "application/vnd.zzazz.deck+xml", - ".zip" => "application/zip", - ".zmm" => "application/vnd.handheld-entertainment+xml", - } - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock.rb deleted file mode 100644 index 5e5c457..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock.rb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -require_relative 'mock_request' diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_request.rb deleted file mode 100644 index 7c87bea..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_request.rb +++ /dev/null @@ -1,161 +0,0 @@ -# frozen_string_literal: true - -require 'uri' -require 'stringio' - -require_relative 'constants' -require_relative 'mock_response' - -module Rack - # Rack::MockRequest helps testing your Rack application without - # actually using HTTP. - # - # After performing a request on a URL with get/post/put/patch/delete, it - # returns a MockResponse with useful helper methods for effective - # testing. - # - # You can pass a hash with additional configuration to the - # get/post/put/patch/delete. - # :input:: A String or IO-like to be used as rack.input. - # :fatal:: Raise a FatalWarning if the app writes to rack.errors. - # :lint:: If true, wrap the application in a Rack::Lint. - - class MockRequest - class FatalWarning < RuntimeError - end - - class FatalWarner - def puts(warning) - raise FatalWarning, warning - end - - def write(warning) - raise FatalWarning, warning - end - - def flush - end - - def string - "" - end - end - - def initialize(app) - @app = app - end - - # Make a GET request and return a MockResponse. See #request. - def get(uri, opts = {}) request(GET, uri, opts) end - # Make a POST request and return a MockResponse. See #request. - def post(uri, opts = {}) request(POST, uri, opts) end - # Make a PUT request and return a MockResponse. See #request. - def put(uri, opts = {}) request(PUT, uri, opts) end - # Make a PATCH request and return a MockResponse. See #request. - def patch(uri, opts = {}) request(PATCH, uri, opts) end - # Make a DELETE request and return a MockResponse. See #request. - def delete(uri, opts = {}) request(DELETE, uri, opts) end - # Make a HEAD request and return a MockResponse. See #request. - def head(uri, opts = {}) request(HEAD, uri, opts) end - # Make an OPTIONS request and return a MockResponse. See #request. - def options(uri, opts = {}) request(OPTIONS, uri, opts) end - - # Make a request using the given request method for the given - # uri to the rack application and return a MockResponse. - # Options given are passed to MockRequest.env_for. - def request(method = GET, uri = "", opts = {}) - env = self.class.env_for(uri, opts.merge(method: method)) - - if opts[:lint] - app = Rack::Lint.new(@app) - else - app = @app - end - - errors = env[RACK_ERRORS] - status, headers, body = app.call(env) - MockResponse.new(status, headers, body, errors) - ensure - body.close if body.respond_to?(:close) - end - - # For historical reasons, we're pinning to RFC 2396. - # URI::Parser = URI::RFC2396_Parser - def self.parse_uri_rfc2396(uri) - @parser ||= URI::Parser.new - @parser.parse(uri) - end - - # Return the Rack environment used for a request to +uri+. - # All options that are strings are added to the returned environment. - # Options: - # :fatal :: Whether to raise an exception if request outputs to rack.errors - # :input :: The rack.input to set - # :http_version :: The SERVER_PROTOCOL to set - # :method :: The HTTP request method to use - # :params :: The params to use - # :script_name :: The SCRIPT_NAME to set - def self.env_for(uri = "", opts = {}) - uri = parse_uri_rfc2396(uri) - uri.path = "/#{uri.path}" unless uri.path[0] == ?/ - - env = {} - - env[REQUEST_METHOD] = (opts[:method] ? opts[:method].to_s.upcase : GET).b - env[SERVER_NAME] = (uri.host || "example.org").b - env[SERVER_PORT] = (uri.port ? uri.port.to_s : "80").b - env[SERVER_PROTOCOL] = opts[:http_version] || 'HTTP/1.1' - env[QUERY_STRING] = (uri.query.to_s).b - env[PATH_INFO] = (uri.path).b - env[RACK_URL_SCHEME] = (uri.scheme || "http").b - env[HTTPS] = (env[RACK_URL_SCHEME] == "https" ? "on" : "off").b - - env[SCRIPT_NAME] = opts[:script_name] || "" - - if opts[:fatal] - env[RACK_ERRORS] = FatalWarner.new - else - env[RACK_ERRORS] = StringIO.new - end - - if params = opts[:params] - if env[REQUEST_METHOD] == GET - params = Utils.parse_nested_query(params) if params.is_a?(String) - params.update(Utils.parse_nested_query(env[QUERY_STRING])) - env[QUERY_STRING] = Utils.build_nested_query(params) - elsif !opts.has_key?(:input) - opts["CONTENT_TYPE"] = "application/x-www-form-urlencoded" - if params.is_a?(Hash) - if data = Rack::Multipart.build_multipart(params) - opts[:input] = data - opts["CONTENT_LENGTH"] ||= data.length.to_s - opts["CONTENT_TYPE"] = "multipart/form-data; boundary=#{Rack::Multipart::MULTIPART_BOUNDARY}" - else - opts[:input] = Utils.build_nested_query(params) - end - else - opts[:input] = params - end - end - end - - rack_input = opts[:input] - if String === rack_input - rack_input = StringIO.new(rack_input) - end - - if rack_input - rack_input.set_encoding(Encoding::BINARY) if rack_input.respond_to?(:set_encoding) - env[RACK_INPUT] = rack_input - - env["CONTENT_LENGTH"] ||= env[RACK_INPUT].size.to_s if env[RACK_INPUT].respond_to?(:size) - end - - opts.each { |field, value| - env[field] = value if String === field - } - - env - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_response.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_response.rb deleted file mode 100644 index 9af8079..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/mock_response.rb +++ /dev/null @@ -1,124 +0,0 @@ -# frozen_string_literal: true - -require 'cgi/cookie' -require 'time' - -require_relative 'response' - -module Rack - # Rack::MockResponse provides useful helpers for testing your apps. - # Usually, you don't create the MockResponse on your own, but use - # MockRequest. - - class MockResponse < Rack::Response - class << self - alias [] new - end - - # Headers - attr_reader :original_headers, :cookies - - # Errors - attr_accessor :errors - - def initialize(status, headers, body, errors = nil) - @original_headers = headers - - if errors - @errors = errors.string if errors.respond_to?(:string) - else - @errors = "" - end - - super(body, status, headers) - - @cookies = parse_cookies_from_header - buffered_body! - end - - def =~(other) - body =~ other - end - - def match(other) - body.match other - end - - def body - return @buffered_body if defined?(@buffered_body) - - # FIXME: apparently users of MockResponse expect the return value of - # MockResponse#body to be a string. However, the real response object - # returns the body as a list. - # - # See spec_showstatus.rb: - # - # should "not replace existing messages" do - # ... - # res.body.should == "foo!" - # end - buffer = @buffered_body = String.new - - @body.each do |chunk| - buffer << chunk - end - - return buffer - end - - def empty? - [201, 204, 304].include? status - end - - def cookie(name) - cookies.fetch(name, nil) - end - - private - - def parse_cookies_from_header - cookies = Hash.new - set_cookie_header = headers['set-cookie'] - if set_cookie_header && !set_cookie_header.empty? - Array(set_cookie_header).each do |cookie| - cookie_name, cookie_filling = cookie.split('=', 2) - cookie_attributes = identify_cookie_attributes cookie_filling - parsed_cookie = CGI::Cookie.new( - 'name' => cookie_name.strip, - 'value' => cookie_attributes.fetch('value'), - 'path' => cookie_attributes.fetch('path', nil), - 'domain' => cookie_attributes.fetch('domain', nil), - 'expires' => cookie_attributes.fetch('expires', nil), - 'secure' => cookie_attributes.fetch('secure', false) - ) - cookies.store(cookie_name, parsed_cookie) - end - end - cookies - end - - def identify_cookie_attributes(cookie_filling) - cookie_bits = cookie_filling.split(';') - cookie_attributes = Hash.new - cookie_attributes.store('value', cookie_bits[0].strip) - cookie_bits.drop(1).each do |bit| - if bit.include? '=' - cookie_attribute, attribute_value = bit.split('=', 2) - cookie_attributes.store(cookie_attribute.strip.downcase, attribute_value.strip) - end - if bit.include? 'secure' - cookie_attributes.store('secure', true) - end - end - - if cookie_attributes.key? 'max-age' - cookie_attributes.store('expires', Time.now + cookie_attributes['max-age'].to_i) - elsif cookie_attributes.key? 'expires' - cookie_attributes.store('expires', Time.httpdate(cookie_attributes['expires'])) - end - - cookie_attributes - end - - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart.rb deleted file mode 100644 index 4b02fb3..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' - -require_relative 'multipart/parser' -require_relative 'multipart/generator' - -require_relative 'bad_request' - -module Rack - # A multipart form data parser, adapted from IOWA. - # - # Usually, Rack::Request#POST takes care of calling this. - module Multipart - MULTIPART_BOUNDARY = "AaB03x" - - class MissingInputError < StandardError - include BadRequest - end - - # Accumulator for multipart form data, conforming to the QueryParser API. - # In future, the Parser could return the pair list directly, but that would - # change its API. - class ParamList # :nodoc: - def self.make_params - new - end - - def self.normalize_params(params, key, value) - params << [key, value] - end - - def initialize - @pairs = [] - end - - def <<(pair) - @pairs << pair - end - - def to_params_hash - @pairs - end - end - - class << self - def parse_multipart(env, params = Rack::Utils.default_query_parser) - unless io = env[RACK_INPUT] - raise MissingInputError, "Missing input stream!" - end - - if content_length = env['CONTENT_LENGTH'] - content_length = content_length.to_i - end - - content_type = env['CONTENT_TYPE'] - - tempfile = env[RACK_MULTIPART_TEMPFILE_FACTORY] || Parser::TEMPFILE_FACTORY - bufsize = env[RACK_MULTIPART_BUFFER_SIZE] || Parser::BUFSIZE - - info = Parser.parse(io, content_length, content_type, tempfile, bufsize, params) - env[RACK_TEMPFILES] = info.tmp_files - - return info.params - end - - def extract_multipart(request, params = Rack::Utils.default_query_parser) - parse_multipart(request.env) - end - - def build_multipart(params, first = true) - Generator.new(params, first).dump - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/generator.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/generator.rb deleted file mode 100644 index 30d7f51..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/generator.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -require_relative 'uploaded_file' - -module Rack - module Multipart - class Generator - def initialize(params, first = true) - @params, @first = params, first - - if @first && !@params.is_a?(Hash) - raise ArgumentError, "value must be a Hash" - end - end - - def dump - return nil if @first && !multipart? - return flattened_params unless @first - - flattened_params.map do |name, file| - if file.respond_to?(:original_filename) - if file.path - ::File.open(file.path, 'rb') do |f| - f.set_encoding(Encoding::BINARY) - content_for_tempfile(f, file, name) - end - else - content_for_tempfile(file, file, name) - end - else - content_for_other(file, name) - end - end.join << "--#{MULTIPART_BOUNDARY}--\r" - end - - private - def multipart? - query = lambda { |value| - case value - when Array - value.any?(&query) - when Hash - value.values.any?(&query) - when Rack::Multipart::UploadedFile - true - end - } - - @params.values.any?(&query) - end - - def flattened_params - @flattened_params ||= begin - h = Hash.new - @params.each do |key, value| - k = @first ? key.to_s : "[#{key}]" - - case value - when Array - value.map { |v| - Multipart.build_multipart(v, false).each { |subkey, subvalue| - h["#{k}[]#{subkey}"] = subvalue - } - } - when Hash - Multipart.build_multipart(value, false).each { |subkey, subvalue| - h[k + subkey] = subvalue - } - else - h[k] = value - end - end - h - end - end - - def content_for_tempfile(io, file, name) - length = ::File.stat(file.path).size if file.path - filename = "; filename=\"#{Utils.escape_path(file.original_filename)}\"" -<<-EOF ---#{MULTIPART_BOUNDARY}\r -content-disposition: form-data; name="#{name}"#{filename}\r -content-type: #{file.content_type}\r -#{"content-length: #{length}\r\n" if length}\r -#{io.read}\r -EOF - end - - def content_for_other(file, name) -<<-EOF ---#{MULTIPART_BOUNDARY}\r -content-disposition: form-data; name="#{name}"\r -\r -#{file}\r -EOF - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/parser.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/parser.rb deleted file mode 100644 index 3960b37..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/parser.rb +++ /dev/null @@ -1,502 +0,0 @@ -# frozen_string_literal: true - -require 'strscan' - -require_relative '../utils' -require_relative '../bad_request' - -module Rack - module Multipart - class MultipartPartLimitError < Errno::EMFILE - include BadRequest - end - - class MultipartTotalPartLimitError < StandardError - include BadRequest - end - - # Use specific error class when parsing multipart request - # that ends early. - class EmptyContentError < ::EOFError - include BadRequest - end - - # Base class for multipart exceptions that do not subclass from - # other exception classes for backwards compatibility. - class BoundaryTooLongError < StandardError - include BadRequest - end - - # Prefer to use the BoundaryTooLongError class or Rack::BadRequest. - Error = BoundaryTooLongError - - EOL = "\r\n" - MULTIPART = %r|\Amultipart/.*boundary=\"?([^\";,]+)\"?|ni - MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{EOL}/ni - MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:(.*)(?=#{EOL}(\S|\z))/ni - MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{EOL}]*)/ni - - class Parser - BUFSIZE = 1_048_576 - TEXT_PLAIN = "text/plain" - TEMPFILE_FACTORY = lambda { |filename, content_type| - extension = ::File.extname(filename.gsub("\0", '%00'))[0, 129] - - Tempfile.new(["RackMultipart", extension]) - } - - class BoundedIO # :nodoc: - def initialize(io, content_length) - @io = io - @content_length = content_length - @cursor = 0 - end - - def read(size, outbuf = nil) - return if @cursor >= @content_length - - left = @content_length - @cursor - - str = if left < size - @io.read left, outbuf - else - @io.read size, outbuf - end - - if str - @cursor += str.bytesize - else - # Raise an error for mismatching content-length and actual contents - raise EOFError, "bad content body" - end - - str - end - end - - MultipartInfo = Struct.new :params, :tmp_files - EMPTY = MultipartInfo.new(nil, []) - - def self.parse_boundary(content_type) - return unless content_type - data = content_type.match(MULTIPART) - return unless data - data[1] - end - - def self.parse(io, content_length, content_type, tmpfile, bufsize, qp) - return EMPTY if 0 == content_length - - boundary = parse_boundary content_type - return EMPTY unless boundary - - if boundary.length > 70 - # RFC 1521 Section 7.2.1 imposes a 70 character maximum for the boundary. - # Most clients use no more than 55 characters. - raise BoundaryTooLongError, "multipart boundary size too large (#{boundary.length} characters)" - end - - io = BoundedIO.new(io, content_length) if content_length - - parser = new(boundary, tmpfile, bufsize, qp) - parser.parse(io) - - parser.result - end - - class Collector - class MimePart < Struct.new(:body, :head, :filename, :content_type, :name) - def get_data - data = body - if filename == "" - # filename is blank which means no file has been selected - return - elsif filename - body.rewind if body.respond_to?(:rewind) - - # Take the basename of the upload's original filename. - # This handles the full Windows paths given by Internet Explorer - # (and perhaps other broken user agents) without affecting - # those which give the lone filename. - fn = filename.split(/[\/\\]/).last - - data = { filename: fn, type: content_type, - name: name, tempfile: body, head: head } - end - - yield data - end - end - - class BufferPart < MimePart - def file?; false; end - def close; end - end - - class TempfilePart < MimePart - def file?; true; end - def close; body.close; end - end - - include Enumerable - - def initialize(tempfile) - @tempfile = tempfile - @mime_parts = [] - @open_files = 0 - end - - def each - @mime_parts.each { |part| yield part } - end - - def on_mime_head(mime_index, head, filename, content_type, name) - if filename - body = @tempfile.call(filename, content_type) - body.binmode if body.respond_to?(:binmode) - klass = TempfilePart - @open_files += 1 - else - body = String.new - klass = BufferPart - end - - @mime_parts[mime_index] = klass.new(body, head, filename, content_type, name) - - check_part_limits - end - - def on_mime_body(mime_index, content) - @mime_parts[mime_index].body << content - end - - def on_mime_finish(mime_index) - end - - private - - def check_part_limits - file_limit = Utils.multipart_file_limit - part_limit = Utils.multipart_total_part_limit - - if file_limit && file_limit > 0 - if @open_files >= file_limit - @mime_parts.each(&:close) - raise MultipartPartLimitError, 'Maximum file multiparts in content reached' - end - end - - if part_limit && part_limit > 0 - if @mime_parts.size >= part_limit - @mime_parts.each(&:close) - raise MultipartTotalPartLimitError, 'Maximum total multiparts in content reached' - end - end - end - end - - attr_reader :state - - def initialize(boundary, tempfile, bufsize, query_parser) - @query_parser = query_parser - @params = query_parser.make_params - @bufsize = bufsize - - @state = :FAST_FORWARD - @mime_index = 0 - @collector = Collector.new tempfile - - @sbuf = StringScanner.new("".dup) - @body_regex = /(?:#{EOL}|\A)--#{Regexp.quote(boundary)}(?:#{EOL}|--)/m - @body_regex_at_end = /#{@body_regex}\z/m - @end_boundary_size = boundary.bytesize + 4 # (-- at start, -- at finish) - @rx_max_size = boundary.bytesize + 6 # (\r\n-- at start, either \r\n or -- at finish) - @head_regex = /(.*?#{EOL})#{EOL}/m - end - - def parse(io) - outbuf = String.new - read_data(io, outbuf) - - loop do - status = - case @state - when :FAST_FORWARD - handle_fast_forward - when :CONSUME_TOKEN - handle_consume_token - when :MIME_HEAD - handle_mime_head - when :MIME_BODY - handle_mime_body - else # when :DONE - return - end - - read_data(io, outbuf) if status == :want_read - end - end - - def result - @collector.each do |part| - part.get_data do |data| - tag_multipart_encoding(part.filename, part.content_type, part.name, data) - @query_parser.normalize_params(@params, part.name, data) - end - end - MultipartInfo.new @params.to_params_hash, @collector.find_all(&:file?).map(&:body) - end - - private - - def dequote(str) # From WEBrick::HTTPUtils - ret = (/\A"(.*)"\Z/ =~ str) ? $1 : str.dup - ret.gsub!(/\\(.)/, "\\1") - ret - end - - def read_data(io, outbuf) - content = io.read(@bufsize, outbuf) - handle_empty_content!(content) - @sbuf.concat(content) - end - - # This handles the initial parser state. We read until we find the starting - # boundary, then we can transition to the next state. If we find the ending - # boundary, this is an invalid multipart upload, but keep scanning for opening - # boundary in that case. If no boundary found, we need to keep reading data - # and retry. It's highly unlikely the initial read will not consume the - # boundary. The client would have to deliberately craft a response - # with the opening boundary beyond the buffer size for that to happen. - def handle_fast_forward - while true - case consume_boundary - when :BOUNDARY - # found opening boundary, transition to next state - @state = :MIME_HEAD - return - when :END_BOUNDARY - # invalid multipart upload - if @sbuf.pos == @end_boundary_size && @sbuf.rest == EOL - # stop parsing a buffer if a buffer is only an end boundary. - @state = :DONE - return - end - - # retry for opening boundary - else - # no boundary found, keep reading data - return :want_read - end - end - end - - def handle_consume_token - tok = consume_boundary - # break if we're at the end of a buffer, but not if it is the end of a field - @state = if tok == :END_BOUNDARY || (@sbuf.eos? && tok != :BOUNDARY) - :DONE - else - :MIME_HEAD - end - end - - CONTENT_DISPOSITION_MAX_PARAMS = 16 - CONTENT_DISPOSITION_MAX_BYTES = 1536 - def handle_mime_head - if @sbuf.scan_until(@head_regex) - head = @sbuf[1] - content_type = head[MULTIPART_CONTENT_TYPE, 1] - if (disposition = head[MULTIPART_CONTENT_DISPOSITION, 1]) && - disposition.bytesize <= CONTENT_DISPOSITION_MAX_BYTES - - # ignore actual content-disposition value (should always be form-data) - i = disposition.index(';') - disposition.slice!(0, i+1) - param = nil - num_params = 0 - - # Parse parameter list - while i = disposition.index('=') - # Only parse up to max parameters, to avoid potential denial of service - num_params += 1 - break if num_params > CONTENT_DISPOSITION_MAX_PARAMS - - # Found end of parameter name, ensure forward progress in loop - param = disposition.slice!(0, i+1) - - # Remove ending equals and preceding whitespace from parameter name - param.chomp!('=') - param.lstrip! - - if disposition[0] == '"' - # Parameter value is quoted, parse it, handling backslash escapes - disposition.slice!(0, 1) - value = String.new - - while i = disposition.index(/(["\\])/) - c = $1 - - # Append all content until ending quote or escape - value << disposition.slice!(0, i) - - # Remove either backslash or ending quote, - # ensures forward progress in loop - disposition.slice!(0, 1) - - # stop parsing parameter value if found ending quote - break if c == '"' - - escaped_char = disposition.slice!(0, 1) - if param == 'filename' && escaped_char != '"' - # Possible IE uploaded filename, append both escape backslash and value - value << c << escaped_char - else - # Other only append escaped value - value << escaped_char - end - end - else - if i = disposition.index(';') - # Parameter value unquoted (which may be invalid), value ends at semicolon - value = disposition.slice!(0, i) - else - # If no ending semicolon, assume remainder of line is value and stop - # parsing - disposition.strip! - value = disposition - disposition = '' - end - end - - case param - when 'name' - name = value - when 'filename' - filename = value - when 'filename*' - filename_star = value - # else - # ignore other parameters - end - - # skip trailing semicolon, to proceed to next parameter - if i = disposition.index(';') - disposition.slice!(0, i+1) - end - end - else - name = head[MULTIPART_CONTENT_ID, 1] - end - - if filename_star - encoding, _, filename = filename_star.split("'", 3) - filename = normalize_filename(filename || '') - filename.force_encoding(find_encoding(encoding)) - elsif filename - filename = normalize_filename(filename) - end - - if name.nil? || name.empty? - name = filename || "#{content_type || TEXT_PLAIN}[]".dup - end - - @collector.on_mime_head @mime_index, head, filename, content_type, name - @state = :MIME_BODY - else - :want_read - end - end - - def handle_mime_body - if (body_with_boundary = @sbuf.check_until(@body_regex)) # check but do not advance the pointer yet - body = body_with_boundary.sub(@body_regex_at_end, '') # remove the boundary from the string - @collector.on_mime_body @mime_index, body - @sbuf.pos += body.length + 2 # skip \r\n after the content - @state = :CONSUME_TOKEN - @mime_index += 1 - else - # Save what we have so far - if @rx_max_size < @sbuf.rest_size - delta = @sbuf.rest_size - @rx_max_size - @collector.on_mime_body @mime_index, @sbuf.peek(delta) - @sbuf.pos += delta - @sbuf.string = @sbuf.rest - end - :want_read - end - end - - # Scan until the we find the start or end of the boundary. - # If we find it, return the appropriate symbol for the start or - # end of the boundary. If we don't find the start or end of the - # boundary, clear the buffer and return nil. - def consume_boundary - if read_buffer = @sbuf.scan_until(@body_regex) - read_buffer.end_with?(EOL) ? :BOUNDARY : :END_BOUNDARY - else - @sbuf.terminate - nil - end - end - - def normalize_filename(filename) - if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) } - filename = Utils.unescape_path(filename) - end - - filename.scrub! - - filename.split(/[\/\\]/).last || String.new - end - - CHARSET = "charset" - deprecate_constant :CHARSET - - def tag_multipart_encoding(filename, content_type, name, body) - name = name.to_s - encoding = Encoding::UTF_8 - - name.force_encoding(encoding) - - return if filename - - if content_type - list = content_type.split(';') - type_subtype = list.first - type_subtype.strip! - if TEXT_PLAIN == type_subtype - rest = list.drop 1 - rest.each do |param| - k, v = param.split('=', 2) - k.strip! - v.strip! - v = v[1..-2] if v.start_with?('"') && v.end_with?('"') - if k == "charset" - encoding = find_encoding(v) - end - end - end - end - - name.force_encoding(encoding) - body.force_encoding(encoding) - end - - # Return the related Encoding object. However, because - # enc is submitted by the user, it may be invalid, so - # use a binary encoding in that case. - def find_encoding(enc) - Encoding.find enc - rescue ArgumentError - Encoding::BINARY - end - - def handle_empty_content!(content) - if content.nil? || content.empty? - raise EmptyContentError - end - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb deleted file mode 100644 index 2782e44..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/multipart/uploaded_file.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -require 'tempfile' -require 'fileutils' - -module Rack - module Multipart - class UploadedFile - - # The filename, *not* including the path, of the "uploaded" file - attr_reader :original_filename - - # The content type of the "uploaded" file - attr_accessor :content_type - - def initialize(filepath = nil, ct = "text/plain", bin = false, - path: filepath, content_type: ct, binary: bin, filename: nil, io: nil) - if io - @tempfile = io - @original_filename = filename - else - raise "#{path} file does not exist" unless ::File.exist?(path) - @original_filename = filename || ::File.basename(path) - @tempfile = Tempfile.new([@original_filename, ::File.extname(path)], encoding: Encoding::BINARY) - @tempfile.binmode if binary - FileUtils.copy_file(path, @tempfile.path) - end - @content_type = content_type - end - - def path - @tempfile.path if @tempfile.respond_to?(:path) - end - alias_method :local_path, :path - - def respond_to?(*args) - super or @tempfile.respond_to?(*args) - end - - def method_missing(method_name, *args, &block) #:nodoc: - @tempfile.__send__(method_name, *args, &block) - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/null_logger.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/null_logger.rb deleted file mode 100644 index 52fc125..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/null_logger.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' - -module Rack - class NullLogger - def initialize(app) - @app = app - end - - def call(env) - env[RACK_LOGGER] = self - @app.call(env) - end - - def info(progname = nil, &block); end - def debug(progname = nil, &block); end - def warn(progname = nil, &block); end - def error(progname = nil, &block); end - def fatal(progname = nil, &block); end - def unknown(progname = nil, &block); end - def info? ; end - def debug? ; end - def warn? ; end - def error? ; end - def fatal? ; end - def debug! ; end - def error! ; end - def fatal! ; end - def info! ; end - def warn! ; end - def level ; end - def progname ; end - def datetime_format ; end - def formatter ; end - def sev_threshold ; end - def level=(level); end - def progname=(progname); end - def datetime_format=(datetime_format); end - def formatter=(formatter); end - def sev_threshold=(sev_threshold); end - def close ; end - def add(severity, message = nil, progname = nil, &block); end - def log(severity, message = nil, progname = nil, &block); end - def <<(msg); end - def reopen(logdev = nil); end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/query_parser.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/query_parser.rb deleted file mode 100644 index 28cbce1..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/query_parser.rb +++ /dev/null @@ -1,200 +0,0 @@ -# frozen_string_literal: true - -require_relative 'bad_request' -require 'uri' - -module Rack - class QueryParser - DEFAULT_SEP = /& */n - COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n } - - # ParameterTypeError is the error that is raised when incoming structural - # parameters (parsed by parse_nested_query) contain conflicting types. - class ParameterTypeError < TypeError - include BadRequest - end - - # InvalidParameterError is the error that is raised when incoming structural - # parameters (parsed by parse_nested_query) contain invalid format or byte - # sequence. - class InvalidParameterError < ArgumentError - include BadRequest - end - - # ParamsTooDeepError is the error that is raised when params are recursively - # nested over the specified limit. - class ParamsTooDeepError < RangeError - include BadRequest - end - - def self.make_default(param_depth_limit) - new Params, param_depth_limit - end - - attr_reader :param_depth_limit - - def initialize(params_class, param_depth_limit) - @params_class = params_class - @param_depth_limit = param_depth_limit - end - - # Stolen from Mongrel, with some small modifications: - # Parses a query string by breaking it up at the '&'. You can also use this - # to parse cookies by changing the characters used in the second parameter - # (which defaults to '&'). - def parse_query(qs, separator = nil, &unescaper) - unescaper ||= method(:unescape) - - params = make_params - - (qs || '').split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p| - next if p.empty? - k, v = p.split('=', 2).map!(&unescaper) - - if cur = params[k] - if cur.class == Array - params[k] << v - else - params[k] = [cur, v] - end - else - params[k] = v - end - end - - return params.to_h - end - - # parse_nested_query expands a query string into structural types. Supported - # types are Arrays, Hashes and basic value types. It is possible to supply - # query strings with parameters of conflicting types, in this case a - # ParameterTypeError is raised. Users are encouraged to return a 400 in this - # case. - def parse_nested_query(qs, separator = nil) - params = make_params - - unless qs.nil? || qs.empty? - (qs || '').split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p| - k, v = p.split('=', 2).map! { |s| unescape(s) } - - _normalize_params(params, k, v, 0) - end - end - - return params.to_h - rescue ArgumentError => e - raise InvalidParameterError, e.message, e.backtrace - end - - # normalize_params recursively expands parameters into structural types. If - # the structural types represented by two different parameter names are in - # conflict, a ParameterTypeError is raised. The depth argument is deprecated - # and should no longer be used, it is kept for backwards compatibility with - # earlier versions of rack. - def normalize_params(params, name, v, _depth=nil) - _normalize_params(params, name, v, 0) - end - - private def _normalize_params(params, name, v, depth) - raise ParamsTooDeepError if depth >= param_depth_limit - - if !name - # nil name, treat same as empty string (required by tests) - k = after = '' - elsif depth == 0 - # Start of parsing, don't treat [] or [ at start of string specially - if start = name.index('[', 1) - # Start of parameter nesting, use part before brackets as key - k = name[0, start] - after = name[start, name.length] - else - # Plain parameter with no nesting - k = name - after = '' - end - elsif name.start_with?('[]') - # Array nesting - k = '[]' - after = name[2, name.length] - elsif name.start_with?('[') && (start = name.index(']', 1)) - # Hash nesting, use the part inside brackets as the key - k = name[1, start-1] - after = name[start+1, name.length] - else - # Probably malformed input, nested but not starting with [ - # treat full name as key for backwards compatibility. - k = name - after = '' - end - - return if k.empty? - - if after == '' - if k == '[]' && depth != 0 - return [v] - else - params[k] = v - end - elsif after == "[" - params[name] = v - elsif after == "[]" - params[k] ||= [] - raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) - params[k] << v - elsif after.start_with?('[]') - # Recognize x[][y] (hash inside array) parameters - unless after[2] == '[' && after.end_with?(']') && (child_key = after[3, after.length-4]) && !child_key.empty? && !child_key.index('[') && !child_key.index(']') - # Handle other nested array parameters - child_key = after[2, after.length] - end - params[k] ||= [] - raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) - if params_hash_type?(params[k].last) && !params_hash_has_key?(params[k].last, child_key) - _normalize_params(params[k].last, child_key, v, depth + 1) - else - params[k] << _normalize_params(make_params, child_key, v, depth + 1) - end - else - params[k] ||= make_params - raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k]) - params[k] = _normalize_params(params[k], after, v, depth + 1) - end - - params - end - - def make_params - @params_class.new - end - - def new_depth_limit(param_depth_limit) - self.class.new @params_class, param_depth_limit - end - - private - - def params_hash_type?(obj) - obj.kind_of?(@params_class) - end - - def params_hash_has_key?(hash, key) - return false if /\[\]/.match?(key) - - key.split(/[\[\]]+/).inject(hash) do |h, part| - next h if part == '' - return false unless params_hash_type?(h) && h.key?(part) - h[part] - end - - true - end - - def unescape(string, encoding = Encoding::UTF_8) - URI.decode_www_form_component(string, encoding) - end - - class Params < Hash - alias_method :to_params_hash, :to_h - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/recursive.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/recursive.rb deleted file mode 100644 index 0945d32..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/recursive.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -require 'uri' - -require_relative 'constants' - -module Rack - # Rack::ForwardRequest gets caught by Rack::Recursive and redirects - # the current request to the app at +url+. - # - # raise ForwardRequest.new("/not-found") - # - - class ForwardRequest < Exception - attr_reader :url, :env - - def initialize(url, env = {}) - @url = URI(url) - @env = env - - @env[PATH_INFO] = @url.path - @env[QUERY_STRING] = @url.query if @url.query - @env[HTTP_HOST] = @url.host if @url.host - @env[HTTP_PORT] = @url.port if @url.port - @env[RACK_URL_SCHEME] = @url.scheme if @url.scheme - - super "forwarding to #{url}" - end - end - - # Rack::Recursive allows applications called down the chain to - # include data from other applications (by using - # rack['rack.recursive.include'][...] or raise a - # ForwardRequest to redirect internally. - - class Recursive - def initialize(app) - @app = app - end - - def call(env) - dup._call(env) - end - - def _call(env) - @script_name = env[SCRIPT_NAME] - @app.call(env.merge(RACK_RECURSIVE_INCLUDE => method(:include))) - rescue ForwardRequest => req - call(env.merge(req.env)) - end - - def include(env, path) - unless path.index(@script_name) == 0 && (path[@script_name.size] == ?/ || - path[@script_name.size].nil?) - raise ArgumentError, "can only include below #{@script_name}, not #{path}" - end - - env = env.merge(PATH_INFO => path, - SCRIPT_NAME => @script_name, - REQUEST_METHOD => GET, - "CONTENT_LENGTH" => "0", "CONTENT_TYPE" => "", - RACK_INPUT => StringIO.new("")) - @app.call(env) - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/reloader.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/reloader.rb deleted file mode 100644 index a15064a..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/reloader.rb +++ /dev/null @@ -1,112 +0,0 @@ -# frozen_string_literal: true - -# Copyright (C) 2009-2018 Michael Fellinger -# Rack::Reloader is subject to the terms of an MIT-style license. -# See MIT-LICENSE or https://opensource.org/licenses/MIT. - -require 'pathname' - -module Rack - - # High performant source reloader - # - # This class acts as Rack middleware. - # - # What makes it especially suited for use in a production environment is that - # any file will only be checked once and there will only be made one system - # call stat(2). - # - # Please note that this will not reload files in the background, it does so - # only when actively called. - # - # It is performing a check/reload cycle at the start of every request, but - # also respects a cool down time, during which nothing will be done. - class Reloader - def initialize(app, cooldown = 10, backend = Stat) - @app = app - @cooldown = cooldown - @last = (Time.now - cooldown) - @cache = {} - @mtimes = {} - @reload_mutex = Mutex.new - - extend backend - end - - def call(env) - if @cooldown and Time.now > @last + @cooldown - if Thread.list.size > 1 - @reload_mutex.synchronize{ reload! } - else - reload! - end - - @last = Time.now - end - - @app.call(env) - end - - def reload!(stderr = $stderr) - rotation do |file, mtime| - previous_mtime = @mtimes[file] ||= mtime - safe_load(file, mtime, stderr) if mtime > previous_mtime - end - end - - # A safe Kernel::load, issuing the hooks depending on the results - def safe_load(file, mtime, stderr = $stderr) - load(file) - stderr.puts "#{self.class}: reloaded `#{file}'" - file - rescue LoadError, SyntaxError => ex - stderr.puts ex - ensure - @mtimes[file] = mtime - end - - module Stat - def rotation - files = [$0, *$LOADED_FEATURES].uniq - paths = ['./', *$LOAD_PATH].uniq - - files.map{|file| - next if /\.(so|bundle)$/.match?(file) # cannot reload compiled files - - found, stat = figure_path(file, paths) - next unless found && stat && mtime = stat.mtime - - @cache[file] = found - - yield(found, mtime) - }.compact - end - - # Takes a relative or absolute +file+ name, a couple possible +paths+ that - # the +file+ might reside in. Returns the full path and File::Stat for the - # path. - def figure_path(file, paths) - found = @cache[file] - found = file if !found and Pathname.new(file).absolute? - found, stat = safe_stat(found) - return found, stat if found - - paths.find do |possible_path| - path = ::File.join(possible_path, file) - found, stat = safe_stat(path) - return ::File.expand_path(found), stat if found - end - - return false, false - end - - def safe_stat(file) - return unless file - stat = ::File.stat(file) - return file, stat if stat.file? - rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ESRCH - @cache.delete(file) and false - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/request.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/request.rb deleted file mode 100644 index 93526a0..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/request.rb +++ /dev/null @@ -1,796 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' -require_relative 'media_type' - -module Rack - # Rack::Request provides a convenient interface to a Rack - # environment. It is stateless, the environment +env+ passed to the - # constructor will be directly modified. - # - # req = Rack::Request.new(env) - # req.post? - # req.params["data"] - - class Request - class << self - attr_accessor :ip_filter - - # The priority when checking forwarded headers. The default - # is [:forwarded, :x_forwarded], which means, check the - # +Forwarded+ header first, followed by the appropriate - # X-Forwarded-* header. You can revert the priority by - # reversing the priority, or remove checking of either - # or both headers by removing elements from the array. - # - # This should be set as appropriate in your environment - # based on what reverse proxies are in use. If you are not - # using reverse proxies, you should probably use an empty - # array. - attr_accessor :forwarded_priority - - # The priority when checking either the X-Forwarded-Proto - # or X-Forwarded-Scheme header for the forwarded protocol. - # The default is [:proto, :scheme], to try the - # X-Forwarded-Proto header before the - # X-Forwarded-Scheme header. Rack 2 had behavior - # similar to [:scheme, :proto]. You can remove either or - # both of the entries in array to ignore that respective header. - attr_accessor :x_forwarded_proto_priority - end - - @forwarded_priority = [:forwarded, :x_forwarded] - @x_forwarded_proto_priority = [:proto, :scheme] - - valid_ipv4_octet = /\.(25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])/ - - trusted_proxies = Regexp.union( - /\A127#{valid_ipv4_octet}{3}\z/, # localhost IPv4 range 127.x.x.x, per RFC-3330 - /\A::1\z/, # localhost IPv6 ::1 - /\Af[cd][0-9a-f]{2}(?::[0-9a-f]{0,4}){0,7}\z/i, # private IPv6 range fc00 .. fdff - /\A10#{valid_ipv4_octet}{3}\z/, # private IPv4 range 10.x.x.x - /\A172\.(1[6-9]|2[0-9]|3[01])#{valid_ipv4_octet}{2}\z/, # private IPv4 range 172.16.0.0 .. 172.31.255.255 - /\A192\.168#{valid_ipv4_octet}{2}\z/, # private IPv4 range 192.168.x.x - /\Alocalhost\z|\Aunix(\z|:)/i, # localhost hostname, and unix domain sockets - ) - - self.ip_filter = lambda { |ip| trusted_proxies.match?(ip) } - - ALLOWED_SCHEMES = %w(https http wss ws).freeze - - def initialize(env) - @env = env - @params = nil - end - - def params - @params ||= super - end - - def update_param(k, v) - super - @params = nil - end - - def delete_param(k) - v = super - @params = nil - v - end - - module Env - # The environment of the request. - attr_reader :env - - def initialize(env) - @env = env - # This module is included at least in `ActionDispatch::Request` - # The call to `super()` allows additional mixed-in initializers are called - super() - end - - # Predicate method to test to see if `name` has been set as request - # specific data - def has_header?(name) - @env.key? name - end - - # Get a request specific value for `name`. - def get_header(name) - @env[name] - end - - # If a block is given, it yields to the block if the value hasn't been set - # on the request. - def fetch_header(name, &block) - @env.fetch(name, &block) - end - - # Loops through each key / value pair in the request specific data. - def each_header(&block) - @env.each(&block) - end - - # Set a request specific value for `name` to `v` - def set_header(name, v) - @env[name] = v - end - - # Add a header that may have multiple values. - # - # Example: - # request.add_header 'Accept', 'image/png' - # request.add_header 'Accept', '*/*' - # - # assert_equal 'image/png,*/*', request.get_header('Accept') - # - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 - def add_header(key, v) - if v.nil? - get_header key - elsif has_header? key - set_header key, "#{get_header key},#{v}" - else - set_header key, v - end - end - - # Delete a request specific value for `name`. - def delete_header(name) - @env.delete name - end - - def initialize_copy(other) - @env = other.env.dup - end - end - - module Helpers - # The set of form-data media-types. Requests that do not indicate - # one of the media types present in this list will not be eligible - # for form-data / param parsing. - FORM_DATA_MEDIA_TYPES = [ - 'application/x-www-form-urlencoded', - 'multipart/form-data' - ] - - # The set of media-types. Requests that do not indicate - # one of the media types present in this list will not be eligible - # for param parsing like soap attachments or generic multiparts - PARSEABLE_DATA_MEDIA_TYPES = [ - 'multipart/related', - 'multipart/mixed' - ] - - # Default ports depending on scheme. Used to decide whether or not - # to include the port in a generated URI. - DEFAULT_PORTS = { 'http' => 80, 'https' => 443, 'coffee' => 80 } - - # The address of the client which connected to the proxy. - HTTP_X_FORWARDED_FOR = 'HTTP_X_FORWARDED_FOR' - - # The contents of the host/:authority header sent to the proxy. - HTTP_X_FORWARDED_HOST = 'HTTP_X_FORWARDED_HOST' - - HTTP_FORWARDED = 'HTTP_FORWARDED' - - # The value of the scheme sent to the proxy. - HTTP_X_FORWARDED_SCHEME = 'HTTP_X_FORWARDED_SCHEME' - - # The protocol used to connect to the proxy. - HTTP_X_FORWARDED_PROTO = 'HTTP_X_FORWARDED_PROTO' - - # The port used to connect to the proxy. - HTTP_X_FORWARDED_PORT = 'HTTP_X_FORWARDED_PORT' - - # Another way for specifying https scheme was used. - HTTP_X_FORWARDED_SSL = 'HTTP_X_FORWARDED_SSL' - - def body; get_header(RACK_INPUT) end - def script_name; get_header(SCRIPT_NAME).to_s end - def script_name=(s); set_header(SCRIPT_NAME, s.to_s) end - - def path_info; get_header(PATH_INFO).to_s end - def path_info=(s); set_header(PATH_INFO, s.to_s) end - - def request_method; get_header(REQUEST_METHOD) end - def query_string; get_header(QUERY_STRING).to_s end - def content_length; get_header('CONTENT_LENGTH') end - def logger; get_header(RACK_LOGGER) end - def user_agent; get_header('HTTP_USER_AGENT') end - - # the referer of the client - def referer; get_header('HTTP_REFERER') end - alias referrer referer - - def session - fetch_header(RACK_SESSION) do |k| - set_header RACK_SESSION, default_session - end - end - - def session_options - fetch_header(RACK_SESSION_OPTIONS) do |k| - set_header RACK_SESSION_OPTIONS, {} - end - end - - # Checks the HTTP request method (or verb) to see if it was of type DELETE - def delete?; request_method == DELETE end - - # Checks the HTTP request method (or verb) to see if it was of type GET - def get?; request_method == GET end - - # Checks the HTTP request method (or verb) to see if it was of type HEAD - def head?; request_method == HEAD end - - # Checks the HTTP request method (or verb) to see if it was of type OPTIONS - def options?; request_method == OPTIONS end - - # Checks the HTTP request method (or verb) to see if it was of type LINK - def link?; request_method == LINK end - - # Checks the HTTP request method (or verb) to see if it was of type PATCH - def patch?; request_method == PATCH end - - # Checks the HTTP request method (or verb) to see if it was of type POST - def post?; request_method == POST end - - # Checks the HTTP request method (or verb) to see if it was of type PUT - def put?; request_method == PUT end - - # Checks the HTTP request method (or verb) to see if it was of type TRACE - def trace?; request_method == TRACE end - - # Checks the HTTP request method (or verb) to see if it was of type UNLINK - def unlink?; request_method == UNLINK end - - def scheme - if get_header(HTTPS) == 'on' - 'https' - elsif get_header(HTTP_X_FORWARDED_SSL) == 'on' - 'https' - elsif forwarded_scheme - forwarded_scheme - else - get_header(RACK_URL_SCHEME) - end - end - - # The authority of the incoming request as defined by RFC3976. - # https://tools.ietf.org/html/rfc3986#section-3.2 - # - # In HTTP/1, this is the `host` header. - # In HTTP/2, this is the `:authority` pseudo-header. - def authority - forwarded_authority || host_authority || server_authority - end - - # The authority as defined by the `SERVER_NAME` and `SERVER_PORT` - # variables. - def server_authority - host = self.server_name - port = self.server_port - - if host - if port - "#{host}:#{port}" - else - host - end - end - end - - def server_name - get_header(SERVER_NAME) - end - - def server_port - get_header(SERVER_PORT) - end - - def cookies - hash = fetch_header(RACK_REQUEST_COOKIE_HASH) do |key| - set_header(key, {}) - end - - string = get_header(HTTP_COOKIE) - - unless string == get_header(RACK_REQUEST_COOKIE_STRING) - hash.replace Utils.parse_cookies_header(string) - set_header(RACK_REQUEST_COOKIE_STRING, string) - end - - hash - end - - def content_type - content_type = get_header('CONTENT_TYPE') - content_type.nil? || content_type.empty? ? nil : content_type - end - - def xhr? - get_header("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" - end - - # The `HTTP_HOST` header. - def host_authority - get_header(HTTP_HOST) - end - - def host_with_port(authority = self.authority) - host, _, port = split_authority(authority) - - if port == DEFAULT_PORTS[self.scheme] - host - else - authority - end - end - - # Returns a formatted host, suitable for being used in a URI. - def host - split_authority(self.authority)[0] - end - - # Returns an address suitable for being to resolve to an address. - # In the case of a domain name or IPv4 address, the result is the same - # as +host+. In the case of IPv6 or future address formats, the square - # brackets are removed. - def hostname - split_authority(self.authority)[1] - end - - def port - if authority = self.authority - _, _, port = split_authority(authority) - end - - port || forwarded_port&.last || DEFAULT_PORTS[scheme] || server_port - end - - def forwarded_for - forwarded_priority.each do |type| - case type - when :forwarded - if forwarded_for = get_http_forwarded(:for) - return(forwarded_for.map! do |authority| - split_authority(authority)[1] - end) - end - when :x_forwarded - if value = get_header(HTTP_X_FORWARDED_FOR) - return(split_header(value).map do |authority| - split_authority(wrap_ipv6(authority))[1] - end) - end - end - end - - nil - end - - def forwarded_port - forwarded_priority.each do |type| - case type - when :forwarded - if forwarded = get_http_forwarded(:for) - return(forwarded.map do |authority| - split_authority(authority)[2] - end.compact) - end - when :x_forwarded - if value = get_header(HTTP_X_FORWARDED_PORT) - return split_header(value).map(&:to_i) - end - end - end - - nil - end - - def forwarded_authority - forwarded_priority.each do |type| - case type - when :forwarded - if forwarded = get_http_forwarded(:host) - return forwarded.last - end - when :x_forwarded - if value = get_header(HTTP_X_FORWARDED_HOST) - return wrap_ipv6(split_header(value).last) - end - end - end - - nil - end - - def ssl? - scheme == 'https' || scheme == 'wss' - end - - def ip - remote_addresses = split_header(get_header('REMOTE_ADDR')) - external_addresses = reject_trusted_ip_addresses(remote_addresses) - - unless external_addresses.empty? - return external_addresses.last - end - - if (forwarded_for = self.forwarded_for) && !forwarded_for.empty? - # The forwarded for addresses are ordered: client, proxy1, proxy2. - # So we reject all the trusted addresses (proxy*) and return the - # last client. Or if we trust everyone, we just return the first - # address. - return reject_trusted_ip_addresses(forwarded_for).last || forwarded_for.first - end - - # If all the addresses are trusted, and we aren't forwarded, just return - # the first remote address, which represents the source of the request. - remote_addresses.first - end - - # The media type (type/subtype) portion of the CONTENT_TYPE header - # without any media type parameters. e.g., when CONTENT_TYPE is - # "text/plain;charset=utf-8", the media-type is "text/plain". - # - # For more information on the use of media types in HTTP, see: - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 - def media_type - MediaType.type(content_type) - end - - # The media type parameters provided in CONTENT_TYPE as a Hash, or - # an empty Hash if no CONTENT_TYPE or media-type parameters were - # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", - # this method responds with the following Hash: - # { 'charset' => 'utf-8' } - def media_type_params - MediaType.params(content_type) - end - - # The character set of the request body if a "charset" media type - # parameter was given, or nil if no "charset" was specified. Note - # that, per RFC2616, text/* media types that specify no explicit - # charset are to be considered ISO-8859-1. - def content_charset - media_type_params['charset'] - end - - # Determine whether the request body contains form-data by checking - # the request content-type for one of the media-types: - # "application/x-www-form-urlencoded" or "multipart/form-data". The - # list of form-data media types can be modified through the - # +FORM_DATA_MEDIA_TYPES+ array. - # - # A request body is also assumed to contain form-data when no - # content-type header is provided and the request_method is POST. - def form_data? - type = media_type - meth = get_header(RACK_METHODOVERRIDE_ORIGINAL_METHOD) || get_header(REQUEST_METHOD) - - (meth == POST && type.nil?) || FORM_DATA_MEDIA_TYPES.include?(type) - end - - # Determine whether the request body contains data by checking - # the request media_type against registered parse-data media-types - def parseable_data? - PARSEABLE_DATA_MEDIA_TYPES.include?(media_type) - end - - # Returns the data received in the query string. - def GET - rr_query_string = get_header(RACK_REQUEST_QUERY_STRING) - query_string = self.query_string - if rr_query_string == query_string - get_header(RACK_REQUEST_QUERY_HASH) - else - if rr_query_string - warn "query string used for GET parsing different from current query string. Starting in Rack 3.2, Rack will used the cached GET value instead of parsing the current query string.", uplevel: 1 - end - query_hash = parse_query(query_string, '&') - set_header(RACK_REQUEST_QUERY_STRING, query_string) - set_header(RACK_REQUEST_QUERY_HASH, query_hash) - end - end - - # Returns the data received in the request body. - # - # This method support both application/x-www-form-urlencoded and - # multipart/form-data. - def POST - if error = get_header(RACK_REQUEST_FORM_ERROR) - raise error.class, error.message, cause: error.cause - end - - begin - rack_input = get_header(RACK_INPUT) - - # If the form hash was already memoized: - if form_hash = get_header(RACK_REQUEST_FORM_HASH) - form_input = get_header(RACK_REQUEST_FORM_INPUT) - # And it was memoized from the same input: - if form_input.equal?(rack_input) - return form_hash - elsif form_input - warn "input stream used for POST parsing different from current input stream. Starting in Rack 3.2, Rack will used the cached POST value instead of parsing the current input stream.", uplevel: 1 - end - end - - # Otherwise, figure out how to parse the input: - if rack_input.nil? - set_header RACK_REQUEST_FORM_INPUT, nil - set_header(RACK_REQUEST_FORM_HASH, {}) - elsif form_data? || parseable_data? - if pairs = Rack::Multipart.parse_multipart(env, Rack::Multipart::ParamList) - set_header RACK_REQUEST_FORM_PAIRS, pairs - set_header RACK_REQUEST_FORM_HASH, expand_param_pairs(pairs) - else - form_vars = get_header(RACK_INPUT).read - - # Fix for Safari Ajax postings that always append \0 - # form_vars.sub!(/\0\z/, '') # performance replacement: - form_vars.slice!(-1) if form_vars.end_with?("\0") - - set_header RACK_REQUEST_FORM_VARS, form_vars - set_header RACK_REQUEST_FORM_HASH, parse_query(form_vars, '&') - end - - set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) - get_header RACK_REQUEST_FORM_HASH - else - set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) - set_header(RACK_REQUEST_FORM_HASH, {}) - end - rescue => error - set_header(RACK_REQUEST_FORM_ERROR, error) - raise - end - end - - # The union of GET and POST data. - # - # Note that modifications will not be persisted in the env. Use update_param or delete_param if you want to destructively modify params. - def params - self.GET.merge(self.POST) - end - - # Destructively update a parameter, whether it's in GET and/or POST. Returns nil. - # - # The parameter is updated wherever it was previous defined, so GET, POST, or both. If it wasn't previously defined, it's inserted into GET. - # - # env['rack.input'] is not touched. - def update_param(k, v) - found = false - if self.GET.has_key?(k) - found = true - self.GET[k] = v - end - if self.POST.has_key?(k) - found = true - self.POST[k] = v - end - unless found - self.GET[k] = v - end - end - - # Destructively delete a parameter, whether it's in GET or POST. Returns the value of the deleted parameter. - # - # If the parameter is in both GET and POST, the POST value takes precedence since that's how #params works. - # - # env['rack.input'] is not touched. - def delete_param(k) - post_value, get_value = self.POST.delete(k), self.GET.delete(k) - post_value || get_value - end - - def base_url - "#{scheme}://#{host_with_port}" - end - - # Tries to return a remake of the original request URL as a string. - def url - base_url + fullpath - end - - def path - script_name + path_info - end - - def fullpath - query_string.empty? ? path : "#{path}?#{query_string}" - end - - def accept_encoding - parse_http_accept_header(get_header("HTTP_ACCEPT_ENCODING")) - end - - def accept_language - parse_http_accept_header(get_header("HTTP_ACCEPT_LANGUAGE")) - end - - def trusted_proxy?(ip) - Rack::Request.ip_filter.call(ip) - end - - # like Hash#values_at - def values_at(*keys) - warn("Request#values_at is deprecated and will be removed in a future version of Rack. Please use request.params.values_at instead", uplevel: 1) - - keys.map { |key| params[key] } - end - - private - - def default_session; {}; end - - # Assist with compatibility when processing `X-Forwarded-For`. - def wrap_ipv6(host) - # Even thought IPv6 addresses should be wrapped in square brackets, - # sometimes this is not done in various legacy/underspecified headers. - # So we try to fix this situation for compatibility reasons. - - # Try to detect IPv6 addresses which aren't escaped yet: - if !host.start_with?('[') && host.count(':') > 1 - "[#{host}]" - else - host - end - end - - def parse_http_accept_header(header) - # It would be nice to use filter_map here, but it's Ruby 2.7+ - parts = header.to_s.split(',') - - parts.map! do |part| - part.strip! - next if part.empty? - - attribute, parameters = part.split(';', 2) - attribute.strip! - parameters&.strip! - quality = 1.0 - if parameters and /\Aq=([\d.]+)/ =~ parameters - quality = $1.to_f - end - [attribute, quality] - end - - parts.compact! - - parts - end - - # Get an array of values set in the RFC 7239 `Forwarded` request header. - def get_http_forwarded(token) - Utils.forwarded_values(get_header(HTTP_FORWARDED))&.[](token) - end - - def query_parser - Utils.default_query_parser - end - - def parse_query(qs, d = '&') - query_parser.parse_nested_query(qs, d) - end - - def parse_multipart - Rack::Multipart.extract_multipart(self, query_parser) - end - - def expand_param_pairs(pairs, query_parser = query_parser()) - params = query_parser.make_params - - pairs.each do |k, v| - query_parser.normalize_params(params, k, v) - end - - params.to_params_hash - end - - def split_header(value) - value ? value.strip.split(/[,\s]+/) : [] - end - - # ipv6 extracted from resolv stdlib, simplified - # to remove numbered match group creation. - ipv6 = Regexp.union( - /(?:[0-9A-Fa-f]{1,4}:){7} - [0-9A-Fa-f]{1,4}/x, - /(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: - (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?/x, - /(?:[0-9A-Fa-f]{1,4}:){6,6} - \d+\.\d+\.\d+\.\d+/x, - /(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: - (?:[0-9A-Fa-f]{1,4}:)* - \d+\.\d+\.\d+\.\d+/x, - /[Ff][Ee]80 - (?::[0-9A-Fa-f]{1,4}){7} - %[-0-9A-Za-z._~]+/x, - /[Ff][Ee]80: - (?: - (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? :: - (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? - | - :(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)? - )? - :[0-9A-Fa-f]{1,4}%[-0-9A-Za-z._~]+/x) - - AUTHORITY = / - \A - (? - # Match IPv6 as a string of hex digits and colons in square brackets - \[(?
#{ipv6})\] - | - # Match any other printable string (except square brackets) as a hostname - (?
[[[:graph:]&&[^\[\]]]]*?) - ) - (:(?\d+))? - \z - /x - - private_constant :AUTHORITY - - def split_authority(authority) - return [] if authority.nil? - return [] unless match = AUTHORITY.match(authority) - return match[:host], match[:address], match[:port]&.to_i - end - - def reject_trusted_ip_addresses(ip_addresses) - ip_addresses.reject { |ip| trusted_proxy?(ip) } - end - - FORWARDED_SCHEME_HEADERS = { - proto: HTTP_X_FORWARDED_PROTO, - scheme: HTTP_X_FORWARDED_SCHEME - }.freeze - private_constant :FORWARDED_SCHEME_HEADERS - def forwarded_scheme - forwarded_priority.each do |type| - case type - when :forwarded - if (forwarded_proto = get_http_forwarded(:proto)) && - (scheme = allowed_scheme(forwarded_proto.last)) - return scheme - end - when :x_forwarded - x_forwarded_proto_priority.each do |x_type| - if header = FORWARDED_SCHEME_HEADERS[x_type] - split_header(get_header(header)).reverse_each do |scheme| - if allowed_scheme(scheme) - return scheme - end - end - end - end - end - end - - nil - end - - def allowed_scheme(header) - header if ALLOWED_SCHEMES.include?(header) - end - - def forwarded_priority - Request.forwarded_priority - end - - def x_forwarded_proto_priority - Request.x_forwarded_proto_priority - end - end - - include Env - include Helpers - end -end - -# :nocov: -require_relative 'multipart' unless defined?(Rack::Multipart) -# :nocov: diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/response.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/response.rb deleted file mode 100644 index ece451d..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/response.rb +++ /dev/null @@ -1,403 +0,0 @@ -# frozen_string_literal: true - -require 'time' - -require_relative 'constants' -require_relative 'utils' -require_relative 'media_type' -require_relative 'headers' - -module Rack - # Rack::Response provides a convenient interface to create a Rack - # response. - # - # It allows setting of headers and cookies, and provides useful - # defaults (an OK response with empty headers and body). - # - # You can use Response#write to iteratively generate your response, - # but note that this is buffered by Rack::Response until you call - # +finish+. +finish+ however can take a block inside which calls to - # +write+ are synchronous with the Rack response. - # - # Your application's +call+ should end returning Response#finish. - class Response - def self.[](status, headers, body) - self.new(body, status, headers) - end - - CHUNKED = 'chunked' - STATUS_WITH_NO_ENTITY_BODY = Utils::STATUS_WITH_NO_ENTITY_BODY - - attr_accessor :length, :status, :body - attr_reader :headers - - # Initialize the response object with the specified +body+, +status+ - # and +headers+. - # - # If the +body+ is +nil+, construct an empty response object with internal - # buffering. - # - # If the +body+ responds to +to_str+, assume it's a string-like object and - # construct a buffered response object containing using that string as the - # initial contents of the buffer. - # - # Otherwise it is expected +body+ conforms to the normal requirements of a - # Rack response body, typically implementing one of +each+ (enumerable - # body) or +call+ (streaming body). - # - # The +status+ defaults to +200+ which is the "OK" HTTP status code. You - # can provide any other valid status code. - # - # The +headers+ must be a +Hash+ of key-value header pairs which conform to - # the Rack specification for response headers. The key must be a +String+ - # instance and the value can be either a +String+ or +Array+ instance. - def initialize(body = nil, status = 200, headers = {}) - @status = status.to_i - - unless headers.is_a?(Hash) - raise ArgumentError, "Headers must be a Hash!" - end - - @headers = Headers.new - # Convert headers input to a plain hash with lowercase keys. - headers.each do |k, v| - @headers[k] = v - end - - @writer = self.method(:append) - - @block = nil - - # Keep track of whether we have expanded the user supplied body. - if body.nil? - @body = [] - @buffered = true - # Body is unspecified - it may be a buffered response, or it may be a HEAD response. - @length = nil - elsif body.respond_to?(:to_str) - @body = [body] - @buffered = true - @length = body.to_str.bytesize - else - @body = body - @buffered = nil # undetermined as of yet. - @length = nil - end - - yield self if block_given? - end - - def redirect(target, status = 302) - self.status = status - self.location = target - end - - def chunked? - CHUNKED == get_header(TRANSFER_ENCODING) - end - - def no_entity_body? - # The response body is an enumerable body and it is not allowed to have an entity body. - @body.respond_to?(:each) && STATUS_WITH_NO_ENTITY_BODY[@status] - end - - # Generate a response array consistent with the requirements of the SPEC. - # @return [Array] a 3-tuple suitable of `[status, headers, body]` - # which is suitable to be returned from the middleware `#call(env)` method. - def finish(&block) - if no_entity_body? - delete_header CONTENT_TYPE - delete_header CONTENT_LENGTH - close - return [@status, @headers, []] - else - if block_given? - # We don't add the content-length here as the user has provided a block that can #write additional chunks to the body. - @block = block - return [@status, @headers, self] - else - # If we know the length of the body, set the content-length header... except if we are chunked? which is a legacy special case where the body might already be encoded and thus the actual encoded body length and the content-length are likely to be different. - if @length && !chunked? - @headers[CONTENT_LENGTH] = @length.to_s - end - return [@status, @headers, @body] - end - end - end - - alias to_a finish # For *response - - def each(&callback) - @body.each(&callback) - @buffered = true - - if @block - @writer = callback - @block.call(self) - end - end - - # Append a chunk to the response body. - # - # Converts the response into a buffered response if it wasn't already. - # - # NOTE: Do not mix #write and direct #body access! - # - def write(chunk) - buffered_body! - - @writer.call(chunk.to_s) - end - - def close - @body.close if @body.respond_to?(:close) - end - - def empty? - @block == nil && @body.empty? - end - - def has_header?(key) - raise ArgumentError unless key.is_a?(String) - @headers.key?(key) - end - def get_header(key) - raise ArgumentError unless key.is_a?(String) - @headers[key] - end - def set_header(key, value) - raise ArgumentError unless key.is_a?(String) - @headers[key] = value - end - def delete_header(key) - raise ArgumentError unless key.is_a?(String) - @headers.delete key - end - - alias :[] :get_header - alias :[]= :set_header - - module Helpers - def invalid?; status < 100 || status >= 600; end - - def informational?; status >= 100 && status < 200; end - def successful?; status >= 200 && status < 300; end - def redirection?; status >= 300 && status < 400; end - def client_error?; status >= 400 && status < 500; end - def server_error?; status >= 500 && status < 600; end - - def ok?; status == 200; end - def created?; status == 201; end - def accepted?; status == 202; end - def no_content?; status == 204; end - def moved_permanently?; status == 301; end - def bad_request?; status == 400; end - def unauthorized?; status == 401; end - def forbidden?; status == 403; end - def not_found?; status == 404; end - def method_not_allowed?; status == 405; end - def not_acceptable?; status == 406; end - def request_timeout?; status == 408; end - def precondition_failed?; status == 412; end - def unprocessable?; status == 422; end - - def redirect?; [301, 302, 303, 307, 308].include? status; end - - def include?(header) - has_header?(header) - end - - # Add a header that may have multiple values. - # - # Example: - # response.add_header 'vary', 'accept-encoding' - # response.add_header 'vary', 'cookie' - # - # assert_equal 'accept-encoding,cookie', response.get_header('vary') - # - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 - def add_header(key, value) - raise ArgumentError unless key.is_a?(String) - - if value.nil? - return get_header(key) - end - - value = value.to_s - - if header = get_header(key) - if header.is_a?(Array) - header << value - else - set_header(key, [header, value]) - end - else - set_header(key, value) - end - end - - # Get the content type of the response. - def content_type - get_header CONTENT_TYPE - end - - # Set the content type of the response. - def content_type=(content_type) - set_header CONTENT_TYPE, content_type - end - - def media_type - MediaType.type(content_type) - end - - def media_type_params - MediaType.params(content_type) - end - - def content_length - cl = get_header CONTENT_LENGTH - cl ? cl.to_i : cl - end - - def location - get_header "location" - end - - def location=(location) - set_header "location", location - end - - def set_cookie(key, value) - add_header SET_COOKIE, Utils.set_cookie_header(key, value) - end - - def delete_cookie(key, value = {}) - set_header(SET_COOKIE, - Utils.delete_set_cookie_header!( - get_header(SET_COOKIE), key, value - ) - ) - end - - def set_cookie_header - get_header SET_COOKIE - end - - def set_cookie_header=(value) - set_header SET_COOKIE, value - end - - def cache_control - get_header CACHE_CONTROL - end - - def cache_control=(value) - set_header CACHE_CONTROL, value - end - - # Specifies that the content shouldn't be cached. Overrides `cache!` if already called. - def do_not_cache! - set_header CACHE_CONTROL, "no-cache, must-revalidate" - set_header EXPIRES, Time.now.httpdate - end - - # Specify that the content should be cached. - # @param duration [Integer] The number of seconds until the cache expires. - # @option directive [String] The cache control directive, one of "public", "private", "no-cache" or "no-store". - def cache!(duration = 3600, directive: "public") - unless headers[CACHE_CONTROL] =~ /no-cache/ - set_header CACHE_CONTROL, "#{directive}, max-age=#{duration}" - set_header EXPIRES, (Time.now + duration).httpdate - end - end - - def etag - get_header ETAG - end - - def etag=(value) - set_header ETAG, value - end - - protected - - # Convert the body of this response into an internally buffered Array if possible. - # - # `@buffered` is a ternary value which indicates whether the body is buffered. It can be: - # * `nil` - The body has not been buffered yet. - # * `true` - The body is buffered as an Array instance. - # * `false` - The body is not buffered and cannot be buffered. - # - # @return [Boolean] whether the body is buffered as an Array instance. - def buffered_body! - if @buffered.nil? - if @body.is_a?(Array) - # The user supplied body was an array: - @body = @body.compact - @length = @body.sum{|part| part.bytesize} - @buffered = true - elsif @body.respond_to?(:each) - # Turn the user supplied body into a buffered array: - body = @body - @body = Array.new - @buffered = true - - body.each do |part| - @writer.call(part.to_s) - end - - body.close if body.respond_to?(:close) - else - # We don't know how to buffer the user-supplied body: - @buffered = false - end - end - - return @buffered - end - - def append(chunk) - chunk = chunk.dup unless chunk.frozen? - @body << chunk - - if @length - @length += chunk.bytesize - elsif @buffered - @length = chunk.bytesize - end - - return chunk - end - end - - include Helpers - - class Raw - include Helpers - - attr_reader :headers - attr_accessor :status - - def initialize(status, headers) - @status = status - @headers = headers - end - - def has_header?(key) - headers.key?(key) - end - - def get_header(key) - headers[key] - end - - def set_header(key, value) - headers[key] = value - end - - def delete_header(key) - headers.delete(key) - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/rewindable_input.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/rewindable_input.rb deleted file mode 100644 index 730c6a2..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/rewindable_input.rb +++ /dev/null @@ -1,113 +0,0 @@ -# -*- encoding: binary -*- -# frozen_string_literal: true - -require 'tempfile' - -require_relative 'constants' - -module Rack - # Class which can make any IO object rewindable, including non-rewindable ones. It does - # this by buffering the data into a tempfile, which is rewindable. - # - # Don't forget to call #close when you're done. This frees up temporary resources that - # RewindableInput uses, though it does *not* close the original IO object. - class RewindableInput - # Makes rack.input rewindable, for compatibility with applications and middleware - # designed for earlier versions of Rack (where rack.input was required to be - # rewindable). - class Middleware - def initialize(app) - @app = app - end - - def call(env) - env[RACK_INPUT] = RewindableInput.new(env[RACK_INPUT]) - @app.call(env) - end - end - - def initialize(io) - @io = io - @rewindable_io = nil - @unlinked = false - end - - def gets - make_rewindable unless @rewindable_io - @rewindable_io.gets - end - - def read(*args) - make_rewindable unless @rewindable_io - @rewindable_io.read(*args) - end - - def each(&block) - make_rewindable unless @rewindable_io - @rewindable_io.each(&block) - end - - def rewind - make_rewindable unless @rewindable_io - @rewindable_io.rewind - end - - def size - make_rewindable unless @rewindable_io - @rewindable_io.size - end - - # Closes this RewindableInput object without closing the originally - # wrapped IO object. Cleans up any temporary resources that this RewindableInput - # has created. - # - # This method may be called multiple times. It does nothing on subsequent calls. - def close - if @rewindable_io - if @unlinked - @rewindable_io.close - else - @rewindable_io.close! - end - @rewindable_io = nil - end - end - - private - - def make_rewindable - # Buffer all data into a tempfile. Since this tempfile is private to this - # RewindableInput object, we chmod it so that nobody else can read or write - # it. On POSIX filesystems we also unlink the file so that it doesn't - # even have a file entry on the filesystem anymore, though we can still - # access it because we have the file handle open. - @rewindable_io = Tempfile.new('RackRewindableInput') - @rewindable_io.chmod(0000) - @rewindable_io.set_encoding(Encoding::BINARY) - @rewindable_io.binmode - # :nocov: - if filesystem_has_posix_semantics? - raise 'Unlink failed. IO closed.' if @rewindable_io.closed? - @unlinked = true - end - # :nocov: - - buffer = "".dup - while @io.read(1024 * 4, buffer) - entire_buffer_written_out = false - while !entire_buffer_written_out - written = @rewindable_io.write(buffer) - entire_buffer_written_out = written == buffer.bytesize - if !entire_buffer_written_out - buffer.slice!(0 .. written - 1) - end - end - end - @rewindable_io.rewind - end - - def filesystem_has_posix_semantics? - RUBY_PLATFORM !~ /(mswin|mingw|cygwin|java)/ - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/runtime.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/runtime.rb deleted file mode 100644 index a1bfa69..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/runtime.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require_relative 'utils' - -module Rack - # Sets an "x-runtime" response header, indicating the response - # time of the request, in seconds - # - # You can put it right before the application to see the processing - # time, or before all the other middlewares to include time for them, - # too. - class Runtime - FORMAT_STRING = "%0.6f" # :nodoc: - HEADER_NAME = "x-runtime" # :nodoc: - - def initialize(app, name = nil) - @app = app - @header_name = HEADER_NAME - @header_name += "-#{name.to_s.downcase}" if name - end - - def call(env) - start_time = Utils.clock_time - _, headers, _ = response = @app.call(env) - - request_time = Utils.clock_time - start_time - - unless headers.key?(@header_name) - headers[@header_name] = FORMAT_STRING % request_time - end - - response - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/sendfile.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/sendfile.rb deleted file mode 100644 index 9c6e0c4..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/sendfile.rb +++ /dev/null @@ -1,167 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'utils' -require_relative 'body_proxy' - -module Rack - - # = Sendfile - # - # The Sendfile middleware intercepts responses whose body is being - # served from a file and replaces it with a server specific x-sendfile - # header. The web server is then responsible for writing the file contents - # to the client. This can dramatically reduce the amount of work required - # by the Ruby backend and takes advantage of the web server's optimized file - # delivery code. - # - # In order to take advantage of this middleware, the response body must - # respond to +to_path+ and the request must include an x-sendfile-type - # header. Rack::Files and other components implement +to_path+ so there's - # rarely anything you need to do in your application. The x-sendfile-type - # header is typically set in your web servers configuration. The following - # sections attempt to document - # - # === Nginx - # - # Nginx supports the x-accel-redirect header. This is similar to x-sendfile - # but requires parts of the filesystem to be mapped into a private URL - # hierarchy. - # - # The following example shows the Nginx configuration required to create - # a private "/files/" area, enable x-accel-redirect, and pass the special - # x-sendfile-type and x-accel-mapping headers to the backend: - # - # location ~ /files/(.*) { - # internal; - # alias /var/www/$1; - # } - # - # location / { - # proxy_redirect off; - # - # proxy_set_header Host $host; - # proxy_set_header X-Real-IP $remote_addr; - # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # - # proxy_set_header x-sendfile-type x-accel-redirect; - # proxy_set_header x-accel-mapping /var/www/=/files/; - # - # proxy_pass http://127.0.0.1:8080/; - # } - # - # Note that the x-sendfile-type header must be set exactly as shown above. - # The x-accel-mapping header should specify the location on the file system, - # followed by an equals sign (=), followed name of the private URL pattern - # that it maps to. The middleware performs a simple substitution on the - # resulting path. - # - # See Also: https://www.nginx.com/resources/wiki/start/topics/examples/xsendfile - # - # === lighttpd - # - # Lighttpd has supported some variation of the x-sendfile header for some - # time, although only recent version support x-sendfile in a reverse proxy - # configuration. - # - # $HTTP["host"] == "example.com" { - # proxy-core.protocol = "http" - # proxy-core.balancer = "round-robin" - # proxy-core.backends = ( - # "127.0.0.1:8000", - # "127.0.0.1:8001", - # ... - # ) - # - # proxy-core.allow-x-sendfile = "enable" - # proxy-core.rewrite-request = ( - # "x-sendfile-type" => (".*" => "x-sendfile") - # ) - # } - # - # See Also: http://redmine.lighttpd.net/wiki/lighttpd/Docs:ModProxyCore - # - # === Apache - # - # x-sendfile is supported under Apache 2.x using a separate module: - # - # https://tn123.org/mod_xsendfile/ - # - # Once the module is compiled and installed, you can enable it using - # XSendFile config directive: - # - # RequestHeader Set x-sendfile-type x-sendfile - # ProxyPassReverse / http://localhost:8001/ - # XSendFile on - # - # === Mapping parameter - # - # The third parameter allows for an overriding extension of the - # x-accel-mapping header. Mappings should be provided in tuples of internal to - # external. The internal values may contain regular expression syntax, they - # will be matched with case indifference. - - class Sendfile - def initialize(app, variation = nil, mappings = []) - @app = app - @variation = variation - @mappings = mappings.map do |internal, external| - [/^#{internal}/i, external] - end - end - - def call(env) - _, headers, body = response = @app.call(env) - - if body.respond_to?(:to_path) - case type = variation(env) - when /x-accel-redirect/i - path = ::File.expand_path(body.to_path) - if url = map_accel_path(env, path) - headers[CONTENT_LENGTH] = '0' - # '?' must be percent-encoded because it is not query string but a part of path - headers[type.downcase] = ::Rack::Utils.escape_path(url).gsub('?', '%3F') - obody = body - response[2] = Rack::BodyProxy.new([]) do - obody.close if obody.respond_to?(:close) - end - else - env[RACK_ERRORS].puts "x-accel-mapping header missing" - end - when /x-sendfile|x-lighttpd-send-file/i - path = ::File.expand_path(body.to_path) - headers[CONTENT_LENGTH] = '0' - headers[type.downcase] = path - obody = body - response[2] = Rack::BodyProxy.new([]) do - obody.close if obody.respond_to?(:close) - end - when '', nil - else - env[RACK_ERRORS].puts "Unknown x-sendfile variation: '#{type}'.\n" - end - end - response - end - - private - def variation(env) - @variation || - env['sendfile.type'] || - env['HTTP_X_SENDFILE_TYPE'] - end - - def map_accel_path(env, path) - if mapping = @mappings.find { |internal, _| internal =~ path } - path.sub(*mapping) - elsif mapping = env['HTTP_X_ACCEL_MAPPING'] - mapping.split(',').map(&:strip).each do |m| - internal, external = m.split('=', 2).map(&:strip) - new_path = path.sub(/^#{internal}/i, external) - return new_path unless path == new_path - end - path - end - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_exceptions.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_exceptions.rb deleted file mode 100644 index 9172a4d..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_exceptions.rb +++ /dev/null @@ -1,407 +0,0 @@ -# frozen_string_literal: true - -require 'erb' - -require_relative 'constants' -require_relative 'utils' -require_relative 'request' - -module Rack - # Rack::ShowExceptions catches all exceptions raised from the app it - # wraps. It shows a useful backtrace with the sourcefile and - # clickable context, the whole Rack environment and the request - # data. - # - # Be careful when you use this on public-facing sites as it could - # reveal information helpful to attackers. - - class ShowExceptions - CONTEXT = 7 - - Frame = Struct.new(:filename, :lineno, :function, - :pre_context_lineno, :pre_context, - :context_line, :post_context_lineno, - :post_context) - - def initialize(app) - @app = app - end - - def call(env) - @app.call(env) - rescue StandardError, LoadError, SyntaxError => e - exception_string = dump_exception(e) - - env[RACK_ERRORS].puts(exception_string) - env[RACK_ERRORS].flush - - if accepts_html?(env) - content_type = "text/html" - body = pretty(env, e) - else - content_type = "text/plain" - body = exception_string - end - - [ - 500, - { - CONTENT_TYPE => content_type, - CONTENT_LENGTH => body.bytesize.to_s, - }, - [body], - ] - end - - def prefers_plaintext?(env) - !accepts_html?(env) - end - - def accepts_html?(env) - Rack::Utils.best_q_match(env["HTTP_ACCEPT"], %w[text/html]) - end - private :accepts_html? - - def dump_exception(exception) - if exception.respond_to?(:detailed_message) - message = exception.detailed_message(highlight: false) - else - message = exception.message - end - string = "#{exception.class}: #{message}\n".dup - string << exception.backtrace.map { |l| "\t#{l}" }.join("\n") - string - end - - def pretty(env, exception) - req = Rack::Request.new(env) - - # This double assignment is to prevent an "unused variable" warning. - # Yes, it is dumb, but I don't like Ruby yelling at me. - path = path = (req.script_name + req.path_info).squeeze("/") - - # This double assignment is to prevent an "unused variable" warning. - # Yes, it is dumb, but I don't like Ruby yelling at me. - frames = frames = exception.backtrace.map { |line| - frame = Frame.new - if line =~ /(.*?):(\d+)(:in `(.*)')?/ - frame.filename = $1 - frame.lineno = $2.to_i - frame.function = $4 - - begin - lineno = frame.lineno - 1 - lines = ::File.readlines(frame.filename) - frame.pre_context_lineno = [lineno - CONTEXT, 0].max - frame.pre_context = lines[frame.pre_context_lineno...lineno] - frame.context_line = lines[lineno].chomp - frame.post_context_lineno = [lineno + CONTEXT, lines.size].min - frame.post_context = lines[lineno + 1..frame.post_context_lineno] - rescue - end - - frame - else - nil - end - }.compact - - template.result(binding) - end - - def template - TEMPLATE - end - - def h(obj) # :nodoc: - case obj - when String - Utils.escape_html(obj) - else - Utils.escape_html(obj.inspect) - end - end - - # :stopdoc: - - # adapted from Django - # Copyright (c) Django Software Foundation and individual contributors. - # Used under the modified BSD license: - # http://www.xfree86.org/3.3.6/COPYRIGHT2.html#5 - TEMPLATE = ERB.new(<<-'HTML'.gsub(/^ /, '')) - - - - - - <%=h exception.class %> at <%=h path %> - - - - - -
-

<%=h exception.class %> at <%=h path %>

- <% if exception.respond_to?(:detailed_message) %> -

<%=h exception.detailed_message(highlight: false) %>

- <% else %> -

<%=h exception.message %>

- <% end %> - - - - - - -
Ruby - <% if first = frames.first %> - <%=h first.filename %>: in <%=h first.function %>, line <%=h frames.first.lineno %> - <% else %> - unknown location - <% end %> -
Web<%=h req.request_method %> <%=h(req.host + path)%>
- -

Jump to:

- -
- -
-

Traceback (innermost first)

-
    - <% frames.each { |frame| %> -
  • - <%=h frame.filename %>: in <%=h frame.function %> - - <% if frame.context_line %> -
    - <% if frame.pre_context %> -
      - <% frame.pre_context.each { |line| %> -
    1. <%=h line %>
    2. - <% } %> -
    - <% end %> - -
      -
    1. <%=h frame.context_line %>...
    - - <% if frame.post_context %> -
      - <% frame.post_context.each { |line| %> -
    1. <%=h line %>
    2. - <% } %> -
    - <% end %> -
    - <% end %> -
  • - <% } %> -
-
- -
-

Request information

- -

GET

- <% if req.GET and not req.GET.empty? %> - - - - - - - - - <% req.GET.sort_by { |k, v| k.to_s }.each { |key, val| %> - - - - - <% } %> - -
VariableValue
<%=h key %>
<%=h val.inspect %>
- <% else %> -

No GET data.

- <% end %> - -

POST

- <% if ((req.POST and not req.POST.empty?) rescue (no_post_data = "Invalid POST data"; nil)) %> - - - - - - - - - <% req.POST.sort_by { |k, v| k.to_s }.each { |key, val| %> - - - - - <% } %> - -
VariableValue
<%=h key %>
<%=h val.inspect %>
- <% else %> -

<%= no_post_data || "No POST data" %>.

- <% end %> - - - - <% unless req.cookies.empty? %> - - - - - - - - - <% req.cookies.each { |key, val| %> - - - - - <% } %> - -
VariableValue
<%=h key %>
<%=h val.inspect %>
- <% else %> -

No cookie data.

- <% end %> - -

Rack ENV

- - - - - - - - - <% env.sort_by { |k, v| k.to_s }.each { |key, val| %> - - - - - <% } %> - -
VariableValue
<%=h key %>
<%=h val.inspect %>
- -
- -
-

- You're seeing this error because you use Rack::ShowExceptions. -

-
- - - - HTML - - # :startdoc: - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_status.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_status.rb deleted file mode 100644 index b6f75a0..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/show_status.rb +++ /dev/null @@ -1,123 +0,0 @@ -# frozen_string_literal: true - -require 'erb' - -require_relative 'constants' -require_relative 'utils' -require_relative 'request' -require_relative 'body_proxy' - -module Rack - # Rack::ShowStatus catches all empty responses and replaces them - # with a site explaining the error. - # - # Additional details can be put into rack.showstatus.detail - # and will be shown as HTML. If such details exist, the error page - # is always rendered, even if the reply was not empty. - - class ShowStatus - def initialize(app) - @app = app - @template = ERB.new(TEMPLATE) - end - - def call(env) - status, headers, body = response = @app.call(env) - empty = headers[CONTENT_LENGTH].to_i <= 0 - - # client or server error, or explicit message - if (status.to_i >= 400 && empty) || env[RACK_SHOWSTATUS_DETAIL] - # This double assignment is to prevent an "unused variable" warning. - # Yes, it is dumb, but I don't like Ruby yelling at me. - req = req = Rack::Request.new(env) - - message = Rack::Utils::HTTP_STATUS_CODES[status.to_i] || status.to_s - - # This double assignment is to prevent an "unused variable" warning. - # Yes, it is dumb, but I don't like Ruby yelling at me. - detail = detail = env[RACK_SHOWSTATUS_DETAIL] || message - - html = @template.result(binding) - size = html.bytesize - - response[2] = Rack::BodyProxy.new([html]) do - body.close if body.respond_to?(:close) - end - - headers[CONTENT_TYPE] = "text/html" - headers[CONTENT_LENGTH] = size.to_s - end - - response - end - - def h(obj) # :nodoc: - case obj - when String - Utils.escape_html(obj) - else - Utils.escape_html(obj.inspect) - end - end - - # :stopdoc: - -# adapted from Django -# Copyright (c) Django Software Foundation and individual contributors. -# Used under the modified BSD license: -# http://www.xfree86.org/3.3.6/COPYRIGHT2.html#5 -TEMPLATE = <<'HTML' - - - - - <%=h message %> at <%=h req.script_name + req.path_info %> - - - - -
-

<%=h message %> (<%= status.to_i %>)

- - - - - - - - - -
Request Method:<%=h req.request_method %>
Request URL:<%=h req.url %>
-
-
-

<%=h detail %>

-
- -
-

- You're seeing this error because you use Rack::ShowStatus. -

-
- - -HTML - - # :startdoc: - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/static.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/static.rb deleted file mode 100644 index 5c9b676..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/static.rb +++ /dev/null @@ -1,187 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'files' -require_relative 'mime' - -module Rack - - # The Rack::Static middleware intercepts requests for static files - # (javascript files, images, stylesheets, etc) based on the url prefixes or - # route mappings passed in the options, and serves them using a Rack::Files - # object. This allows a Rack stack to serve both static and dynamic content. - # - # Examples: - # - # Serve all requests beginning with /media from the "media" folder located - # in the current directory (ie media/*): - # - # use Rack::Static, :urls => ["/media"] - # - # Same as previous, but instead of returning 404 for missing files under - # /media, call the next middleware: - # - # use Rack::Static, :urls => ["/media"], :cascade => true - # - # Serve all requests beginning with /css or /images from the folder "public" - # in the current directory (ie public/css/* and public/images/*): - # - # use Rack::Static, :urls => ["/css", "/images"], :root => "public" - # - # Serve all requests to / with "index.html" from the folder "public" in the - # current directory (ie public/index.html): - # - # use Rack::Static, :urls => {"/" => 'index.html'}, :root => 'public' - # - # Serve all requests normally from the folder "public" in the current - # directory but uses index.html as default route for "/" - # - # use Rack::Static, :urls => [""], :root => 'public', :index => - # 'index.html' - # - # Set custom HTTP Headers for based on rules: - # - # use Rack::Static, :root => 'public', - # :header_rules => [ - # [rule, {header_field => content, header_field => content}], - # [rule, {header_field => content}] - # ] - # - # Rules for selecting files: - # - # 1) All files - # Provide the :all symbol - # :all => Matches every file - # - # 2) Folders - # Provide the folder path as a string - # '/folder' or '/folder/subfolder' => Matches files in a certain folder - # - # 3) File Extensions - # Provide the file extensions as an array - # ['css', 'js'] or %w(css js) => Matches files ending in .css or .js - # - # 4) Regular Expressions / Regexp - # Provide a regular expression - # %r{\.(?:css|js)\z} => Matches files ending in .css or .js - # /\.(?:eot|ttf|otf|woff2|woff|svg)\z/ => Matches files ending in - # the most common web font formats (.eot, .ttf, .otf, .woff2, .woff, .svg) - # Note: This Regexp is available as a shortcut, using the :fonts rule - # - # 5) Font Shortcut - # Provide the :fonts symbol - # :fonts => Uses the Regexp rule stated right above to match all common web font endings - # - # Rule Ordering: - # Rules are applied in the order that they are provided. - # List rather general rules above special ones. - # - # Complete example use case including HTTP header rules: - # - # use Rack::Static, :root => 'public', - # :header_rules => [ - # # Cache all static files in public caches (e.g. Rack::Cache) - # # as well as in the browser - # [:all, {'cache-control' => 'public, max-age=31536000'}], - # - # # Provide web fonts with cross-origin access-control-headers - # # Firefox requires this when serving assets using a Content Delivery Network - # [:fonts, {'access-control-allow-origin' => '*'}] - # ] - # - class Static - def initialize(app, options = {}) - @app = app - @urls = options[:urls] || ["/favicon.ico"] - @index = options[:index] - @gzip = options[:gzip] - @cascade = options[:cascade] - root = options[:root] || Dir.pwd - - # HTTP Headers - @header_rules = options[:header_rules] || [] - # Allow for legacy :cache_control option while prioritizing global header_rules setting - @header_rules.unshift([:all, { CACHE_CONTROL => options[:cache_control] }]) if options[:cache_control] - - @file_server = Rack::Files.new(root) - end - - def add_index_root?(path) - @index && route_file(path) && path.end_with?('/') - end - - def overwrite_file_path(path) - @urls.kind_of?(Hash) && @urls.key?(path) || add_index_root?(path) - end - - def route_file(path) - @urls.kind_of?(Array) && @urls.any? { |url| path.index(url) == 0 } - end - - def can_serve(path) - route_file(path) || overwrite_file_path(path) - end - - def call(env) - path = env[PATH_INFO] - - if can_serve(path) - if overwrite_file_path(path) - env[PATH_INFO] = (add_index_root?(path) ? path + @index : @urls[path]) - elsif @gzip && env['HTTP_ACCEPT_ENCODING'] && /\bgzip\b/.match?(env['HTTP_ACCEPT_ENCODING']) - path = env[PATH_INFO] - env[PATH_INFO] += '.gz' - response = @file_server.call(env) - env[PATH_INFO] = path - - if response[0] == 404 - response = nil - elsif response[0] == 304 - # Do nothing, leave headers as is - else - response[1][CONTENT_TYPE] = Mime.mime_type(::File.extname(path), 'text/plain') - response[1]['content-encoding'] = 'gzip' - end - end - - path = env[PATH_INFO] - response ||= @file_server.call(env) - - if @cascade && response[0] == 404 - return @app.call(env) - end - - headers = response[1] - applicable_rules(path).each do |rule, new_headers| - new_headers.each { |field, content| headers[field] = content } - end - - response - else - @app.call(env) - end - end - - # Convert HTTP header rules to HTTP headers - def applicable_rules(path) - @header_rules.find_all do |rule, new_headers| - case rule - when :all - true - when :fonts - /\.(?:ttf|otf|eot|woff2|woff|svg)\z/.match?(path) - when String - path = ::Rack::Utils.unescape(path) - path.start_with?(rule) || path.start_with?('/' + rule) - when Array - /\.(#{rule.join('|')})\z/.match?(path) - when Regexp - rule.match?(path) - else - false - end - end - end - - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb deleted file mode 100644 index 0b94cc7..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/tempfile_reaper.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require_relative 'constants' -require_relative 'body_proxy' - -module Rack - - # Middleware tracks and cleans Tempfiles created throughout a request (i.e. Rack::Multipart) - # Ideas/strategy based on posts by Eric Wong and Charles Oliver Nutter - # https://groups.google.com/forum/#!searchin/rack-devel/temp/rack-devel/brK8eh-MByw/sw61oJJCGRMJ - class TempfileReaper - def initialize(app) - @app = app - end - - def call(env) - env[RACK_TEMPFILES] ||= [] - - begin - _, _, body = response = @app.call(env) - rescue Exception - env[RACK_TEMPFILES]&.each(&:close!) - raise - end - - response[2] = BodyProxy.new(body) do - env[RACK_TEMPFILES]&.each(&:close!) - end - - response - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/urlmap.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/urlmap.rb deleted file mode 100644 index 99c4d82..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/urlmap.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -require 'set' - -require_relative 'constants' - -module Rack - # Rack::URLMap takes a hash mapping urls or paths to apps, and - # dispatches accordingly. Support for HTTP/1.1 host names exists if - # the URLs start with http:// or https://. - # - # URLMap modifies the SCRIPT_NAME and PATH_INFO such that the part - # relevant for dispatch is in the SCRIPT_NAME, and the rest in the - # PATH_INFO. This should be taken care of when you need to - # reconstruct the URL in order to create links. - # - # URLMap dispatches in such a way that the longest paths are tried - # first, since they are most specific. - - class URLMap - def initialize(map = {}) - remap(map) - end - - def remap(map) - @known_hosts = Set[] - @mapping = map.map { |location, app| - if location =~ %r{\Ahttps?://(.*?)(/.*)} - host, location = $1, $2 - @known_hosts << host - else - host = nil - end - - unless location[0] == ?/ - raise ArgumentError, "paths need to start with /" - end - - location = location.chomp('/') - match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", Regexp::NOENCODING) - - [host, location, match, app] - }.sort_by do |(host, location, _, _)| - [host ? -host.size : Float::INFINITY, -location.size] - end - end - - def call(env) - path = env[PATH_INFO] - script_name = env[SCRIPT_NAME] - http_host = env[HTTP_HOST] - server_name = env[SERVER_NAME] - server_port = env[SERVER_PORT] - - is_same_server = casecmp?(http_host, server_name) || - casecmp?(http_host, "#{server_name}:#{server_port}") - - is_host_known = @known_hosts.include? http_host - - @mapping.each do |host, location, match, app| - unless casecmp?(http_host, host) \ - || casecmp?(server_name, host) \ - || (!host && is_same_server) \ - || (!host && !is_host_known) # If we don't have a matching host, default to the first without a specified host - next - end - - next unless m = match.match(path.to_s) - - rest = m[1] - next unless !rest || rest.empty? || rest[0] == ?/ - - env[SCRIPT_NAME] = (script_name + location) - env[PATH_INFO] = rest - - return app.call(env) - end - - [404, { CONTENT_TYPE => "text/plain", "x-cascade" => "pass" }, ["Not Found: #{path}"]] - - ensure - env[PATH_INFO] = path - env[SCRIPT_NAME] = script_name - end - - private - def casecmp?(v1, v2) - # if both nil, or they're the same string - return true if v1 == v2 - - # if either are nil... (but they're not the same) - return false if v1.nil? - return false if v2.nil? - - # otherwise check they're not case-insensitive the same - v1.casecmp(v2).zero? - end - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/utils.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/utils.rb deleted file mode 100644 index bbf4969..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/utils.rb +++ /dev/null @@ -1,631 +0,0 @@ -# -*- encoding: binary -*- -# frozen_string_literal: true - -require 'uri' -require 'fileutils' -require 'set' -require 'tempfile' -require 'time' -require 'erb' - -require_relative 'query_parser' -require_relative 'mime' -require_relative 'headers' -require_relative 'constants' - -module Rack - # Rack::Utils contains a grab-bag of useful methods for writing web - # applications adopted from all kinds of Ruby libraries. - - module Utils - ParameterTypeError = QueryParser::ParameterTypeError - InvalidParameterError = QueryParser::InvalidParameterError - ParamsTooDeepError = QueryParser::ParamsTooDeepError - DEFAULT_SEP = QueryParser::DEFAULT_SEP - COMMON_SEP = QueryParser::COMMON_SEP - KeySpaceConstrainedParams = QueryParser::Params - URI_PARSER = defined?(::URI::RFC2396_PARSER) ? ::URI::RFC2396_PARSER : ::URI::DEFAULT_PARSER - - class << self - attr_accessor :default_query_parser - end - # The default amount of nesting to allowed by hash parameters. - # This helps prevent a rogue client from triggering a possible stack overflow - # when parsing parameters. - self.default_query_parser = QueryParser.make_default(32) - - module_function - - # URI escapes. (CGI style space to +) - def escape(s) - URI.encode_www_form_component(s) - end - - # Like URI escaping, but with %20 instead of +. Strictly speaking this is - # true URI escaping. - def escape_path(s) - URI_PARSER.escape s - end - - # Unescapes the **path** component of a URI. See Rack::Utils.unescape for - # unescaping query parameters or form components. - def unescape_path(s) - URI_PARSER.unescape s - end - - # Unescapes a URI escaped string with +encoding+. +encoding+ will be the - # target encoding of the string returned, and it defaults to UTF-8 - def unescape(s, encoding = Encoding::UTF_8) - URI.decode_www_form_component(s, encoding) - end - - class << self - attr_accessor :multipart_total_part_limit - - attr_accessor :multipart_file_limit - - # multipart_part_limit is the original name of multipart_file_limit, but - # the limit only counts parts with filenames. - alias multipart_part_limit multipart_file_limit - alias multipart_part_limit= multipart_file_limit= - end - - # The maximum number of file parts a request can contain. Accepting too - # many parts can lead to the server running out of file handles. - # Set to `0` for no limit. - self.multipart_file_limit = (ENV['RACK_MULTIPART_PART_LIMIT'] || ENV['RACK_MULTIPART_FILE_LIMIT'] || 128).to_i - - # The maximum total number of parts a request can contain. Accepting too - # many can lead to excessive memory use and parsing time. - self.multipart_total_part_limit = (ENV['RACK_MULTIPART_TOTAL_PART_LIMIT'] || 4096).to_i - - def self.param_depth_limit - default_query_parser.param_depth_limit - end - - def self.param_depth_limit=(v) - self.default_query_parser = self.default_query_parser.new_depth_limit(v) - end - - if defined?(Process::CLOCK_MONOTONIC) - def clock_time - Process.clock_gettime(Process::CLOCK_MONOTONIC) - end - else - # :nocov: - def clock_time - Time.now.to_f - end - # :nocov: - end - - def parse_query(qs, d = nil, &unescaper) - Rack::Utils.default_query_parser.parse_query(qs, d, &unescaper) - end - - def parse_nested_query(qs, d = nil) - Rack::Utils.default_query_parser.parse_nested_query(qs, d) - end - - def build_query(params) - params.map { |k, v| - if v.class == Array - build_query(v.map { |x| [k, x] }) - else - v.nil? ? escape(k) : "#{escape(k)}=#{escape(v)}" - end - }.join("&") - end - - def build_nested_query(value, prefix = nil) - case value - when Array - value.map { |v| - build_nested_query(v, "#{prefix}[]") - }.join("&") - when Hash - value.map { |k, v| - build_nested_query(v, prefix ? "#{prefix}[#{k}]" : k) - }.delete_if(&:empty?).join('&') - when nil - escape(prefix) - else - raise ArgumentError, "value must be a Hash" if prefix.nil? - "#{escape(prefix)}=#{escape(value)}" - end - end - - def q_values(q_value_header) - q_value_header.to_s.split(',').map do |part| - value, parameters = part.split(';', 2).map(&:strip) - quality = 1.0 - if parameters && (md = /\Aq=([\d.]+)/.match(parameters)) - quality = md[1].to_f - end - [value, quality] - end - end - - def forwarded_values(forwarded_header) - return nil unless forwarded_header - forwarded_header = forwarded_header.to_s.gsub("\n", ";") - - forwarded_header.split(';').each_with_object({}) do |field, values| - field.split(',').each do |pair| - pair = pair.split('=').map(&:strip).join('=') - return nil unless pair =~ /\A(by|for|host|proto)="?([^"]+)"?\Z/i - (values[$1.downcase.to_sym] ||= []) << $2 - end - end - end - module_function :forwarded_values - - # Return best accept value to use, based on the algorithm - # in RFC 2616 Section 14. If there are multiple best - # matches (same specificity and quality), the value returned - # is arbitrary. - def best_q_match(q_value_header, available_mimes) - values = q_values(q_value_header) - - matches = values.map do |req_mime, quality| - match = available_mimes.find { |am| Rack::Mime.match?(am, req_mime) } - next unless match - [match, quality] - end.compact.sort_by do |match, quality| - (match.split('/', 2).count('*') * -10) + quality - end.last - matches&.first - end - - # Introduced in ERB 4.0. ERB::Escape is an alias for ERB::Utils which - # doesn't get monkey-patched by rails - if defined?(ERB::Escape) && ERB::Escape.instance_method(:html_escape) - define_method(:escape_html, ERB::Escape.instance_method(:html_escape)) - else - require 'cgi/escape' - # Escape ampersands, brackets and quotes to their HTML/XML entities. - def escape_html(string) - CGI.escapeHTML(string.to_s) - end - end - - def select_best_encoding(available_encodings, accept_encoding) - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html - - expanded_accept_encoding = [] - - accept_encoding.each do |m, q| - preference = available_encodings.index(m) || available_encodings.size - - if m == "*" - (available_encodings - accept_encoding.map(&:first)).each do |m2| - expanded_accept_encoding << [m2, q, preference] - end - else - expanded_accept_encoding << [m, q, preference] - end - end - - encoding_candidates = expanded_accept_encoding - .sort_by { |_, q, p| [-q, p] } - .map!(&:first) - - unless encoding_candidates.include?("identity") - encoding_candidates.push("identity") - end - - expanded_accept_encoding.each do |m, q| - encoding_candidates.delete(m) if q == 0.0 - end - - (encoding_candidates & available_encodings)[0] - end - - # :call-seq: - # parse_cookies_header(value) -> hash - # - # Parse cookies from the provided header +value+ according to RFC6265. The - # syntax for cookie headers only supports semicolons. Returns a map of - # cookie +key+ to cookie +value+. - # - # parse_cookies_header('myname=myvalue; max-age=0') - # # => {"myname"=>"myvalue", "max-age"=>"0"} - # - def parse_cookies_header(value) - return {} unless value - - value.split(/; */n).each_with_object({}) do |cookie, cookies| - next if cookie.empty? - key, value = cookie.split('=', 2) - cookies[key] = (unescape(value) rescue value) unless cookies.key?(key) - end - end - - # :call-seq: - # parse_cookies(env) -> hash - # - # Parse cookies from the provided request environment using - # parse_cookies_header. Returns a map of cookie +key+ to cookie +value+. - # - # parse_cookies({'HTTP_COOKIE' => 'myname=myvalue'}) - # # => {'myname' => 'myvalue'} - # - def parse_cookies(env) - parse_cookies_header env[HTTP_COOKIE] - end - - # A valid cookie key according to RFC2616. - # A can be any US-ASCII characters, except control characters, spaces, or tabs. It also must not contain a separator character like the following: ( ) < > @ , ; : \ " / [ ] ? = { }. - VALID_COOKIE_KEY = /\A[!#$%&'*+\-\.\^_`|~0-9a-zA-Z]+\z/.freeze - private_constant :VALID_COOKIE_KEY - - private def escape_cookie_key(key) - if key =~ VALID_COOKIE_KEY - key - else - warn "Cookie key #{key.inspect} is not valid according to RFC2616; it will be escaped. This behaviour is deprecated and will be removed in a future version of Rack.", uplevel: 2 - escape(key) - end - end - - # :call-seq: - # set_cookie_header(key, value) -> encoded string - # - # Generate an encoded string using the provided +key+ and +value+ suitable - # for the +set-cookie+ header according to RFC6265. The +value+ may be an - # instance of either +String+ or +Hash+. - # - # If the cookie +value+ is an instance of +Hash+, it considers the following - # cookie attribute keys: +domain+, +max_age+, +expires+ (must be instance - # of +Time+), +secure+, +http_only+, +same_site+ and +value+. For more - # details about the interpretation of these fields, consult - # [RFC6265 Section 5.2](https://datatracker.ietf.org/doc/html/rfc6265#section-5.2). - # - # An extra cookie attribute +escape_key+ can be provided to control whether - # or not the cookie key is URL encoded. If explicitly set to +false+, the - # cookie key name will not be url encoded (escaped). The default is +true+. - # - # set_cookie_header("myname", "myvalue") - # # => "myname=myvalue" - # - # set_cookie_header("myname", {value: "myvalue", max_age: 10}) - # # => "myname=myvalue; max-age=10" - # - def set_cookie_header(key, value) - case value - when Hash - key = escape_cookie_key(key) unless value[:escape_key] == false - domain = "; domain=#{value[:domain]}" if value[:domain] - path = "; path=#{value[:path]}" if value[:path] - max_age = "; max-age=#{value[:max_age]}" if value[:max_age] - expires = "; expires=#{value[:expires].httpdate}" if value[:expires] - secure = "; secure" if value[:secure] - httponly = "; httponly" if (value.key?(:httponly) ? value[:httponly] : value[:http_only]) - same_site = - case value[:same_site] - when false, nil - nil - when :none, 'None', :None - '; samesite=none' - when :lax, 'Lax', :Lax - '; samesite=lax' - when true, :strict, 'Strict', :Strict - '; samesite=strict' - else - raise ArgumentError, "Invalid :same_site value: #{value[:same_site].inspect}" - end - partitioned = "; partitioned" if value[:partitioned] - value = value[:value] - else - key = escape_cookie_key(key) - end - - value = [value] unless Array === value - - return "#{key}=#{value.map { |v| escape v }.join('&')}#{domain}" \ - "#{path}#{max_age}#{expires}#{secure}#{httponly}#{same_site}#{partitioned}" - end - - # :call-seq: - # set_cookie_header!(headers, key, value) -> header value - # - # Append a cookie in the specified headers with the given cookie +key+ and - # +value+ using set_cookie_header. - # - # If the headers already contains a +set-cookie+ key, it will be converted - # to an +Array+ if not already, and appended to. - def set_cookie_header!(headers, key, value) - if header = headers[SET_COOKIE] - if header.is_a?(Array) - header << set_cookie_header(key, value) - else - headers[SET_COOKIE] = [header, set_cookie_header(key, value)] - end - else - headers[SET_COOKIE] = set_cookie_header(key, value) - end - end - - # :call-seq: - # delete_set_cookie_header(key, value = {}) -> encoded string - # - # Generate an encoded string based on the given +key+ and +value+ using - # set_cookie_header for the purpose of causing the specified cookie to be - # deleted. The +value+ may be an instance of +Hash+ and can include - # attributes as outlined by set_cookie_header. The encoded cookie will have - # a +max_age+ of 0 seconds, an +expires+ date in the past and an empty - # +value+. When used with the +set-cookie+ header, it will cause the client - # to *remove* any matching cookie. - # - # delete_set_cookie_header("myname") - # # => "myname=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" - # - def delete_set_cookie_header(key, value = {}) - set_cookie_header(key, value.merge(max_age: '0', expires: Time.at(0), value: '')) - end - - def delete_cookie_header!(headers, key, value = {}) - headers[SET_COOKIE] = delete_set_cookie_header!(headers[SET_COOKIE], key, value) - - return nil - end - - # :call-seq: - # delete_set_cookie_header!(header, key, value = {}) -> header value - # - # Set an expired cookie in the specified headers with the given cookie - # +key+ and +value+ using delete_set_cookie_header. This causes - # the client to immediately delete the specified cookie. - # - # delete_set_cookie_header!(nil, "mycookie") - # # => "mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" - # - # If the header is non-nil, it will be modified in place. - # - # header = [] - # delete_set_cookie_header!(header, "mycookie") - # # => ["mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"] - # header - # # => ["mycookie=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"] - # - def delete_set_cookie_header!(header, key, value = {}) - if header - header = Array(header) - header << delete_set_cookie_header(key, value) - else - header = delete_set_cookie_header(key, value) - end - - return header - end - - def rfc2822(time) - time.rfc2822 - end - - # Parses the "Range:" header, if present, into an array of Range objects. - # Returns nil if the header is missing or syntactically invalid. - # Returns an empty array if none of the ranges are satisfiable. - def byte_ranges(env, size) - get_byte_ranges env['HTTP_RANGE'], size - end - - def get_byte_ranges(http_range, size) - # See - # Ignore Range when file size is 0 to avoid a 416 error. - return nil if size.zero? - return nil unless http_range && http_range =~ /bytes=([^;]+)/ - ranges = [] - $1.split(/,\s*/).each do |range_spec| - return nil unless range_spec.include?('-') - range = range_spec.split('-') - r0, r1 = range[0], range[1] - if r0.nil? || r0.empty? - return nil if r1.nil? - # suffix-byte-range-spec, represents trailing suffix of file - r0 = size - r1.to_i - r0 = 0 if r0 < 0 - r1 = size - 1 - else - r0 = r0.to_i - if r1.nil? - r1 = size - 1 - else - r1 = r1.to_i - return nil if r1 < r0 # backwards range is syntactically invalid - r1 = size - 1 if r1 >= size - end - end - ranges << (r0..r1) if r0 <= r1 - end - - return [] if ranges.map(&:size).sum > size - - ranges - end - - # :nocov: - if defined?(OpenSSL.fixed_length_secure_compare) - # Constant time string comparison. - # - # NOTE: the values compared should be of fixed length, such as strings - # that have already been processed by HMAC. This should not be used - # on variable length plaintext strings because it could leak length info - # via timing attacks. - def secure_compare(a, b) - return false unless a.bytesize == b.bytesize - - OpenSSL.fixed_length_secure_compare(a, b) - end - # :nocov: - else - def secure_compare(a, b) - return false unless a.bytesize == b.bytesize - - l = a.unpack("C*") - - r, i = 0, -1 - b.each_byte { |v| r |= v ^ l[i += 1] } - r == 0 - end - end - - # Context allows the use of a compatible middleware at different points - # in a request handling stack. A compatible middleware must define - # #context which should take the arguments env and app. The first of which - # would be the request environment. The second of which would be the rack - # application that the request would be forwarded to. - class Context - attr_reader :for, :app - - def initialize(app_f, app_r) - raise 'running context does not respond to #context' unless app_f.respond_to? :context - @for, @app = app_f, app_r - end - - def call(env) - @for.context(env, @app) - end - - def recontext(app) - self.class.new(@for, app) - end - - def context(env, app = @app) - recontext(app).call(env) - end - end - - # Every standard HTTP code mapped to the appropriate message. - # Generated with: - # curl -s https://www.iana.org/assignments/http-status-codes/http-status-codes-1.csv \ - # | ruby -rcsv -e "puts CSV.parse(STDIN, headers: true) \ - # .reject {|v| v['Description'] == 'Unassigned' or v['Description'].include? '(' } \ - # .map {|v| %Q/#{v['Value']} => '#{v['Description']}'/ }.join(','+?\n)" - HTTP_STATUS_CODES = { - 100 => 'Continue', - 101 => 'Switching Protocols', - 102 => 'Processing', - 103 => 'Early Hints', - 200 => 'OK', - 201 => 'Created', - 202 => 'Accepted', - 203 => 'Non-Authoritative Information', - 204 => 'No Content', - 205 => 'Reset Content', - 206 => 'Partial Content', - 207 => 'Multi-Status', - 208 => 'Already Reported', - 226 => 'IM Used', - 300 => 'Multiple Choices', - 301 => 'Moved Permanently', - 302 => 'Found', - 303 => 'See Other', - 304 => 'Not Modified', - 305 => 'Use Proxy', - 307 => 'Temporary Redirect', - 308 => 'Permanent Redirect', - 400 => 'Bad Request', - 401 => 'Unauthorized', - 402 => 'Payment Required', - 403 => 'Forbidden', - 404 => 'Not Found', - 405 => 'Method Not Allowed', - 406 => 'Not Acceptable', - 407 => 'Proxy Authentication Required', - 408 => 'Request Timeout', - 409 => 'Conflict', - 410 => 'Gone', - 411 => 'Length Required', - 412 => 'Precondition Failed', - 413 => 'Content Too Large', - 414 => 'URI Too Long', - 415 => 'Unsupported Media Type', - 416 => 'Range Not Satisfiable', - 417 => 'Expectation Failed', - 421 => 'Misdirected Request', - 422 => 'Unprocessable Content', - 423 => 'Locked', - 424 => 'Failed Dependency', - 425 => 'Too Early', - 426 => 'Upgrade Required', - 428 => 'Precondition Required', - 429 => 'Too Many Requests', - 431 => 'Request Header Fields Too Large', - 451 => 'Unavailable For Legal Reasons', - 500 => 'Internal Server Error', - 501 => 'Not Implemented', - 502 => 'Bad Gateway', - 503 => 'Service Unavailable', - 504 => 'Gateway Timeout', - 505 => 'HTTP Version Not Supported', - 506 => 'Variant Also Negotiates', - 507 => 'Insufficient Storage', - 508 => 'Loop Detected', - 511 => 'Network Authentication Required' - } - - # Responses with HTTP status codes that should not have an entity body - STATUS_WITH_NO_ENTITY_BODY = Hash[((100..199).to_a << 204 << 304).product([true])] - - SYMBOL_TO_STATUS_CODE = Hash[*HTTP_STATUS_CODES.map { |code, message| - [message.downcase.gsub(/\s|-/, '_').to_sym, code] - }.flatten] - - OBSOLETE_SYMBOLS_TO_STATUS_CODES = { - payload_too_large: 413, - unprocessable_entity: 422, - bandwidth_limit_exceeded: 509, - not_extended: 510 - }.freeze - private_constant :OBSOLETE_SYMBOLS_TO_STATUS_CODES - - OBSOLETE_SYMBOL_MAPPINGS = { - payload_too_large: :content_too_large, - unprocessable_entity: :unprocessable_content - }.freeze - private_constant :OBSOLETE_SYMBOL_MAPPINGS - - def status_code(status) - if status.is_a?(Symbol) - SYMBOL_TO_STATUS_CODE.fetch(status) do - fallback_code = OBSOLETE_SYMBOLS_TO_STATUS_CODES.fetch(status) { raise ArgumentError, "Unrecognized status code #{status.inspect}" } - message = "Status code #{status.inspect} is deprecated and will be removed in a future version of Rack." - if canonical_symbol = OBSOLETE_SYMBOL_MAPPINGS[status] - # message = "#{message} Please use #{canonical_symbol.inspect} instead." - # For now, let's not emit any warning when there is a mapping. - else - warn message, uplevel: 3 - end - fallback_code - end - else - status.to_i - end - end - - PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact) - - def clean_path_info(path_info) - parts = path_info.split PATH_SEPS - - clean = [] - - parts.each do |part| - next if part.empty? || part == '.' - part == '..' ? clean.pop : clean << part - end - - clean_path = clean.join(::File::SEPARATOR) - clean_path.prepend("/") if parts.empty? || parts.first.empty? - clean_path - end - - NULL_BYTE = "\0" - - def valid_path?(path) - path.valid_encoding? && !path.include?(NULL_BYTE) - end - - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/version.rb b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/version.rb deleted file mode 100644 index 5b45e76..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/lib/rack/version.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -# Copyright (C) 2007-2019 Leah Neukirchen -# -# Rack is freely distributable under the terms of an MIT-style license. -# See MIT-LICENSE or https://opensource.org/licenses/MIT. - -# The Rack main module, serving as a namespace for all core Rack -# modules and classes. -# -# All modules meant for use in your application are autoloaded here, -# so it should be enough just to require 'rack' in your code. - -module Rack - RELEASE = "3.1.8" - - # Return the Rack release as a dotted string. - def self.release - RELEASE - end -end diff --git a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/rack.gemspec b/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/rack.gemspec deleted file mode 100644 index ed37415..0000000 --- a/spikes/gem-checksums/stale-checksum-v1-bug/before/vendored/rack-3.1.8/rack.gemspec +++ /dev/null @@ -1,31 +0,0 @@ -# -*- encoding: utf-8 -*- -# stub: rack 3.1.8 ruby lib - -Gem::Specification.new do |s| - s.name = "rack".freeze - s.version = "3.1.8".freeze - - s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= - s.metadata = { "bug_tracker_uri" => "https://github.com/rack/rack/issues", "changelog_uri" => "https://github.com/rack/rack/blob/main/CHANGELOG.md", "documentation_uri" => "https://rubydoc.info/github/rack/rack", "source_code_uri" => "https://github.com/rack/rack" } if s.respond_to? :metadata= - s.require_paths = ["lib".freeze] - s.authors = ["Leah Neukirchen".freeze] - s.date = "2024-10-14" - s.description = "Rack provides a minimal, modular and adaptable interface for developing\nweb applications in Ruby. By wrapping HTTP requests and responses in\nthe simplest way possible, it unifies and distills the API for web\nservers, web frameworks, and software in between (the so-called\nmiddleware) into a single method call.\n".freeze - s.email = "leah@vuxu.org".freeze - s.extra_rdoc_files = ["README.md".freeze, "CHANGELOG.md".freeze, "CONTRIBUTING.md".freeze] - s.files = ["CHANGELOG.md".freeze, "CONTRIBUTING.md".freeze, "README.md".freeze] - s.homepage = "https://github.com/rack/rack".freeze - s.licenses = ["MIT".freeze] - s.required_ruby_version = Gem::Requirement.new(">= 2.4.0".freeze) - s.rubygems_version = "3.5.11".freeze - s.summary = "A modular Ruby webserver interface.".freeze - - s.installed_by_version = "3.5.22".freeze - - s.specification_version = 4 - - s.add_development_dependency(%q.freeze, ["~> 5.0".freeze]) - s.add_development_dependency(%q.freeze, [">= 0".freeze]) - s.add_development_dependency(%q.freeze, [">= 0".freeze]) - s.add_development_dependency(%q.freeze, [">= 0".freeze]) -end diff --git a/spikes/pdm/README.md b/spikes/pdm/README.md deleted file mode 100644 index 69f9732..0000000 --- a/spikes/pdm/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# pdm vendor spike fixtures - -Tool versions (exact, recorded from the spike run on 2026-06-10): - -- **pdm 2.27.0** (installed via `python3 -m venv /tmp/pdmv && /tmp/pdmv/bin/pip install pdm`) -- **Python 3.14.3** (Homebrew, macOS arm64); `pdm init -n` pinned `requires-python = "==3.14.*"` -- Lockfile format: `lock_version = "4.5.0"`, default `strategy = ["inherit_metadata"]` - -Patched artifact: `six-1.16.0-py2.py3-none-any.whl` rebuilt from the PyPI wheel with a -marker comment appended to `six.py` -(`# socket-patch 9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f: patched six.py`) and the -dist-info `RECORD` rewritten (urlsafe-b64 sha256 + size). Vendored at -`.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl`. - -- patched wheel sha256: `7015f5a42a0f83fd1b7d3ca0ba10d8777a207c19b6ffebb39e2e1c03af6a281b` -- original PyPI wheel sha256: `8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254` - -Every `after/pdm.lock` in these pairs was generated by pdm itself (never hand-written). -Committable files only (`pyproject.toml`, `pdm.lock`, `.socket/`); no `.venv`/`.pdm-python`. - -## Pairs - -### direct-registry/ -- `before/`: fresh `pdm init -n` project (no dependency, no lock yet). -- `after/`: state after `pdm add six==1.16.0`. Shows the **registry lock shape**: no - `path` key; `files = [{file = "...", hash = "sha256:..."}]` listing the PyPI wheel - **and** sdist hashes. - -### direct-path-wheel/ -- `before/`: registry state (`pdm add six==1.16.0`). -- `after/`: state after - `pdm add ./.socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl`. - This is the D1 oracle shape: - - pyproject dependency becomes `file:///${PROJECT_ROOT}/.socket/...whl` - (NOTE: pdm **keeps** the old `"six==1.16.0"` entry alongside — both coexist). - - lock `[[package]]` gains `path = "./.socket/vendor/pypi//...whl"` - (project-relative, no `${PROJECT_ROOT}` in the lock) and `files = []` shrinks to a - single `{file = "", hash = "sha256:"}` entry. - -### transitive-registry/ -- `before/`: fresh `pdm init -n` project. -- `after/`: state after `pdm add python-dateutil==2.9.0.post0`. six is transitive and - pdm resolved it to **1.17.0** from the registry (latest matching `six>=1.5`). - -### transitive-path/ -- `before/`: registry state (`pdm add python-dateutil==2.9.0.post0`, six 1.17.0 transitive). -- `after/`: pyproject gains - ```toml - [tool.pdm.resolution.overrides] - six = "file:///${PROJECT_ROOT}/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" - ``` - then `pdm lock` regenerates the lock: six pinned to 1.16.0 with the exact same - `path = "./..."` + single-hash `files` shape as direct-path-wheel. `pdm sync` in a - fresh venv with a cold `PDM_CACHE_DIR` installs the patched wheel (marker verified), - and `python-dateutil` imports it fine. - -## Key behavioral findings (pdm 2.27.0) - -- **Lock-only splice works**: with pyproject left as a plain registry requirement - (`six==1.16.0`), hand-editing only the six `[[package]]` entry to the path shape - (content_hash untouched) passes `pdm sync`, `pdm install --check`, and - `pdm install --frozen-lockfile` (all exit 0), installs the marker, and leaves the - lock byte-identical. Same result for the transitive case and for a - `--static-urls` lock (a `{file=..., hash=...}` entry is accepted inside a - `strategy = [..., "static_urls"]` lock). -- **Hash check is fail-closed for local path wheels**: tampering the vendored wheel - makes `pdm sync` fail with `unearth.errors.HashMismatchError` - (Expected/Actual sha256 printed), exit 1, package not installed. -- **Silent unpatch (lock-only splice)**: `pdm lock` rewrites the entry back to the - registry shape with no warning; `pdm update six` re-locks AND reinstalls the - unpatched wheel. Plain `pdm install` preserves (resolves from lockfile; lock - byte-stable because content_hash matches). -- **Override survives everything**: with `[tool.pdm.resolution.overrides]` in - pyproject, `pdm lock` and `pdm update six` keep the path entry byte-identically — - this closes the silent-unpatch hole and is itself the tool-generated route to a - transitive path lock. Note overrides change `content_hash` (they feed resolution). -- **Flags on this version**: `pdm install --check`, `pdm install --frozen-lockfile` - (alias `--no-lock`). `pdm lock --static-urls` works (deprecated alias of - `-S static_urls`). `pdm lock --no-hashes` does **not** exist; `-S no_hashes` → - `[PdmUsageError]: Invalid strategy flag: hashes, supported: cross_platform, - static_urls, direct_minimal_versions, inherit_metadata`. -- `content_hash` covers requirements only (identical between default and - static-urls locks of the same pyproject), so lock-entry splices don't invalidate it. diff --git a/spikes/pdm/direct-path-wheel/after/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/pdm/direct-path-wheel/after/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index 2e6888ddc907d33d32239a84ea11080d3cb0b9b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38806 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.5", -] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[[package]] -name = "six" -version = "1.16.0" -requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -path = "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" -summary = "Python 2 and 3 compatibility utilities" -groups = ["default"] -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:7015f5a42a0f83fd1b7d3ca0ba10d8777a207c19b6ffebb39e2e1c03af6a281b"}, -] diff --git a/spikes/pdm/transitive-path/after/pyproject.toml b/spikes/pdm/transitive-path/after/pyproject.toml deleted file mode 100644 index 4a10e79..0000000 --- a/spikes/pdm/transitive-path/after/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "transitive-path" -version = "0.1.0" -description = "Default template for PDM package" -authors = [ - {name = "Mikola Lysenko",email = "mikolalysenko@gmail.com"}, -] -dependencies = ["python-dateutil==2.9.0.post0"] -requires-python = "==3.14.*" -readme = "README.md" -license = {text = "MIT"} - - -[tool.pdm] -distribution = false - -[tool.pdm.resolution.overrides] -six = "file:///${PROJECT_ROOT}/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" diff --git a/spikes/pdm/transitive-path/before/pdm.lock b/spikes/pdm/transitive-path/before/pdm.lock deleted file mode 100644 index f9bc582..0000000 --- a/spikes/pdm/transitive-path/before/pdm.lock +++ /dev/null @@ -1,36 +0,0 @@ -# This file is @generated by PDM. -# It is not intended for manual editing. - -[metadata] -groups = ["default"] -strategy = ["inherit_metadata"] -lock_version = "4.5.0" -content_hash = "sha256:b35b8b182ba39eb4b0e832cc853dd574342a4a4cb9ed441209d23928a52ae106" - -[[metadata.targets]] -requires_python = "==3.14.*" - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -summary = "Extensions to the standard Python datetime module" -groups = ["default"] -dependencies = [ - "six>=1.5", -] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[[package]] -name = "six" -version = "1.17.0" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -summary = "Python 2 and 3 compatibility utilities" -groups = ["default"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] diff --git a/spikes/pdm/transitive-path/before/pyproject.toml b/spikes/pdm/transitive-path/before/pyproject.toml deleted file mode 100644 index ccaa54f..0000000 --- a/spikes/pdm/transitive-path/before/pyproject.toml +++ /dev/null @@ -1,15 +0,0 @@ -[project] -name = "transitive-path" -version = "0.1.0" -description = "Default template for PDM package" -authors = [ - {name = "Mikola Lysenko",email = "mikolalysenko@gmail.com"}, -] -dependencies = ["python-dateutil==2.9.0.post0"] -requires-python = "==3.14.*" -readme = "README.md" -license = {text = "MIT"} - - -[tool.pdm] -distribution = false diff --git a/spikes/pdm/transitive-registry/after/pdm.lock b/spikes/pdm/transitive-registry/after/pdm.lock deleted file mode 100644 index f9bc582..0000000 --- a/spikes/pdm/transitive-registry/after/pdm.lock +++ /dev/null @@ -1,36 +0,0 @@ -# This file is @generated by PDM. -# It is not intended for manual editing. - -[metadata] -groups = ["default"] -strategy = ["inherit_metadata"] -lock_version = "4.5.0" -content_hash = "sha256:b35b8b182ba39eb4b0e832cc853dd574342a4a4cb9ed441209d23928a52ae106" - -[[metadata.targets]] -requires_python = "==3.14.*" - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -summary = "Extensions to the standard Python datetime module" -groups = ["default"] -dependencies = [ - "six>=1.5", -] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[[package]] -name = "six" -version = "1.17.0" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -summary = "Python 2 and 3 compatibility utilities" -groups = ["default"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] diff --git a/spikes/pdm/transitive-registry/after/pyproject.toml b/spikes/pdm/transitive-registry/after/pyproject.toml deleted file mode 100644 index 90f6379..0000000 --- a/spikes/pdm/transitive-registry/after/pyproject.toml +++ /dev/null @@ -1,15 +0,0 @@ -[project] -name = "transitive-registry" -version = "0.1.0" -description = "Default template for PDM package" -authors = [ - {name = "Mikola Lysenko",email = "mikolalysenko@gmail.com"}, -] -dependencies = ["python-dateutil==2.9.0.post0"] -requires-python = "==3.14.*" -readme = "README.md" -license = {text = "MIT"} - - -[tool.pdm] -distribution = false diff --git a/spikes/pdm/transitive-registry/before/pyproject.toml b/spikes/pdm/transitive-registry/before/pyproject.toml deleted file mode 100644 index 069d1fc..0000000 --- a/spikes/pdm/transitive-registry/before/pyproject.toml +++ /dev/null @@ -1,15 +0,0 @@ -[project] -name = "transitive-registry" -version = "0.1.0" -description = "Default template for PDM package" -authors = [ - {name = "Mikola Lysenko",email = "mikolalysenko@gmail.com"}, -] -dependencies = [] -requires-python = "==3.14.*" -readme = "README.md" -license = {text = "MIT"} - - -[tool.pdm] -distribution = false diff --git a/spikes/pipenv/README.md b/spikes/pipenv/README.md deleted file mode 100644 index 66a9908..0000000 --- a/spikes/pipenv/README.md +++ /dev/null @@ -1,123 +0,0 @@ -# pipenv vendor-v2 spike fixtures - -Tool versions used to generate every `Pipfile.lock` in this tree: - -- pipenv **2026.6.2** (installed via `python3 -m venv /tmp/pev && /tmp/pev/bin/pip install pipenv`) -- pip **26.0** (driving pipenv; pipenv vendors/patches its own pip internally) -- Python **3.14.3** (CPython, macOS arm64, Homebrew); virtualenv seeded pip **26.1.1** into project venvs -- Lock/install runs used `PIPENV_VENV_IN_PROJECT=1` and fresh (cold) `PIPENV_CACHE_DIR` / `PIP_CACHE_DIR` per run - -Vendored artifact convention under test: a patched wheel at -`.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl`. -The patch appends to `six.py`: - -```python -# socket-patch-marker: 9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f -SOCKET_PATCH_MARKER = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f" -``` - -with `RECORD` regenerated (correct per-file sha256/size), rebuilt with `zipfile` (deterministic -timestamps). Hashes (see `artifacts/SHA256SUMS`): - -- original registry wheel: `8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254` -- patched wheel: `573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0` -- six 1.16.0 sdist (registry, appears in pipenv-generated locks): `1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926` - -Every `Pipfile.lock` file in the pair directories was generated by `pipenv lock` itself -(never hand-written). The `Pipfile.lock.lock-only-edit` files are the ONE exception and are -clearly suffixed: they are the registry lock with ONLY the `default.six` entry hand-replaced -(JSON emitted with `json.dumps(obj, indent=4, sort_keys=True) + "\n"`, which byte-matches -pipenv's own serializer — verified by re-rendering a pipenv-written lock). - -## Pairs - -### direct-registry/ -`Pipfile` pins `six = "==1.16.0"` from PyPI; `Pipfile.lock` is the pristine `pipenv lock` -output. Baseline "before" state for the lock-only patch flow. six entry has -`hashes` (registry sdist+wheel), `index: "pypi"`, `markers`, `version: "==1.16.0"`. - -### direct-file/ -`Pipfile` declares `six = {file = "./.socket/vendor/pypi//six-1.16.0-py2.py3-none-any.whl"}`. -`Pipfile.lock` is the `pipenv lock` output for it. Shows the V1 lock shape verbatim: - -```json -"six": { - "file": "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'" -} -``` - -Key facts: key is `file` (not `path`), `./` prefix kept, NO `version` key, NO `index` key, -and — surprise — `hashes` are the **PyPI registry hashes** (sdist + original wheel), not the -local patched wheel's hash. pipenv parsed name/version from the wheel filename and fetched -index hashes. `pipenv sync` still installs the local (patched) wheel and exits 0, because -file-ref entries are installed via a separate pip invocation that never passes hashes (see -tamper note below). - -`Pipfile.lock.lock-only-edit` (hand-edited): the direct-registry lock with only `default.six` -replaced by the file-ref shape and `hashes` set to the patched wheel's sha256, `index`/`version` -dropped, `markers` kept, `_meta` untouched. From a fresh checkout + cold caches, -`pipenv sync`, `pipenv install --deploy`, and `pipenv verify` all exit 0, the venv imports the -marker, and the lock stays byte-identical. - -### transitive-registry/ -`Pipfile` pins `python-dateutil = "==2.8.2"`; `Pipfile.lock` is pristine `pipenv lock` output. -six appears FLAT in `default` (resolved to 1.17.0, with `hashes`+`markers`+`version` but no -`index` key — transitive entries omit `index`). Baseline for the transitive lock-only flow. - -### transitive-file/ -`Pipfile` keeps `python-dateutil = "==2.8.2"` and PROMOTES six to a direct file ref -(`six = {file = "./.socket/vendor/pypi//..."}`); `Pipfile.lock` is the `pipenv lock` -output: dateutil keeps its registry entry, six gets the same file-ref shape as direct-file -(again with registry hashes). `pipenv sync` installs the patched 1.16.0 wheel next to -dateutil 2.8.2 and `import dateutil.parser` works. - -`Pipfile.lock.lock-only-edit` (hand-edited): the transitive-registry lock with only -`default.six` swapped to the vendored file ref (1.17.0 registry entry -> patched 1.16.0 wheel). -Fresh `pipenv sync` / `install --deploy` / `verify` all exit 0, marker present, lock -byte-stable. - -## Behavioral findings (verbatim-tested with the versions above) - -1. **No hash verification for `file` entries (tamper NOT caught).** pipenv installs file-ref - lock entries in a separate "Editable Requirements" install phase; the temp requirements - line is just `./.socket/...whl ; ` — no `--hash`, no `--require-hashes`: - - ``` - Writing supplied requirement line to temporary file: - "./.socket/vendor/pypi/9f6b2c4e-.../six-1.16.0-py2.py3-none-any.whl ; python_version >= '2.7' ..." - Install Phase: Editable Requirements - $ .venv/bin/python .../pip install -i https://pypi.org/simple --no-input --upgrade --no-deps -r /tmp/...-reqs.txt - ``` - - A tampered wheel (sha256 `7c7da793...`, lock said `573ecfcc...`) installed cleanly: - `pipenv sync` exit 0, `pipenv install --deploy` exit 0, `pipenv verify` exit 0, and the - tampered code was importable. The `hashes` array on a `file` entry is decorative. - Raw pip CAN verify local wheels (`pip install --require-hashes -r req.txt` with - ` --hash=sha256:...` fails closed with "THESE PACKAGES DO NOT MATCH THE HASHES"); - pipenv simply never engages that path for file refs. - -2. **Relative `file` refs resolve against the Pipfile's directory, not CWD.** Verified by - running `pipenv sync` from a project subdirectory (works, no `.socket` at CWD) and from an - unrelated directory via `PIPENV_PIPFILE=/abs/path/Pipfile` with a decoy wheel planted at - `$CWD/.socket/vendor/pypi//` — the project's wheel (marker `9f6b2c4e...`) was - installed, never the decoy. - -3. **Silent unpatch matrix** (starting from the lock-only-edited state): - - `pipenv lock` — REGENERATES: six entry reverts to the registry entry; vendored ref gone. - - `pipenv update six` — REGENERATES and worse: rewrites the Pipfile pin `six = "==1.16.0"` - to `six = "*"`, then locks/installs registry six 1.17.0. - - bare `pipenv install` — lock UNCHANGED (its `_meta` hash still matches the Pipfile), the - vendored patched wheel stays installed. - -4. **Lock serializer**: byte-identical to `json.dumps(obj, indent=4, sort_keys=True) + "\n"` - (4-space indent, all keys sorted at every level, single trailing newline, default - separators). After `pipenv sync`/`install --deploy`/`verify`, the lock's sha256 is - unchanged. - -5. **`pipenv verify` / `--deploy`** only compare `_meta.hash` (derived from the Pipfile) - against the lock; editing only `default.*` keeps both green. diff --git a/spikes/pipenv/artifacts/SHA256SUMS b/spikes/pipenv/artifacts/SHA256SUMS deleted file mode 100644 index a68744a..0000000 --- a/spikes/pipenv/artifacts/SHA256SUMS +++ /dev/null @@ -1,2 +0,0 @@ -8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 original-six-1.16.0-py2.py3-none-any.whl -573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0 patched-six-1.16.0-py2.py3-none-any.whl diff --git a/spikes/pipenv/artifacts/original-six-1.16.0-py2.py3-none-any.whl b/spikes/pipenv/artifacts/original-six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index fd942658a2f748ba433dd8632abb910a416e184f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11053 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 diff --git a/spikes/pipenv/artifacts/patched-six-1.16.0-py2.py3-none-any.whl b/spikes/pipenv/artifacts/patched-six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index f9c9723259e022ecc8fc283cd5138ce7c797dd02..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38859 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 '2.7' and python_version not in '3.0, 3.1, 3.2'" - } - }, - "develop": {} -} diff --git a/spikes/pipenv/direct-file/Pipfile.lock.lock-only-edit b/spikes/pipenv/direct-file/Pipfile.lock.lock-only-edit deleted file mode 100644 index 394c711..0000000 --- a/spikes/pipenv/direct-file/Pipfile.lock.lock-only-edit +++ /dev/null @@ -1,28 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "55f44fe4c8bc29094f3076c7eddb912ca00f80c016020ffa2bcbd67ccc7114a1" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.14" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "six": { - "file": "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", - "hashes": [ - "sha256:573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'" - } - }, - "develop": {} -} diff --git a/spikes/pipenv/direct-registry/Pipfile b/spikes/pipenv/direct-registry/Pipfile deleted file mode 100644 index 792fe33..0000000 --- a/spikes/pipenv/direct-registry/Pipfile +++ /dev/null @@ -1,10 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -six = "==1.16.0" - -[requires] -python_version = "3.14" diff --git a/spikes/pipenv/direct-registry/Pipfile.lock b/spikes/pipenv/direct-registry/Pipfile.lock deleted file mode 100644 index 7b6cd8d..0000000 --- a/spikes/pipenv/direct-registry/Pipfile.lock +++ /dev/null @@ -1,30 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "55f44fe4c8bc29094f3076c7eddb912ca00f80c016020ffa2bcbd67ccc7114a1" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.14" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "six": { - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", - "version": "==1.16.0" - } - }, - "develop": {} -} diff --git a/spikes/pipenv/transitive-file/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/pipenv/transitive-file/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index f9c9723259e022ecc8fc283cd5138ce7c797dd02..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38859 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 '2.7' and python_version not in '3.0, 3.1, 3.2'", - "version": "==2.8.2" - }, - "six": { - "file": "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'" - } - }, - "develop": {} -} diff --git a/spikes/pipenv/transitive-file/Pipfile.lock.lock-only-edit b/spikes/pipenv/transitive-file/Pipfile.lock.lock-only-edit deleted file mode 100644 index c6b652a..0000000 --- a/spikes/pipenv/transitive-file/Pipfile.lock.lock-only-edit +++ /dev/null @@ -1,37 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "58546015c76e8085bff3be981f626feed276df866834bb057ab1c118de09ff77" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.14" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "python-dateutil": { - "hashes": [ - "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", - "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" - ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", - "version": "==2.8.2" - }, - "six": { - "file": "./.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl", - "hashes": [ - "sha256:573ecfcc2c1f54aeb4e3d6198d58069a3a3258a5a2b18906aae2761a4b2568a0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'" - } - }, - "develop": {} -} diff --git a/spikes/pipenv/transitive-registry/Pipfile b/spikes/pipenv/transitive-registry/Pipfile deleted file mode 100644 index 7c24f2f..0000000 --- a/spikes/pipenv/transitive-registry/Pipfile +++ /dev/null @@ -1,10 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -python-dateutil = "==2.8.2" - -[requires] -python_version = "3.14" diff --git a/spikes/pipenv/transitive-registry/Pipfile.lock b/spikes/pipenv/transitive-registry/Pipfile.lock deleted file mode 100644 index 8fcb22c..0000000 --- a/spikes/pipenv/transitive-registry/Pipfile.lock +++ /dev/null @@ -1,38 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "58546015c76e8085bff3be981f626feed276df866834bb057ab1c118de09ff77" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.14" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "python-dateutil": { - "hashes": [ - "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", - "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" - ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", - "version": "==2.8.2" - }, - "six": { - "hashes": [ - "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", - "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", - "version": "==1.17.0" - } - }, - "develop": {} -} diff --git a/spikes/pnpm/README.md b/spikes/pnpm/README.md deleted file mode 100644 index 210adb0..0000000 --- a/spikes/pnpm/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# pnpm vendor v2 spike fixtures - -Mechanism under test: root `package.json` -> `"pnpm": {"overrides": {"left-pad@1.3.0": "file:.socket/vendor/npm//left-pad-1.3.0.tgz"}}`. - -## Exact tool versions - -- node v24.12.0, corepack 0.34.5 (macOS, Darwin 25.5.0) -- pnpm 9.15.9 (via `corepack pnpm@9`; also verified resolvable via `"packageManager": "pnpm@9.15.9"`) -- pnpm 10.34.1 (via `corepack pnpm@10`; also verified via `"packageManager": "pnpm@10.34.1"`) -- All installs used isolated `--store-dir` + `XDG_CACHE_HOME`/`XDG_DATA_HOME`/`XDG_STATE_HOME` (cold caches). - -**Both majors emit byte-identical `lockfileVersion: '9.0'` lockfiles in every scenario tested.** -Each pair below therefore has a single `after/pnpm-lock.yaml` valid for both; every `after` lock -was generated by `pnpm install` itself (never hand-written). The hand-edited locks produced by -`edit_lock.py` were verified byte-identical to the pnpm-generated ones before being trusted. - -## Vendored artifact - -`.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz` -- registry left-pad-1.3.0.tgz, `package/index.js` prepended with - `/* SOCKET_PATCHED 9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f left-pad@1.3.0 */`, repacked with `tar -czf` (entries under `package/`). -- patched sha512 (lock integrity): `sha512-VR8nCbFxvOcFX5Rxku2psjaj0+xzKdzFkcuqZJSHf597bMVomG100t6+cJkMBFRLhyVdSVwufbCwVzlCzZkUwg==` -- pristine sha512 (registry): `sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==` - -## Pairs - -### p1-multi-dep/ (P1, P2, P3, P5b, P6) -Direct `left-pad@1.3.0`, transitive via `consumer` (`file:` dir dep with `left-pad: ^1.3.0`), -scoping via `left-pad-old: npm:left-pad@1.2.0`. -- `before/`: pristine project + pnpm-generated registry lock. -- `after/`: override added to package.json + pnpm-generated lock + `.socket` tarball. -- Lock shape changes (the full surgical diff, see `edit_lock.py`): - 1. `overrides:` section inserted after `settings:`. - 2. Root importer `left-pad` **specifier AND version both rewritten** to - `file:.socket/vendor/npm//left-pad-1.3.0.tgz` (specifier does NOT stay `1.3.0`). - 3. `packages:` key `left-pad@1.3.0` -> `left-pad@file:.tgz`; resolution becomes - `{integrity: , tarball: file:.tgz}` (BOTH keys) plus a new - top-level `version: 1.3.0` line; the registry entry's `deprecated:` line is dropped. - 4. `snapshots:` key rekeyed the same way; dependents reference it as bare - `left-pad: file:.tgz` (no `name@` prefix) in their `dependencies`. - 5. `left-pad@1.2.0` entries untouched (scoping). -- P2: edited-by-script lock + package.json == pnpm-generated bytes; `pnpm install --frozen-lockfile` - (fresh store) exit 0, marker installed (direct + transitive), subsequent plain `pnpm install` - leaves lock byte-identical (sha256 `a7c36d374de4c705bdb43d7aee42d944656a3b0d9c5d2c08c5b41664d23ee156`). -- P3 (lock edited, package.json NOT): frozen install fails - `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH Cannot proceed with the frozen installation. The current "overrides" configuration doesn't match the value found in the lockfile` - (exit 1); plain install silently re-resolves, removes the `overrides:` section and installs - registry bytes (no warning that the patch was dropped). -- P5b: store pre-warmed with registry left-pad@1.3.0 does not shadow the patch; patched bytes still installed. - -### p4-single-dep-offline/ (P4, P5a) -Single dependency `left-pad: 1.3.0` + override. -- Fresh-checkout simulation: ONLY `package.json` + `pnpm-lock.yaml` + `.socket/` copied to an empty - dir; empty store; `pnpm install --frozen-lockfile --offline` -> exit 0, patched bytes. Both majors. -- P5a tamper: one byte flipped mid-tarball -> frozen install exit 1, nothing installed: - `ERR_PNPM_TARBALL_INTEGRITY Got unexpected checksum for ".../.socket/vendor/npm//left-pad-1.3.0.tgz". Wanted "sha512-VR8nCb...". Got "sha512-FaR7sF..."` - (pnpm 10's message additionally suggests `pnpm install --update-checksums`.) - -### p7-workspace/ (P7) -`pnpm-workspace.yaml` (`packages/*`), `packages/app` depends on `left-pad: ^1.3.0`, -override lives only in the ROOT package.json `pnpm.overrides`. -- Sub-importer fragment (note the per-importer re-relativized specifier vs root-relative version): - ```yaml - packages/app: - dependencies: - left-pad: - specifier: file:../../.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz - version: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz - ``` -- `packages/app/package.json` is NOT rewritten (still `^1.3.0`); frozen install passes; app gets patched bytes. - -## edit_lock.py - -The exact surgical transformation (package.json + 4 lock text edits) used in P2; integrity is -computed from the vendored tarball bytes (sha512, base64). Output verified byte-identical to -pnpm's own lock for both majors before any install was run against it. diff --git a/spikes/pnpm/edit_lock.py b/spikes/pnpm/edit_lock.py deleted file mode 100644 index 90de5c6..0000000 --- a/spikes/pnpm/edit_lock.py +++ /dev/null @@ -1,51 +0,0 @@ -import sys, base64, hashlib, json - -proj = sys.argv[1] -rel = ".socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" -spec = "file:" + rel - -# integrity computed from the actual vendored tarball (what the tool would do) -with open(f"{proj}/{rel}", "rb") as f: - integ = "sha512-" + base64.b64encode(hashlib.sha512(f.read()).digest()).decode() - -# 1) package.json: add pnpm.overrides -with open(f"{proj}/package.json") as f: - pkg = json.load(f) -pkg["pnpm"] = {"overrides": {"left-pad@1.3.0": spec}} -with open(f"{proj}/package.json", "w") as f: - json.dump(pkg, f, indent=2) - f.write("\n") - -# 2) pnpm-lock.yaml surgical text edits -with open(f"{proj}/pnpm-lock.yaml") as f: - lock = f.read() - -# a) insert overrides: section after the settings block -lock = lock.replace( - " excludeLinksFromLockfile: false\n\nimporters:", - f" excludeLinksFromLockfile: false\n\noverrides:\n left-pad@1.3.0: {spec}\n\nimporters:", - 1) - -# b) importer: direct dep specifier+version -> file: spec -lock = lock.replace( - " left-pad:\n specifier: 1.3.0\n version: 1.3.0\n", - f" left-pad:\n specifier: {spec}\n version: {spec}\n", - 1) - -# c) packages: entry rekeyed; resolution gets patched integrity + tarball; version field; no deprecated -lock = lock.replace( - " left-pad@1.3.0:\n" - " resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==}\n" - " deprecated: use String.prototype.padStart()\n", - f" left-pad@{spec}:\n" - f" resolution: {{integrity: {integ}, tarball: {spec}}}\n" - f" version: 1.3.0\n", - 1) - -# d) snapshots: consumer dep ref + rekeyed snapshot -lock = lock.replace(" left-pad: 1.3.0\n", f" left-pad: {spec}\n", 1) -lock = lock.replace(" left-pad@1.3.0: {}\n", f" left-pad@{spec}: {{}}\n", 1) - -with open(f"{proj}/pnpm-lock.yaml", "w") as f: - f.write(lock) -print("edited", proj, "integrity:", integ) diff --git a/spikes/pnpm/p1-multi-dep/after/consumer/package.json b/spikes/pnpm/p1-multi-dep/after/consumer/package.json deleted file mode 100644 index f6ea562..0000000 --- a/spikes/pnpm/p1-multi-dep/after/consumer/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "consumer", - "version": "1.0.0", - "dependencies": { - "left-pad": "^1.3.0" - } -} diff --git a/spikes/pnpm/p1-multi-dep/after/package.json b/spikes/pnpm/p1-multi-dep/after/package.json deleted file mode 100644 index cfca5f4..0000000 --- a/spikes/pnpm/p1-multi-dep/after/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "vendor-spike", - "version": "1.0.0", - "private": true, - "dependencies": { - "consumer": "file:./consumer", - "left-pad": "1.3.0", - "left-pad-old": "npm:left-pad@1.2.0" - }, - "pnpm": { - "overrides": { - "left-pad@1.3.0": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" - } - } -} diff --git a/spikes/pnpm/p1-multi-dep/after/pnpm-lock.yaml b/spikes/pnpm/p1-multi-dep/after/pnpm-lock.yaml deleted file mode 100644 index 45eec80..0000000 --- a/spikes/pnpm/p1-multi-dep/after/pnpm-lock.yaml +++ /dev/null @@ -1,45 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -overrides: - left-pad@1.3.0: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz - -importers: - - .: - dependencies: - consumer: - specifier: file:./consumer - version: file:consumer - left-pad: - specifier: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz - version: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz - left-pad-old: - specifier: npm:left-pad@1.2.0 - version: left-pad@1.2.0 - -packages: - - consumer@file:consumer: - resolution: {directory: consumer, type: directory} - - left-pad@1.2.0: - resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} - deprecated: use String.prototype.padStart() - - left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: - resolution: {integrity: sha512-VR8nCbFxvOcFX5Rxku2psjaj0+xzKdzFkcuqZJSHf597bMVomG100t6+cJkMBFRLhyVdSVwufbCwVzlCzZkUwg==, tarball: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz} - version: 1.3.0 - -snapshots: - - consumer@file:consumer: - dependencies: - left-pad: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz - - left-pad@1.2.0: {} - - left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: {} diff --git a/spikes/pnpm/p1-multi-dep/before/consumer/package.json b/spikes/pnpm/p1-multi-dep/before/consumer/package.json deleted file mode 100644 index f6ea562..0000000 --- a/spikes/pnpm/p1-multi-dep/before/consumer/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "consumer", - "version": "1.0.0", - "dependencies": { - "left-pad": "^1.3.0" - } -} diff --git a/spikes/pnpm/p1-multi-dep/before/package.json b/spikes/pnpm/p1-multi-dep/before/package.json deleted file mode 100644 index 67dcdd4..0000000 --- a/spikes/pnpm/p1-multi-dep/before/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "vendor-spike", - "version": "1.0.0", - "private": true, - "dependencies": { - "consumer": "file:./consumer", - "left-pad": "1.3.0", - "left-pad-old": "npm:left-pad@1.2.0" - } -} diff --git a/spikes/pnpm/p1-multi-dep/before/pnpm-lock.yaml b/spikes/pnpm/p1-multi-dep/before/pnpm-lock.yaml deleted file mode 100644 index aa2d943..0000000 --- a/spikes/pnpm/p1-multi-dep/before/pnpm-lock.yaml +++ /dev/null @@ -1,42 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - consumer: - specifier: file:./consumer - version: file:consumer - left-pad: - specifier: 1.3.0 - version: 1.3.0 - left-pad-old: - specifier: npm:left-pad@1.2.0 - version: left-pad@1.2.0 - -packages: - - consumer@file:consumer: - resolution: {directory: consumer, type: directory} - - left-pad@1.2.0: - resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} - deprecated: use String.prototype.padStart() - - left-pad@1.3.0: - resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} - deprecated: use String.prototype.padStart() - -snapshots: - - consumer@file:consumer: - dependencies: - left-pad: 1.3.0 - - left-pad@1.2.0: {} - - left-pad@1.3.0: {} diff --git a/spikes/pnpm/p4-single-dep-offline/after/package.json b/spikes/pnpm/p4-single-dep-offline/after/package.json deleted file mode 100644 index c9a6d68..0000000 --- a/spikes/pnpm/p4-single-dep-offline/after/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "vendor-offline", - "version": "1.0.0", - "private": true, - "dependencies": { - "left-pad": "1.3.0" - }, - "pnpm": { - "overrides": { - "left-pad@1.3.0": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" - } - } -} diff --git a/spikes/pnpm/p4-single-dep-offline/after/pnpm-lock.yaml b/spikes/pnpm/p4-single-dep-offline/after/pnpm-lock.yaml deleted file mode 100644 index b216ede..0000000 --- a/spikes/pnpm/p4-single-dep-offline/after/pnpm-lock.yaml +++ /dev/null @@ -1,26 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -overrides: - left-pad@1.3.0: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz - -importers: - - .: - dependencies: - left-pad: - specifier: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz - version: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz - -packages: - - left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: - resolution: {integrity: sha512-VR8nCbFxvOcFX5Rxku2psjaj0+xzKdzFkcuqZJSHf597bMVomG100t6+cJkMBFRLhyVdSVwufbCwVzlCzZkUwg==, tarball: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz} - version: 1.3.0 - -snapshots: - - left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: {} diff --git a/spikes/pnpm/p4-single-dep-offline/before/package.json b/spikes/pnpm/p4-single-dep-offline/before/package.json deleted file mode 100644 index f8136c4..0000000 --- a/spikes/pnpm/p4-single-dep-offline/before/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "vendor-offline", - "version": "1.0.0", - "private": true, - "dependencies": { - "left-pad": "1.3.0" - } -} diff --git a/spikes/pnpm/p4-single-dep-offline/before/pnpm-lock.yaml b/spikes/pnpm/p4-single-dep-offline/before/pnpm-lock.yaml deleted file mode 100644 index 952f295..0000000 --- a/spikes/pnpm/p4-single-dep-offline/before/pnpm-lock.yaml +++ /dev/null @@ -1,23 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - left-pad: - specifier: 1.3.0 - version: 1.3.0 - -packages: - - left-pad@1.3.0: - resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} - deprecated: use String.prototype.padStart() - -snapshots: - - left-pad@1.3.0: {} diff --git a/spikes/pnpm/p7-workspace/after/package.json b/spikes/pnpm/p7-workspace/after/package.json deleted file mode 100644 index 43aca72..0000000 --- a/spikes/pnpm/p7-workspace/after/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "ws-root", - "version": "1.0.0", - "private": true, - "pnpm": { - "overrides": { - "left-pad@1.3.0": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" - } - } -} diff --git a/spikes/pnpm/p7-workspace/after/packages/app/package.json b/spikes/pnpm/p7-workspace/after/packages/app/package.json deleted file mode 100644 index aa21462..0000000 --- a/spikes/pnpm/p7-workspace/after/packages/app/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "app", - "version": "1.0.0", - "dependencies": { - "left-pad": "^1.3.0" - } -} diff --git a/spikes/pnpm/p7-workspace/after/pnpm-lock.yaml b/spikes/pnpm/p7-workspace/after/pnpm-lock.yaml deleted file mode 100644 index e94b09f..0000000 --- a/spikes/pnpm/p7-workspace/after/pnpm-lock.yaml +++ /dev/null @@ -1,28 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -overrides: - left-pad@1.3.0: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz - -importers: - - .: {} - - packages/app: - dependencies: - left-pad: - specifier: file:../../.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz - version: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz - -packages: - - left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: - resolution: {integrity: sha512-VR8nCbFxvOcFX5Rxku2psjaj0+xzKdzFkcuqZJSHf597bMVomG100t6+cJkMBFRLhyVdSVwufbCwVzlCzZkUwg==, tarball: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz} - version: 1.3.0 - -snapshots: - - left-pad@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz: {} diff --git a/spikes/pnpm/p7-workspace/after/pnpm-workspace.yaml b/spikes/pnpm/p7-workspace/after/pnpm-workspace.yaml deleted file mode 100644 index dee51e9..0000000 --- a/spikes/pnpm/p7-workspace/after/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - - "packages/*" diff --git a/spikes/pnpm/p7-workspace/before/package.json b/spikes/pnpm/p7-workspace/before/package.json deleted file mode 100644 index 3bb2881..0000000 --- a/spikes/pnpm/p7-workspace/before/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "ws-root", - "version": "1.0.0", - "private": true -} diff --git a/spikes/pnpm/p7-workspace/before/packages/app/package.json b/spikes/pnpm/p7-workspace/before/packages/app/package.json deleted file mode 100644 index aa21462..0000000 --- a/spikes/pnpm/p7-workspace/before/packages/app/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "app", - "version": "1.0.0", - "dependencies": { - "left-pad": "^1.3.0" - } -} diff --git a/spikes/pnpm/p7-workspace/before/pnpm-lock.yaml b/spikes/pnpm/p7-workspace/before/pnpm-lock.yaml deleted file mode 100644 index 63e2fa4..0000000 --- a/spikes/pnpm/p7-workspace/before/pnpm-lock.yaml +++ /dev/null @@ -1,25 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: {} - - packages/app: - dependencies: - left-pad: - specifier: ^1.3.0 - version: 1.3.0 - -packages: - - left-pad@1.3.0: - resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} - deprecated: use String.prototype.padStart() - -snapshots: - - left-pad@1.3.0: {} diff --git a/spikes/pnpm/p7-workspace/before/pnpm-workspace.yaml b/spikes/pnpm/p7-workspace/before/pnpm-workspace.yaml deleted file mode 100644 index dee51e9..0000000 --- a/spikes/pnpm/p7-workspace/before/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - - "packages/*" diff --git a/spikes/poetry/README.md b/spikes/poetry/README.md deleted file mode 100644 index a0ebdc4..0000000 --- a/spikes/poetry/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# Poetry vendor-v2 spike fixtures - -Spike date: 2026-06-10. Host: macOS (Darwin 25.5.0), Python 3.14.3. - -Exact tool versions: -- `lock-2.1/` generated by **Poetry 2.4.1** (`python3 -m venv /tmp/poe2 && pip install poetry`), lock-version `2.1` -- `lock-2.0/` generated by **Poetry 1.8.5** (`pip install 'poetry<2'`), lock-version `2.0` -- Wheels downloaded with pip 26.0 (`pip3 download six==1.16.0 --no-deps`) - -## Wheels - -- `wheels/original/six-1.16.0-py2.py3-none-any.whl` - sha256 `8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254` (the real PyPI wheel) -- `wheels/patched/six-1.16.0-py2.py3-none-any.whl` - sha256 `0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1` - Built by: unzip original; append `\n# SOCKET-PATCHED\n` to `six.py`; rewrite its RECORD line - (`sha256=` base64.urlsafe nopad of new bytes + new size, here - `six.py,sha256=jCYos5OzBq4HcWl4E7kATbVtnHLzmsxE64HkNrjPry4,34567`); rezip with - `zip -X -r` keeping the canonical wheel filename. pip and poetry both install it cleanly. - -Every `*-path*` fixture carries this patched wheel at the vendor path -`.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl` -relative to `pyproject.toml`. - -## Fixture pairs (pyproject.toml + poetry.lock; ALL locks tool-generated via `poetry lock`) - -Same four pairs under each of `lock-2.1/` (Poetry 2.4.1) and `lock-2.0/` (Poetry 1.8.5): - -| pair | pyproject | shows | -|---|---|---| -| `direct-registry/` | `six = "1.16.0"` | registry baseline: two `files[]` hashes (whl+sdist), **no** `[package.source]` | -| `direct-path-wheel/` | `six = {path = ".socket/vendor/pypi//...whl"}` | vendored shape: `[package.source] type="file"` with **relative** url, single `files[]` entry = patched-wheel sha256 | -| `transitive-registry/` | `python-dateutil = "2.8.2"` (six transitive) | registry baseline for the transitive case; resolver picks six 1.17.0 | -| `transitive-path/` | dateutil + six path dep | the only *tool-generatable* lock where the transitive six comes from the file source (six must also be declared direct); same six entry shape as direct-path-wheel | - -All 8 fixtures re-verified post-generation: `poetry install` in a copy exits 0, leaves -poetry.lock byte-identical, and the `*-path*` pairs import six with the `# SOCKET-PATCHED` -marker (registry pairs install unpatched six, as expected). - -Lock-shape facts (P1): the `[package.source]` table renders **after** `files = [...]`, as the -last subtable of the `[[package]]` entry (before the next `[[package]]`/`[metadata]`). -Poetry 2 adds `groups = ["main"]`; 1.8 has no `groups` key. The url is relative to the -project root in both majors — even when `poetry add` was given an absolute path. - -## evidence-lockonly/ — NOT tool-generated locks (the decisive P2/P3 experiment) - -These four dirs are the *hand-spliced* artifacts proving the lock-only strategy: pyproject -stays pure-registry (`six = "1.16.0"` / `python-dateutil = "2.8.2"`), and ONLY the six -`[[package]]` entry in poetry.lock was replaced with the tool-generated file-source entry -(taken verbatim from the direct-path-wheel pair), `metadata.content-hash` untouched. - -Result on BOTH majors: `poetry install` (and `poetry sync` / `poetry install --sync`) -exit 0, install the PATCHED wheel, leave poetry.lock byte-unchanged, and -`poetry check --lock` passes. The transitive splice even installed six 1.16.0 where the -resolver had picked 1.17.0 — install is 100% lock-driven. - -## Claim results (run on both majors unless noted) - -- **P1** confirmed — see lock-shape facts above. Surprise: Poetry 2's `poetry add ` - writes an ABSOLUTE `file:///...` URL into PEP 621 `[project].dependencies` (not - committable); 1.8 writes a relative `{path = ...}`. The lock url is relative either way. - Fixture pyprojects therefore use the `[tool.poetry.dependencies]` path form on both majors. -- **P2** PASS (lock-only direct): Poetry 2.4.1 (both `[tool.poetry]` and PEP 621 pyprojects) - and 1.8.5: install + sync exit 0, marker present, lock byte-unchanged. -- **P3** PASS (lock-only transitive): both majors; dateutil from registry + six from file. -- **P4** PASS (fail-closed): tampered wheel + stale lock hash + empty POETRY_CACHE_DIR → - exit 1 on both majors with - `RuntimeError: Hash for six (...) from archive six-1.16.0-py2.py3-none-any.whl not found in known hashes (was: sha256:2597d578...)` - raised from `installation/executor.py:_validate_archive_hash`. -- **P5** silent-unpatch matrix on the P2 state: - - Poetry 2.4.1 plain `poetry lock`: PRESERVES file source (lock byte-identical). - - Poetry 2.4.1 `poetry lock --regenerate`: REWRITES six to registry. - - Poetry 1.8.5 `poetry lock --no-update`: PRESERVES. - - Poetry 1.8.5 plain `poetry lock` (full re-resolve in 1.x): REWRITES. - - `poetry update six`: REWRITES on both majors (silent, exit 0 — six version stays - 1.16.0 because pyproject pins it, but files[] revert to registry hashes → unpatched). - - `poetry add packaging`: PRESERVES on both majors (re-resolve keeps locked sources of - untouched packages). -- **P6** confirmed: `poetry check --lock` exits 0 on the spliced state (2.4.1 both - pyproject styles + 1.8.5). content-hash only covers pyproject, untouched by the splice. -- **P7** PASS: dir containing ONLY pyproject.toml + poetry.lock + .socket/, brand-new - empty POETRY_CACHE_DIR, fresh in-project venv → install exit 0, marker present - (both majors direct; also verified transitive on 2.4.1). -- **P8** both majors canonicalize: pyproject `PyYAML = "6.0.1"` locks as - `name = "pyyaml"` (PEP 503 lowercase); `files[]` entries keep the original - `PyYAML-6.0.1-*.whl` filename casing. - -## Caveats - -- `poetry update ` and full re-lock (`lock --regenerate` on 2.x, plain `lock` on 1.x) - silently revert the patch with exit 0; vendor v2 needs a drift check (the lock's six - `files[]` hash is the cheapest oracle). -- Poetry installs file-source wheels directly from the path; hash is verified on every - install (P4), so the committable guarantee holds with no extra flags — poetry has no - "looser" install mode to guard against. diff --git a/spikes/poetry/evidence-lockonly/lock-2.0-direct/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/evidence-lockonly/lock-2.0-direct/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index 1816a1d901cd1de5053d0d215035b540ece1765c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11196 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 diff --git a/spikes/poetry/evidence-lockonly/lock-2.0-direct/poetry.lock b/spikes/poetry/evidence-lockonly/lock-2.0-direct/poetry.lock deleted file mode 100644 index 7742924..0000000 --- a/spikes/poetry/evidence-lockonly/lock-2.0-direct/poetry.lock +++ /dev/null @@ -1,20 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, -] - -[package.source] -type = "file" -url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" - -[metadata] -lock-version = "2.0" -python-versions = ">=3.9" -content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" diff --git a/spikes/poetry/evidence-lockonly/lock-2.0-direct/pyproject.toml b/spikes/poetry/evidence-lockonly/lock-2.0-direct/pyproject.toml deleted file mode 100644 index b2d8496..0000000 --- a/spikes/poetry/evidence-lockonly/lock-2.0-direct/pyproject.toml +++ /dev/null @@ -1,10 +0,0 @@ -[tool.poetry] -name = "scratch" -version = "0.1.0" -description = "" -authors = ["Spike "] -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.9" -six = "1.16.0" diff --git a/spikes/poetry/evidence-lockonly/lock-2.0-transitive/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/evidence-lockonly/lock-2.0-transitive/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index 1816a1d901cd1de5053d0d215035b540ece1765c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11196 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 diff --git a/spikes/poetry/evidence-lockonly/lock-2.0-transitive/poetry.lock b/spikes/poetry/evidence-lockonly/lock-2.0-transitive/poetry.lock deleted file mode 100644 index e3fc134..0000000 --- a/spikes/poetry/evidence-lockonly/lock-2.0-transitive/poetry.lock +++ /dev/null @@ -1,34 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. - -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, -] - -[package.source] -type = "file" -url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" - -[metadata] -lock-version = "2.0" -python-versions = ">=3.9" -content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca" diff --git a/spikes/poetry/evidence-lockonly/lock-2.0-transitive/pyproject.toml b/spikes/poetry/evidence-lockonly/lock-2.0-transitive/pyproject.toml deleted file mode 100644 index d43c93c..0000000 --- a/spikes/poetry/evidence-lockonly/lock-2.0-transitive/pyproject.toml +++ /dev/null @@ -1,10 +0,0 @@ -[tool.poetry] -name = "scratch" -version = "0.1.0" -description = "" -authors = ["Spike "] -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.9" -python-dateutil = "2.8.2" diff --git a/spikes/poetry/evidence-lockonly/lock-2.1-direct/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/evidence-lockonly/lock-2.1-direct/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index 1816a1d901cd1de5053d0d215035b540ece1765c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11196 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 diff --git a/spikes/poetry/evidence-lockonly/lock-2.1-direct/poetry.lock b/spikes/poetry/evidence-lockonly/lock-2.1-direct/poetry.lock deleted file mode 100644 index 4bf41d2..0000000 --- a/spikes/poetry/evidence-lockonly/lock-2.1-direct/poetry.lock +++ /dev/null @@ -1,21 +0,0 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -groups = ["main"] -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, -] - -[package.source] -type = "file" -url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" - -[metadata] -lock-version = "2.1" -python-versions = ">=3.9" -content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" diff --git a/spikes/poetry/evidence-lockonly/lock-2.1-direct/pyproject.toml b/spikes/poetry/evidence-lockonly/lock-2.1-direct/pyproject.toml deleted file mode 100644 index b2d8496..0000000 --- a/spikes/poetry/evidence-lockonly/lock-2.1-direct/pyproject.toml +++ /dev/null @@ -1,10 +0,0 @@ -[tool.poetry] -name = "scratch" -version = "0.1.0" -description = "" -authors = ["Spike "] -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.9" -six = "1.16.0" diff --git a/spikes/poetry/evidence-lockonly/lock-2.1-transitive/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/evidence-lockonly/lock-2.1-transitive/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index 1816a1d901cd1de5053d0d215035b540ece1765c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11196 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 diff --git a/spikes/poetry/evidence-lockonly/lock-2.1-transitive/poetry.lock b/spikes/poetry/evidence-lockonly/lock-2.1-transitive/poetry.lock deleted file mode 100644 index 915c7a2..0000000 --- a/spikes/poetry/evidence-lockonly/lock-2.1-transitive/poetry.lock +++ /dev/null @@ -1,36 +0,0 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. - -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -groups = ["main"] -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, -] - -[package.source] -type = "file" -url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" - -[metadata] -lock-version = "2.1" -python-versions = ">=3.9" -content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca" diff --git a/spikes/poetry/evidence-lockonly/lock-2.1-transitive/pyproject.toml b/spikes/poetry/evidence-lockonly/lock-2.1-transitive/pyproject.toml deleted file mode 100644 index d43c93c..0000000 --- a/spikes/poetry/evidence-lockonly/lock-2.1-transitive/pyproject.toml +++ /dev/null @@ -1,10 +0,0 @@ -[tool.poetry] -name = "scratch" -version = "0.1.0" -description = "" -authors = ["Spike "] -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.9" -python-dateutil = "2.8.2" diff --git a/spikes/poetry/lock-2.0/direct-path-wheel/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/lock-2.0/direct-path-wheel/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index 1816a1d901cd1de5053d0d215035b540ece1765c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11196 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 diff --git a/spikes/poetry/lock-2.0/direct-path-wheel/poetry.lock b/spikes/poetry/lock-2.0/direct-path-wheel/poetry.lock deleted file mode 100644 index a1c59d8..0000000 --- a/spikes/poetry/lock-2.0/direct-path-wheel/poetry.lock +++ /dev/null @@ -1,20 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, -] - -[package.source] -type = "file" -url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" - -[metadata] -lock-version = "2.0" -python-versions = ">=3.9" -content-hash = "5434ae139eccff13b64b1afa678ebd6ad51d0123b6147ab3449303014b0e29ef" diff --git a/spikes/poetry/lock-2.0/direct-path-wheel/pyproject.toml b/spikes/poetry/lock-2.0/direct-path-wheel/pyproject.toml deleted file mode 100644 index c58f3ed..0000000 --- a/spikes/poetry/lock-2.0/direct-path-wheel/pyproject.toml +++ /dev/null @@ -1,10 +0,0 @@ -[tool.poetry] -name = "scratch" -version = "0.1.0" -description = "" -authors = ["Spike "] -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.9" -six = {path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"} diff --git a/spikes/poetry/lock-2.0/direct-registry/poetry.lock b/spikes/poetry/lock-2.0/direct-registry/poetry.lock deleted file mode 100644 index 907d872..0000000 --- a/spikes/poetry/lock-2.0/direct-registry/poetry.lock +++ /dev/null @@ -1,17 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[metadata] -lock-version = "2.0" -python-versions = ">=3.9" -content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" diff --git a/spikes/poetry/lock-2.0/direct-registry/pyproject.toml b/spikes/poetry/lock-2.0/direct-registry/pyproject.toml deleted file mode 100644 index b2d8496..0000000 --- a/spikes/poetry/lock-2.0/direct-registry/pyproject.toml +++ /dev/null @@ -1,10 +0,0 @@ -[tool.poetry] -name = "scratch" -version = "0.1.0" -description = "" -authors = ["Spike "] -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.9" -six = "1.16.0" diff --git a/spikes/poetry/lock-2.0/transitive-path/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/lock-2.0/transitive-path/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index 1816a1d901cd1de5053d0d215035b540ece1765c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11196 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 diff --git a/spikes/poetry/lock-2.0/transitive-path/poetry.lock b/spikes/poetry/lock-2.0/transitive-path/poetry.lock deleted file mode 100644 index b8a9b1e..0000000 --- a/spikes/poetry/lock-2.0/transitive-path/poetry.lock +++ /dev/null @@ -1,34 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. - -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, -] - -[package.source] -type = "file" -url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" - -[metadata] -lock-version = "2.0" -python-versions = ">=3.9" -content-hash = "4bb72a51114ed762964be4c9ae3c2517f1ae92e4ce6d142c6009a2fc1e1e5d67" diff --git a/spikes/poetry/lock-2.0/transitive-path/pyproject.toml b/spikes/poetry/lock-2.0/transitive-path/pyproject.toml deleted file mode 100644 index d35f922..0000000 --- a/spikes/poetry/lock-2.0/transitive-path/pyproject.toml +++ /dev/null @@ -1,11 +0,0 @@ -[tool.poetry] -name = "scratch" -version = "0.1.0" -description = "" -authors = ["Spike "] -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.9" -python-dateutil = "2.8.2" -six = {path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"} diff --git a/spikes/poetry/lock-2.0/transitive-registry/poetry.lock b/spikes/poetry/lock-2.0/transitive-registry/poetry.lock deleted file mode 100644 index 9eb6721..0000000 --- a/spikes/poetry/lock-2.0/transitive-registry/poetry.lock +++ /dev/null @@ -1,31 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. - -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[metadata] -lock-version = "2.0" -python-versions = ">=3.9" -content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca" diff --git a/spikes/poetry/lock-2.0/transitive-registry/pyproject.toml b/spikes/poetry/lock-2.0/transitive-registry/pyproject.toml deleted file mode 100644 index d43c93c..0000000 --- a/spikes/poetry/lock-2.0/transitive-registry/pyproject.toml +++ /dev/null @@ -1,10 +0,0 @@ -[tool.poetry] -name = "scratch" -version = "0.1.0" -description = "" -authors = ["Spike "] -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.9" -python-dateutil = "2.8.2" diff --git a/spikes/poetry/lock-2.1/direct-path-wheel/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/lock-2.1/direct-path-wheel/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index 1816a1d901cd1de5053d0d215035b540ece1765c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11196 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 diff --git a/spikes/poetry/lock-2.1/direct-path-wheel/poetry.lock b/spikes/poetry/lock-2.1/direct-path-wheel/poetry.lock deleted file mode 100644 index a0be459..0000000 --- a/spikes/poetry/lock-2.1/direct-path-wheel/poetry.lock +++ /dev/null @@ -1,21 +0,0 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -groups = ["main"] -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, -] - -[package.source] -type = "file" -url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" - -[metadata] -lock-version = "2.1" -python-versions = ">=3.9" -content-hash = "5434ae139eccff13b64b1afa678ebd6ad51d0123b6147ab3449303014b0e29ef" diff --git a/spikes/poetry/lock-2.1/direct-path-wheel/pyproject.toml b/spikes/poetry/lock-2.1/direct-path-wheel/pyproject.toml deleted file mode 100644 index c58f3ed..0000000 --- a/spikes/poetry/lock-2.1/direct-path-wheel/pyproject.toml +++ /dev/null @@ -1,10 +0,0 @@ -[tool.poetry] -name = "scratch" -version = "0.1.0" -description = "" -authors = ["Spike "] -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.9" -six = {path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"} diff --git a/spikes/poetry/lock-2.1/direct-registry/poetry.lock b/spikes/poetry/lock-2.1/direct-registry/poetry.lock deleted file mode 100644 index 9f4f30d..0000000 --- a/spikes/poetry/lock-2.1/direct-registry/poetry.lock +++ /dev/null @@ -1,18 +0,0 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -groups = ["main"] -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[metadata] -lock-version = "2.1" -python-versions = ">=3.9" -content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" diff --git a/spikes/poetry/lock-2.1/direct-registry/pyproject.toml b/spikes/poetry/lock-2.1/direct-registry/pyproject.toml deleted file mode 100644 index b2d8496..0000000 --- a/spikes/poetry/lock-2.1/direct-registry/pyproject.toml +++ /dev/null @@ -1,10 +0,0 @@ -[tool.poetry] -name = "scratch" -version = "0.1.0" -description = "" -authors = ["Spike "] -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.9" -six = "1.16.0" diff --git a/spikes/poetry/lock-2.1/transitive-path/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/lock-2.1/transitive-path/.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index 1816a1d901cd1de5053d0d215035b540ece1765c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11196 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 diff --git a/spikes/poetry/lock-2.1/transitive-path/poetry.lock b/spikes/poetry/lock-2.1/transitive-path/poetry.lock deleted file mode 100644 index 6d49955..0000000 --- a/spikes/poetry/lock-2.1/transitive-path/poetry.lock +++ /dev/null @@ -1,36 +0,0 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. - -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -groups = ["main"] -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:0bf540048d557577b88d92443652cc4c4cbfd291c8c53f00c7bcac3a213f14d1"}, -] - -[package.source] -type = "file" -url = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" - -[metadata] -lock-version = "2.1" -python-versions = ">=3.9" -content-hash = "4bb72a51114ed762964be4c9ae3c2517f1ae92e4ce6d142c6009a2fc1e1e5d67" diff --git a/spikes/poetry/lock-2.1/transitive-path/pyproject.toml b/spikes/poetry/lock-2.1/transitive-path/pyproject.toml deleted file mode 100644 index d35f922..0000000 --- a/spikes/poetry/lock-2.1/transitive-path/pyproject.toml +++ /dev/null @@ -1,11 +0,0 @@ -[tool.poetry] -name = "scratch" -version = "0.1.0" -description = "" -authors = ["Spike "] -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.9" -python-dateutil = "2.8.2" -six = {path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl"} diff --git a/spikes/poetry/lock-2.1/transitive-registry/poetry.lock b/spikes/poetry/lock-2.1/transitive-registry/poetry.lock deleted file mode 100644 index a4f40d4..0000000 --- a/spikes/poetry/lock-2.1/transitive-registry/poetry.lock +++ /dev/null @@ -1,33 +0,0 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. - -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[metadata] -lock-version = "2.1" -python-versions = ">=3.9" -content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca" diff --git a/spikes/poetry/lock-2.1/transitive-registry/pyproject.toml b/spikes/poetry/lock-2.1/transitive-registry/pyproject.toml deleted file mode 100644 index d43c93c..0000000 --- a/spikes/poetry/lock-2.1/transitive-registry/pyproject.toml +++ /dev/null @@ -1,10 +0,0 @@ -[tool.poetry] -name = "scratch" -version = "0.1.0" -description = "" -authors = ["Spike "] -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.9" -python-dateutil = "2.8.2" diff --git a/spikes/poetry/wheels/original/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/wheels/original/six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index fd942658a2f748ba433dd8632abb910a416e184f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11053 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 diff --git a/spikes/poetry/wheels/patched/six-1.16.0-py2.py3-none-any.whl b/spikes/poetry/wheels/patched/six-1.16.0-py2.py3-none-any.whl deleted file mode 100644 index 1816a1d901cd1de5053d0d215035b540ece1765c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11196 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 diff --git a/spikes/uv/README.md b/spikes/uv/README.md deleted file mode 100644 index 21b521d..0000000 --- a/spikes/uv/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# uv vendored-wheel spike fixtures - -Generated 2026-06-09 with **uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin)**, CPython 3.14.3 (macOS arm64). -De-risking spike for `socket-patch vendor`: vendoring a patched wheel at -`.socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl` -and rewriting `pyproject.toml` + `uv.lock` so the project consumes it. - -Each directory is a `pyproject.toml` + `uv.lock` pair, all uv-generated (never hand-written), -except where noted. All locks: `version = 1`, `revision = 3`. - -## Fixture pairs - -| dir | shows | -| --- | --- | -| `direct-registry/` | BEFORE: direct dep `six==1.16.0` from PyPI. requires-dist keeps `specifier = "==1.16.0"`; six `source = { registry = ... }` with `sdist` + url/size/upload-time wheels. | -| `direct-path-wheel/` | AFTER: `[tool.uv.sources] six = { path = ... }`. six becomes `source = { path = "" }`; wheels element is `{ filename, hash }` ONLY (no url/size/upload-time/path); `sdist` line dropped; `version` retained; requires-dist becomes `{ name = "six", path = "" }` — **specifier is dropped, not kept alongside**. | -| `transitive-registry/` | BEFORE: direct dep `python-dateutil==2.8.2`; six is transitive and resolves to 1.17.0 from registry. | -| `transitive-promoted/` | AFTER: six promoted into `[project] dependencies` (`"six==1.16.0"`) + sources path entry. Root `dependencies` gains `{ name = "six" }`; requires-dist gains `{ name = "six", path = ... }` (dateutil entry keeps its specifier); six entry switches to path source, pinned down 1.17.0 → 1.16.0. | -| `override-transitive/` | ALTERNATIVE (no promotion): `[tool.uv] override-dependencies = ["six==1.16.0"]` + sources entry, six NOT in project.dependencies. Lock gains `[manifest]` with `overrides = [{ name = "six", path = ... }]` (path replaces specifier there too); six entry is the same path shape; requires-dist untouched. Installs from the vendored wheel; byte-stable under plain `uv sync`. | - -## Key behaviors observed (claim numbers from the spike) - -1. Path-wheel lock shape: see `direct-path-wheel/uv.lock` lines for six. -2. Surgical text edit of the registry lock reproduced the uv-generated path lock - **byte-identically**; `uv lock --check` exit 0, `uv sync --locked` installs from the - vendored wheel, plain `uv sync` leaves the lock byte-identical (sha256 stable). -3. Fresh checkout (only pyproject/uv.lock/.socket), fresh `UV_CACHE_DIR`, - `uv sync --frozen --offline` → installs the patched wheel; marker visible in - site-packages `six.py`. (Wheel was repacked with a marker + RECORD fixed; lock hash - refreshed via `uv lock --upgrade-package six` — see surprise below.) -4. Tamper: valid-zip content change → `uv sync --frozen` fails - "Hash mismatch ... Expected: sha256: Computed: sha256:", exit 1. - A raw byte-flip fails earlier with "deflate decompression error: invalid distances set". -5. Promotion (transitive → direct + source) works; `uv sync --locked` ok; plain sync byte-stable. -6. Lock-only edit (path source written ONLY into six's `[[package]]`, pyproject untouched): - `uv lock --check`, `uv sync --locked`, `--frozen`, plain `uv sync`, even plain `uv lock` - ALL pass and preserve it — but `uv lock --upgrade`/`--upgrade-package six` silently - reverts to registry six 1.17.0. -7. `[tool.uv.sources]` entry for a package not in any direct declaration: **silently - ignored** (exit 0, no warning), whether the package is transitive or absent entirely. -8. Sources DO apply to `override-dependencies` (see `override-transitive/`). -9. Silent-revert risk is real: registry pyproject + path lock → plain `uv sync` re-resolves - and rewrites the lock back to registry source, exit 0, no warning, registry wheel - installed. `uv lock --check` on that combo DOES fail ("The lockfile at `uv.lock` needs - to be updated, but `--check` was provided."). -10. Single-project locks have NO `[manifest]` section (no `members` key); `[manifest]` - appears only to carry resolver inputs (e.g. `overrides`). Virtual root: - `source = { virtual = "." }`; packaged (build-system) root: `source = { editable = "." }`. - -## Surprise / implementation hazard - -Replacing the vendored wheel's bytes at an unchanged path does NOT refresh the lock hash: -plain `uv lock` keeps the stale hash (lock validation never re-hashes files). Use -`uv lock --upgrade-package `, delete+regenerate, or write the new sha256 surgically. diff --git a/spikes/uv/direct-path-wheel/README.md b/spikes/uv/direct-path-wheel/README.md deleted file mode 100644 index 1b338b8..0000000 --- a/spikes/uv/direct-path-wheel/README.md +++ /dev/null @@ -1,3 +0,0 @@ -uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin). AFTER pair: [tool.uv.sources] six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }. -six: source = { path = "" }; wheels = [{ filename, hash }] only (no url/size/upload-time); no sdist; version retained. -requires-dist = [{ name = "six", path = "" }] — specifier DROPPED. Lock uses the pristine wheel hash sha256:8abb2f1d... . diff --git a/spikes/uv/direct-path-wheel/pyproject.toml b/spikes/uv/direct-path-wheel/pyproject.toml deleted file mode 100644 index b6e2e59..0000000 --- a/spikes/uv/direct-path-wheel/pyproject.toml +++ /dev/null @@ -1,8 +0,0 @@ -[project] -name = "proj" -version = "0.1.0" -requires-python = ">=3.10" -dependencies = ["six==1.16.0"] - -[tool.uv.sources] -six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } diff --git a/spikes/uv/direct-path-wheel/uv.lock b/spikes/uv/direct-path-wheel/uv.lock deleted file mode 100644 index 0aac493..0000000 --- a/spikes/uv/direct-path-wheel/uv.lock +++ /dev/null @@ -1,22 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" - -[[package]] -name = "proj" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "six" }, -] - -[package.metadata] -requires-dist = [{ name = "six", path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }] - -[[package]] -name = "six" -version = "1.16.0" -source = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } -wheels = [ - { filename = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" }, -] diff --git a/spikes/uv/direct-registry/README.md b/spikes/uv/direct-registry/README.md deleted file mode 100644 index 0a699ee..0000000 --- a/spikes/uv/direct-registry/README.md +++ /dev/null @@ -1,2 +0,0 @@ -uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin). BEFORE pair: virtual project, direct dep six==1.16.0 from PyPI registry. -requires-dist = [{ name = "six", specifier = "==1.16.0" }]; six source = { registry = "https://pypi.org/simple" } with sdist + url/size/upload-time wheel entries. diff --git a/spikes/uv/direct-registry/pyproject.toml b/spikes/uv/direct-registry/pyproject.toml deleted file mode 100644 index 74444fb..0000000 --- a/spikes/uv/direct-registry/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[project] -name = "proj" -version = "0.1.0" -requires-python = ">=3.10" -dependencies = ["six==1.16.0"] diff --git a/spikes/uv/direct-registry/uv.lock b/spikes/uv/direct-registry/uv.lock deleted file mode 100644 index 745dac3..0000000 --- a/spikes/uv/direct-registry/uv.lock +++ /dev/null @@ -1,23 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" - -[[package]] -name = "proj" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "six" }, -] - -[package.metadata] -requires-dist = [{ name = "six", specifier = "==1.16.0" }] - -[[package]] -name = "six" -version = "1.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, -] diff --git a/spikes/uv/override-transitive/README.md b/spikes/uv/override-transitive/README.md deleted file mode 100644 index ac20e15..0000000 --- a/spikes/uv/override-transitive/README.md +++ /dev/null @@ -1,3 +0,0 @@ -uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin). ALTERNATIVE pair (claim 8): [tool.uv] override-dependencies = ["six==1.16.0"] + sources path entry; six NOT in project.dependencies. -Lock gains [manifest] overrides = [{ name = "six", path = "" }]; six [[package]] uses path source; requires-dist untouched. -Installs from vendored wheel; plain sync byte-stable. diff --git a/spikes/uv/override-transitive/pyproject.toml b/spikes/uv/override-transitive/pyproject.toml deleted file mode 100644 index 370c5da..0000000 --- a/spikes/uv/override-transitive/pyproject.toml +++ /dev/null @@ -1,11 +0,0 @@ -[project] -name = "proj" -version = "0.1.0" -requires-python = ">=3.10" -dependencies = ["python-dateutil==2.8.2"] - -[tool.uv] -override-dependencies = ["six==1.16.0"] - -[tool.uv.sources] -six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } diff --git a/spikes/uv/override-transitive/uv.lock b/spikes/uv/override-transitive/uv.lock deleted file mode 100644 index ac73dbf..0000000 --- a/spikes/uv/override-transitive/uv.lock +++ /dev/null @@ -1,37 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" - -[manifest] -overrides = [{ name = "six", path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }] - -[[package]] -name = "proj" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "python-dateutil" }, -] - -[package.metadata] -requires-dist = [{ name = "python-dateutil", specifier = "==2.8.2" }] - -[[package]] -name = "python-dateutil" -version = "2.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324, upload-time = "2021-07-14T08:19:19.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702, upload-time = "2021-07-14T08:19:18.161Z" }, -] - -[[package]] -name = "six" -version = "1.16.0" -source = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } -wheels = [ - { filename = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" }, -] diff --git a/spikes/uv/transitive-promoted/README.md b/spikes/uv/transitive-promoted/README.md deleted file mode 100644 index c89e69c..0000000 --- a/spikes/uv/transitive-promoted/README.md +++ /dev/null @@ -1,3 +0,0 @@ -uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin). AFTER pair: six promoted to [project] dependencies ("six==1.16.0") + [tool.uv.sources] path entry. -Root dependencies = [{ name = "python-dateutil" }, { name = "six" }]; requires-dist = [{ name = "python-dateutil", specifier = "==2.8.2" }, { name = "six", path = "" }]. -six pinned 1.17.0 -> 1.16.0, path source. uv sync --locked installs from vendored wheel; plain sync byte-stable. diff --git a/spikes/uv/transitive-promoted/pyproject.toml b/spikes/uv/transitive-promoted/pyproject.toml deleted file mode 100644 index e4b574e..0000000 --- a/spikes/uv/transitive-promoted/pyproject.toml +++ /dev/null @@ -1,8 +0,0 @@ -[project] -name = "proj" -version = "0.1.0" -requires-python = ">=3.10" -dependencies = ["python-dateutil==2.8.2", "six==1.16.0"] - -[tool.uv.sources] -six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } diff --git a/spikes/uv/transitive-promoted/uv.lock b/spikes/uv/transitive-promoted/uv.lock deleted file mode 100644 index 294bfa5..0000000 --- a/spikes/uv/transitive-promoted/uv.lock +++ /dev/null @@ -1,38 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" - -[[package]] -name = "proj" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "python-dateutil" }, - { name = "six" }, -] - -[package.metadata] -requires-dist = [ - { name = "python-dateutil", specifier = "==2.8.2" }, - { name = "six", path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" }, -] - -[[package]] -name = "python-dateutil" -version = "2.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324, upload-time = "2021-07-14T08:19:19.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702, upload-time = "2021-07-14T08:19:18.161Z" }, -] - -[[package]] -name = "six" -version = "1.16.0" -source = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.16.0-py2.py3-none-any.whl" } -wheels = [ - { filename = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" }, -] diff --git a/spikes/uv/transitive-registry/README.md b/spikes/uv/transitive-registry/README.md deleted file mode 100644 index fca93be..0000000 --- a/spikes/uv/transitive-registry/README.md +++ /dev/null @@ -1 +0,0 @@ -uv 0.11.19 (7b2cff1c3 2026-06-03 aarch64-apple-darwin). BEFORE pair: direct dep python-dateutil==2.8.2; six transitive, resolved to 1.17.0 from registry. diff --git a/spikes/uv/transitive-registry/pyproject.toml b/spikes/uv/transitive-registry/pyproject.toml deleted file mode 100644 index 0e38349..0000000 --- a/spikes/uv/transitive-registry/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[project] -name = "proj" -version = "0.1.0" -requires-python = ">=3.10" -dependencies = ["python-dateutil==2.8.2"] diff --git a/spikes/uv/transitive-registry/uv.lock b/spikes/uv/transitive-registry/uv.lock deleted file mode 100644 index ce6c3c3..0000000 --- a/spikes/uv/transitive-registry/uv.lock +++ /dev/null @@ -1,35 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" - -[[package]] -name = "proj" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "python-dateutil" }, -] - -[package.metadata] -requires-dist = [{ name = "python-dateutil", specifier = "==2.8.2" }] - -[[package]] -name = "python-dateutil" -version = "2.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324, upload-time = "2021-07-14T08:19:19.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702, upload-time = "2021-07-14T08:19:18.161Z" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] diff --git a/spikes/yarn-berry-nm/README.md b/spikes/yarn-berry-nm/README.md deleted file mode 100644 index 9bf08be..0000000 --- a/spikes/yarn-berry-nm/README.md +++ /dev/null @@ -1,183 +0,0 @@ -# Spike: yarn berry 4.x (node-modules linker) + vendored .socket tarball - -**Verdict: VIABLE. Berry's lock checksum is byte-for-byte reproducible offline from our -tarball — `checksum: 10c0/` is sha512 of a deterministic zip we can rebuild with -`rebuild_zip.py` (stdlib python, no yarn). Recipe pinned and verified on yarn 4.12.0 and -4.6.0, identical output, TZ-insensitive.** - -## Tool versions -- node v24.12.0 (nvm), corepack 0.34.5 -- yarn 4.12.0 and yarn 4.6.0, both via corepack `packageManager` pin -- Python 3.14.3 (rebuild script), macOS Darwin 25.5.0 arm64 -- bsd tar / shasum from macOS for tarball builds -- Fixture dep: left-pad@1.3.0 (orig tgz sha1 `5b8a3a7765dfe001261dde915589e782f8c94d1e`) -- Vendored path: `.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz`, - patched file = `index.js` with first line `/* socket-patch-marker 9f6b2c4e-... */` - -Every `after/yarn.lock` in `fixtures/` was generated by `yarn install` itself (never -hand-written). The only hand-edited lock is `fixtures/b1-omitted-checksum/before/yarn.lock` -(checksum line deleted — that hand-edit IS the B1 probe input). - -## B3 — resolutions ground truth (fixtures/b3-vendored-resolutions/) - -package.json change required: add a `resolutions` entry (the regular dependency stays a -registry range): - -```json -"dependencies": { "left-pad": "1.3.0" }, -"resolutions": { "left-pad": "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" } -``` - -`.yarnrc.yml`: `nodeLinker: node-modules`, `enableGlobalCache: true`, `enableTelemetry: false`. - -Lock entry yarn 4.12.0 writes (VERBATIM): - -``` -"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": - version: 1.3.0 - resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." - checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 - languageName: node - linkType: hard -``` - -- Lock KEY uses `::locator=%40workspace%3A.` (URL-encoded - `vendor-spike@workspace:.`) — the key embeds the workspace **name** from package.json. -- `hash=39ea9b` = **first 6 hex chars of sha512 of the tgz bytes** (verified: sha512 of - the patched tgz starts `39ea9bc99fb9`; tampering the tgz flipped it to `b4fd84`). -- `checksum: 10c0/<128 hex>` = sha512 of the **converted cache zip** (see B2), prefix - `10` = internal CACHE_VERSION, `c0` = compressionLevel 0 (yarn 4 default). -- Fresh clone of exactly {package.json, yarn.lock, .yarnrc.yml, .socket/} with empty - caches: `yarn install --immutable` passes — see B5 (passes even with network disabled). -- before/ = registry-only project, lock entry for comparison: - `"left-pad@npm:1.3.0"` / `resolution: "left-pad@npm:1.3.0"` / - `checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955`. - -## B1 — omitted-checksum probe: PASSES, does NOT fail (fixtures/b1-omitted-checksum/) - -Expectation (YN0028/YN0018 failure) was WRONG. With the `checksum:` line deleted from the -B3 lock, **cold cache**: - -``` -$ yarn install --immutable # yarn 4.12.0, empty YARN_GLOBAL_FOLDER -➤ YN0000: · Yarn 4.12.0 -➤ YN0000: ┌ Resolution step ... └ Completed -➤ YN0000: ┌ Fetch step -➤ YN0013: │ A package was added to the project (+ 11.32 KiB). -➤ YN0000: ┌ Link step ... └ Completed -➤ YN0000: · Done in 0s 52ms -EXIT=0 -``` - -`yarn install --immutable --check-cache` afterwards: also EXIT=0 (no YN0028, no YN0018). -Installed bytes ARE the patched ones (`/* socket-patch-marker 9f6b2c4e-... */` present in -`node_modules/left-pad/index.js`). Surprise #2: the `--immutable` run **rewrote -yarn.lock**, re-adding the checksum line (after/yarn.lock, tool-generated, is -byte-identical to the B3 lock). A missing checksum is "trust on first use + self-heal", -not an immutability violation. - -Tamper guards still hold (cold cache, intact committed lock): -- **Tampered tgz** (one byte of marker changed) → resolution recomputes `hash=b4fd84`, - post-resolution validation prints a lock diff and fails: - `➤ YN0028: │ The lockfile would have been modified by this install, which is explicitly forbidden.` EXIT=1. -- **Corrupted checksum hex** (present but wrong) → - `➤ YN0018: │ left-pad@file:...::hash=39ea9b&locator=...: The remote archive doesn't match the expected checksum` EXIT=1. - -So the verification chain for a committed vendored artifact is: lock `hash=` pins the tgz -bytes (sha512 prefix), lock `checksum:` pins the converted zip (full sha512). Omitting the -checksum weakens nothing for tamper-detection of the tgz (hash= still catches it) but we -should always emit the checksum anyway — it is reproducible (B2). - -## B2 — DECISIVE: checksum reproducible offline from the tgz. PASS. - -1. `sha512(cache zip) == lock checksum hex` — confirmed exactly: - `left-pad-file-8dfd6a0c16-10c0.zip` sha512 = `7785879d...8d7ca3` = the `10c0/` hex. -2. `rebuild_zip.py left-pad out.zip` (python stdlib, offline, no yarn) produces a - zip **byte-identical** (`cmp` clean) to yarn's cache zip. Verified for: - - yarn 4.12.0, macOS (this fixture) - - yarn 4.6.0: same lock checksum + same `hash=39ea9b` (cold cache, fresh install) - - TZ=Asia/Kathmandu fresh install: same checksum (DOS timestamps written as UTC, - not host-local time) - - `modeprobe.tgz` (files 0600/0664/0444/0755, dir 0700): byte-identical after - encoding yarn's mode normalization (below). - -### The recipe (everything that is in the zip, nothing else) -- **Name mapping**: strip first path component of each tar entry (`package/`), prefix - `node_modules//`. -- **Entry order**: tar order; parent directory entries are emitted on first need - (mkdirp): `node_modules/`, `node_modules//` appear before the first entry, - deeper dirs (e.g. `perf/`) at the tar position that first references them. -- **Compression**: stored (method 0) for every entry — that's the `c0` in `10c0`. -- **Timestamps**: every entry dosdate=0x08D6 dostime=0xAE40 = 1984-06-22 21:50:00, - i.e. yarn SAFE_TIME=456789000 rendered as UTC. No extended-timestamp extra field. -- **Modes (normalized by yarn, not copied from tar)**: files → `0o100644`, or - `0o100755` iff tar mode has any exec bit (0600→644, 0664→644, 0444→644, 0755→755); - dirs → always `0o40755` (0700→755). external_attr = mode << 16, low 16 bits 0 - (no MS-DOS dir bit). internal_attr = 0. -- **Local headers**: version-needed 10 (files) / 20 (dirs); flags 0x0000 (no data - descriptor, no UTF-8 flag for ASCII names); crc/sizes inline (0 for dirs); NO extra - field. -- **Central directory**: version-made-by 0x033F ((3<<8)|63 = UNIX, spec 6.3); NO extra - field, NO comments; one CDH per LFH in same order. -- **EOCD**: single disk, no zip64, no archive comment. Total file = LFHs+data, then - central dir, then EOCD. (left-pad zip: 11599 bytes, 13 entries.) -- **Checksum**: `10c0/` + sha512 hex of the whole zip file. (`10` = yarn's internal - CACHE_VERSION — not user-settable; bump risk on yarn major.) -- **Cache file name**: global cache `left-pad-file-8dfd6a0c16-10c0.zip`; project-local - mirror (`enableGlobalCache: false`) embeds the checksum head instead: - `left-pad-file-8dfd6a0c16-7785879d9a.zip`. - -## B4 — .yarnrc.yml knobs (fixtures/b4-compression-mixed/) - -- `compressionLevel: mixed` (or any non-zero) CHANGES the checksum AND the cache key: - lock gets `cacheKey: 10` and `checksum: 10/fdd30d4a91e92c85b92fd3f1757629b5c35516ab20058a1688a73181cd61dc03980cf5fbb7fe99d3af2a35b32fef2e07653173642e0839df9bd4b298f18fdb5d`; - files become deflate (method 8) where it helps. Reproducing THAT would require matching - yarn's embedded zlib bit-for-bit — do not support it; require/assume `compressionLevel: 0` - (the yarn 4 default) and treat a non-`10c0` cacheKey as "regenerate lock via yarn". -- `cacheVersion`: NOT a setting — `yarn config get cacheVersion` → - `Usage Error: Couldn't find a configuration settings named "cacheVersion"`. The `10` is - internal. -- Settings that exist but don't change fresh-install checksums: `cacheFolder`, - `enableGlobalCache`, `cacheMigrationMode`, `enableImmutableCache`. - -## B5 — strictest fresh-checkout proof: PASS - -Copied exactly `package.json + yarn.lock + .yarnrc.yml + .socket/` (B3 after/) into a -brand-new mktemp dir; `YARN_GLOBAL_FOLDER=`, `YARN_ENABLE_GLOBAL_CACHE=false`, -**and `YARN_ENABLE_NETWORK=false`** (stricter than asked): - -``` -$ yarn install --immutable --check-cache -➤ YN0000: · Yarn 4.12.0 -➤ YN0013: │ A package was added to the project (+ 11.32 KiB). -➤ YN0000: · Done in 0s 59ms -EXIT=0 -``` - -Marker present in `node_modules/left-pad/index.js`; the project-local -`.yarn/cache/left-pad-file-8dfd6a0c16-7785879d9a.zip` sha512 == lock checksum. Fully -offline: the file: protocol never touches the registry when the lock is complete. - -## Layout -- `rebuild_zip.py` — offline tgz→berry-cache-zip rebuilder (the recipe, executable). -- `fixtures/b3-vendored-resolutions/{before,after}` — registry project vs vendored - resolutions project; both locks yarn-generated. -- `fixtures/b1-omitted-checksum/{before,after}` — before = checksum line hand-deleted - (probe input); after = lock as yarn rewrote it under `--immutable` (self-healed). -- `fixtures/b2-zip-reproducibility/` — orig/patched/modeprobe tgz, yarn's cache zips, - and the byte-identical rebuilt zips (`cmp` verifiable: rebuilt-from-tgz.zip vs - yarn-cache-left-pad-file-8dfd6a0c16-10c0.zip; rebuilt-modeprobe.zip vs - yarn-cache-modeprobe-file-10c0.zip). -- `fixtures/b4-compression-mixed/{before,after}` — same project, compressionLevel - default(0) vs mixed; shows cacheKey `10c0` → `10` and checksum change. - -## Caveats for the design -- Lock key + resolution embed the root workspace name and relative tgz path; renaming the - package.json `name` or moving the tgz invalidates the entry (YN0028 under --immutable). -- The `10` cache version is yarn-internal and has historically bumped on yarn majors - (8→10 across v3→v4); the recipe must be re-validated per cacheKey, and a non-10c0 - cacheKey in the user's lock means "let yarn write the checksum" (which `--immutable` - tolerates for missing checksums per B1, but emit it ourselves when cacheKey is 10c0). -- nodeLinker: node-modules tested; pnp linker untested in this spike (cache/checksum - layer is linker-independent, but unverified here). -- Tarballs with symlink/hardlink entries untested (npm pack never emits them). diff --git a/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/after/yarn.lock b/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/after/yarn.lock deleted file mode 100644 index ea10f55..0000000 --- a/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/after/yarn.lock +++ /dev/null @@ -1,21 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10c0 - -"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": - version: 1.3.0 - resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." - checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 - languageName: node - linkType: hard - -"vendor-spike@workspace:.": - version: 0.0.0-use.local - resolution: "vendor-spike@workspace:." - dependencies: - left-pad: "npm:1.3.0" - languageName: unknown - linkType: soft diff --git a/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/.yarnrc.yml b/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/.yarnrc.yml deleted file mode 100644 index 22efd3d..0000000 --- a/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/.yarnrc.yml +++ /dev/null @@ -1,3 +0,0 @@ -nodeLinker: node-modules -enableGlobalCache: true -enableTelemetry: false diff --git a/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/package.json b/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/package.json deleted file mode 100644 index 11fd5af..0000000 --- a/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "vendor-spike", - "version": "1.0.0", - "packageManager": "yarn@4.12.0", - "dependencies": { - "left-pad": "1.3.0" - }, - "resolutions": { - "left-pad": "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" - } -} diff --git a/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/yarn.lock b/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/yarn.lock deleted file mode 100644 index c247723..0000000 --- a/spikes/yarn-berry-nm/fixtures/b1-omitted-checksum/before/yarn.lock +++ /dev/null @@ -1,20 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10c0 - -"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": - version: 1.3.0 - resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." - languageName: node - linkType: hard - -"vendor-spike@workspace:.": - version: 0.0.0-use.local - resolution: "vendor-spike@workspace:." - dependencies: - left-pad: "npm:1.3.0" - languageName: unknown - linkType: soft diff --git a/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/rebuilt-from-tgz.zip b/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/rebuilt-from-tgz.zip deleted file mode 100644 index a0b1e8023aed65d424bc95be5275b89bdad00333..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11599 zcmeHN&u=4F9Zy?|kfjv}Ar38+w=Sy3*?4Scv%j*z6jR3s$Ci36X{n|U*1Z@fvCA{7ZoZ3#yTRoxy+4cjUc+@5Fr>)^}!3{p$48zU)M`q2;JG zB?XY+zuJ2JkH7rl+#H{;&s-D9Ff#9l^?9+^b3-u<{BB?k1e%>dN)h^AKBidtO4TpW#w%UCu z`qs!4Z~L*ZEl&i}al=hXc`fFEvWjD8V&jtms^2w+3ucht+eFYflN z2B*=rIts%;3P(nk+Ye1KUoT;)piFiwN34LncVjn@ zM%k>Ft5?+^d0rfetfACn9lJQvvBIck_oRKu@e&sr73p*(I2X#WvLHIKXGf&Pu)|37ET*T6>i=4Kjjdx?KZ=^^Xm5%SXtpLz2Ru_bRJNc>$PqnJne5z#^ zfnl@qR7)>B{nA?V>DOMyQiZU9z9>eYoV8fnvHGDTz1ecKRVTErW5xXlhfjn<%a)Z> z0d5dzqmc=}(%`X=0%`HY;$Sq6h1XaR=NE($WV6|T0##ful3koro($+3A=(<(jV$DJEZcZ?G5j`*~)w=*ef5>r@%QV8Hu3<-*qyM5*ckpPD%q*{q&2LvPC)O zsd=7diy=Xpssz8&@d_R-TbHuzRIGk{xyHJ1AUwF6(;tdU#~CM>SfHO+C0O`loK`Zs zQd2^#9X~%k8WhL8S~E|^;H}Ya!d-jEZkPg~WKsYH2#wQ#pbOI=3NTKE#-$>R97v;a zDk#kcbvnak{=fZ6S>9|ka8@0%T&Wc!rXcoq0v{10Z1GBFW7DW+L3q{x4nc)NH@zgt z;VQ(dLr%9%wz$|pJ8?1wEDl~gD>ASl^$Krx&k8u8AyLcJ_A;a=RiMQARk(TA;Mx# zdt-NdE5#piuij&FVwG@&5lZPSt3@d$?BGyUq4?-%Lzq$mu|xUERSF^VbtxC!6ULTH zia`a@W%8|rkcc85X(wCm)b$K3;I0hpZvF@kFtW;D5 zs$W(5b6pgOU4hhz-m(e-`G&z`BmvAt+7 z$y(DnZ`GFYR=aF3I<-skf_0_cw9h*iI?QSRUmy7N>I0QHL}5kc;V{%m!N)`->(gFl z#~&t{*37oxjnKOfBLj=gZ;}4X{^yNCtJs`qPrgjOiAw1JJ8N5|tudUot zq*!o1w#T+aM$sagIW21KKDrM8u|OF4hj-;^S(igmPGspU!~p12B1|Jqnxww(4>2<8 zAr27+9ho*!;4tj6Pz)oV6nt;gHkXqL#QMBLY|t%zo}xa|0-$jP#rtWXJkv{6Z5glDO0mkNkPH}FT$9=~e^ zAbx=AV!uCTV5K(J$VeO^5xE24Lm2{2x`R_j-I8jk&>Sf-YQ=&$vsn;(Qr!f5LU-Wy z!BTLi10uja8FcF7er5bwiMe2&@{Yi;!;zERmwKWxs1~Y zb>u^67ZFSaCWJ&BBHF0X9Fkj%@PHDf+k#TG&f-0FG3-k<<fpAe(*D`~8?g)Z-;Jy&4iEODzZSx(6!YFp+z`}&`v98nJ7PxBnoCq z@`-oI%wE)k#;Zdf1PBD7Mq^ab!x*LYF-qkqL`R6GZ~`v(NFlrhSb^gR~rq1ZkKKjwS|9oa{j?e77o3-tow>P)G zMJuX5>)Yb)>#KWW@AVCF^Um6v;_dA_;_m7edbY*Rof~g$t_eIFTe}-)U@ad16s|_r zr7h{s#A2hdbf$#)YyNN?xZPePjI}Ddn@eK#JxqAh|J|?Rek@+K@b+5ZTTUBA^ZFb=(mg!k!`>@V)mT{xaE6UKVA2`pXP3%Mbvs$2ib5 z&zp^;*O8$exI?;Mj!e0NWDXrW2X)qw^hV>M4D)WsjKX9f0mva@gPnQ7#fiKM73zfu zVW15s+RH|uLY+FlKuH-B9x1dP`CwfRk#u7&G$n_s7Bk@?fbTAv&6Fg-EnKi+tYJ3H z%L+B(!dp3X&gVNE%JIk#9D?3Y@FL^j2=yAi>UN3cOf8HZcQ*|Fql)w+16l$(bu}{;L+!{Nsy8dmY!Jdes}NY&RgZubZGMG z{a?Ib!sPK0GqpHTE@}}x`yR+-Z0~w5H8Z)U(dsOk=Azl4=SD5=zOO zdI4m6ye!`7ThXB}Rz1h}{0HHoyTEoJN8)XiFBSwmr04Wu3vc38nWA?rTYM)jl^kg! zV3b+?M+Y!WsZ`AYy70XuaZuH*5{x zBx<635qg4u7m|NV$-jo#Tr5vHMfY#>FZ~@G@_B-dqixtmqz&6JnQ1?sYG-Ng5$P?Y z1LpBN$>E^POv{eWOw)M&hy)bNIQYaYlgS^1oo|$8X4tP*)2Ug4kD7(%RltRivv^;1 zO&_PdcYgoZpQAd&=NN{i#&nWlOdb6#;Fbm2rs6ECQXG@v#oNkwO-{+W^*lQc0>->W zKxUnz3d{}KcADqngv@4V(-H=ht+wry)w)C+DD4*u7HwuDyRnPkk{GyXMgG!K~>mDO{f;FptLX&8A_%Cg$;z*ZqtI8@ByI#m{k*{*bStXMy^FX zikZ9!&V=DN*qfIHg`sC4A@Ra}jvko&am?885PPS%Qosm*(yjGT>Z1jMYjophPQ7rUj z+N6fo5>-Nfs#NOkA{V+*sa;bEF|U}?J4%F^)E#6e)R-kQjw-QVp(^36*+}ka52_~0 z_y*W>B)k)Y12h(Lo(S&BiJ{?w?pSCfc9Kf;Z;o_ysS3d1l1{k{`O~9?v zF@!fi3z*`!D#v4bPKhZ_!HbAp9&u3Sd*GTSV-yV60>vkKkOG% zGh+A5Bxq*QmzSZ5_hW?f%(K^@J37YUFD&)vlU24RnDwNk{KcjIe6o5D&55Nt+QjcK zxqW^^GyMxp4dcTP?#+Zj8w&|dgPOkV$w6KJ(kygz5;hHMx&WBN8iCTINz2NC)4--H z6*;gMUzi0f&55T$P3N#VsJFg03sf^fZ;zQE?8gf$02bDivO7j64l*I%v${S%@K@051(8 ir{PVTs2tv!@kDqej6)-~J8q*Efm) diff --git a/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/rebuilt-modeprobe.zip b/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/rebuilt-modeprobe.zip deleted file mode 100644 index fbc7645e1ee8560464bb05a4a05ee3274dd83c0e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1012 zcmWIWW@Zs#00D<}*EqloD8UP)^YT+t<8$*2@bp%mpk&Rm`D7sYs+-Ug1I(Z$jn8l!gq$?cJ&7Lc2m}2Xak~tb j2GC7Ik934-bMcr4O9BDjtZX11>_Er}OgDFddKef0ilp?C diff --git a/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-left-pad-file-8dfd6a0c16-10c0.zip b/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-left-pad-file-8dfd6a0c16-10c0.zip deleted file mode 100644 index a0b1e8023aed65d424bc95be5275b89bdad00333..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11599 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 ir{PVTs2tv!@kDqej6)-~J8q*Efm) diff --git a/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-modeprobe-file-10c0.zip b/spikes/yarn-berry-nm/fixtures/b2-zip-reproducibility/yarn-cache-modeprobe-file-10c0.zip deleted file mode 100644 index fbc7645e1ee8560464bb05a4a05ee3274dd83c0e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1012 zcmWIWW@Zs#00D<}*EqloD8UP)^YT+t<8$*2@bp%mpk&Rm`D7sYs+-Ug1I(Z$jn8l!gq$?cJ&7Lc2m}2Xak~tb j2GC7Ik934-bMcr4O9BDjtZX11>_Er}OgDFddKef0ilp?C diff --git a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/.yarnrc.yml b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/.yarnrc.yml deleted file mode 100644 index 22efd3d..0000000 --- a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/.yarnrc.yml +++ /dev/null @@ -1,3 +0,0 @@ -nodeLinker: node-modules -enableGlobalCache: true -enableTelemetry: false diff --git a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/package.json b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/package.json deleted file mode 100644 index 11fd5af..0000000 --- a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "vendor-spike", - "version": "1.0.0", - "packageManager": "yarn@4.12.0", - "dependencies": { - "left-pad": "1.3.0" - }, - "resolutions": { - "left-pad": "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" - } -} diff --git a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/yarn.lock b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/yarn.lock deleted file mode 100644 index ea10f55..0000000 --- a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/after/yarn.lock +++ /dev/null @@ -1,21 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10c0 - -"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": - version: 1.3.0 - resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." - checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 - languageName: node - linkType: hard - -"vendor-spike@workspace:.": - version: 0.0.0-use.local - resolution: "vendor-spike@workspace:." - dependencies: - left-pad: "npm:1.3.0" - languageName: unknown - linkType: soft diff --git a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/.yarnrc.yml b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/.yarnrc.yml deleted file mode 100644 index 22efd3d..0000000 --- a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/.yarnrc.yml +++ /dev/null @@ -1,3 +0,0 @@ -nodeLinker: node-modules -enableGlobalCache: true -enableTelemetry: false diff --git a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/package.json b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/package.json deleted file mode 100644 index 712413b..0000000 --- a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "vendor-spike", - "version": "1.0.0", - "packageManager": "yarn@4.12.0", - "dependencies": { - "left-pad": "1.3.0" - } -} diff --git a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/yarn.lock b/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/yarn.lock deleted file mode 100644 index a328cb0..0000000 --- a/spikes/yarn-berry-nm/fixtures/b3-vendored-resolutions/before/yarn.lock +++ /dev/null @@ -1,21 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10c0 - -"left-pad@npm:1.3.0": - version: 1.3.0 - resolution: "left-pad@npm:1.3.0" - checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955 - languageName: node - linkType: hard - -"vendor-spike@workspace:.": - version: 0.0.0-use.local - resolution: "vendor-spike@workspace:." - dependencies: - left-pad: "npm:1.3.0" - languageName: unknown - linkType: soft diff --git a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/.yarnrc.yml b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/.yarnrc.yml deleted file mode 100644 index c4d8047..0000000 --- a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/.yarnrc.yml +++ /dev/null @@ -1,4 +0,0 @@ -nodeLinker: node-modules -enableGlobalCache: true -enableTelemetry: false -compressionLevel: mixed diff --git a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/package.json b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/package.json deleted file mode 100644 index 11fd5af..0000000 --- a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "vendor-spike", - "version": "1.0.0", - "packageManager": "yarn@4.12.0", - "dependencies": { - "left-pad": "1.3.0" - }, - "resolutions": { - "left-pad": "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" - } -} diff --git a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/yarn.lock b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/yarn.lock deleted file mode 100644 index 8c7cdc2..0000000 --- a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/after/yarn.lock +++ /dev/null @@ -1,21 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10 - -"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": - version: 1.3.0 - resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." - checksum: 10/fdd30d4a91e92c85b92fd3f1757629b5c35516ab20058a1688a73181cd61dc03980cf5fbb7fe99d3af2a35b32fef2e07653173642e0839df9bd4b298f18fdb5d - languageName: node - linkType: hard - -"vendor-spike@workspace:.": - version: 0.0.0-use.local - resolution: "vendor-spike@workspace:." - dependencies: - left-pad: "npm:1.3.0" - languageName: unknown - linkType: soft diff --git a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/.yarnrc.yml b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/.yarnrc.yml deleted file mode 100644 index 22efd3d..0000000 --- a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/.yarnrc.yml +++ /dev/null @@ -1,3 +0,0 @@ -nodeLinker: node-modules -enableGlobalCache: true -enableTelemetry: false diff --git a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/package.json b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/package.json deleted file mode 100644 index 11fd5af..0000000 --- a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "vendor-spike", - "version": "1.0.0", - "packageManager": "yarn@4.12.0", - "dependencies": { - "left-pad": "1.3.0" - }, - "resolutions": { - "left-pad": "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz" - } -} diff --git a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/yarn.lock b/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/yarn.lock deleted file mode 100644 index ea10f55..0000000 --- a/spikes/yarn-berry-nm/fixtures/b4-compression-mixed/before/yarn.lock +++ /dev/null @@ -1,21 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10c0 - -"left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::locator=vendor-spike%40workspace%3A.": - version: 1.3.0 - resolution: "left-pad@file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz::hash=39ea9b&locator=vendor-spike%40workspace%3A." - checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 - languageName: node - linkType: hard - -"vendor-spike@workspace:.": - version: 0.0.0-use.local - resolution: "vendor-spike@workspace:." - dependencies: - left-pad: "npm:1.3.0" - languageName: unknown - linkType: soft diff --git a/spikes/yarn-berry-nm/rebuild_zip.py b/spikes/yarn-berry-nm/rebuild_zip.py deleted file mode 100644 index 32ea3fe..0000000 --- a/spikes/yarn-berry-nm/rebuild_zip.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env python3 -"""Rebuild a yarn berry 4.x (cacheKey 10c0, compressionLevel 0) cache zip -from an npm-style tarball, byte-identically. Offline; stdlib only. - -Usage: rebuild_zip.py - -Recipe (empirically derived from yarn 4.12.0 cache zips, libzip-wasm output): -- Entry order : tar order. Parent dirs are emitted on first need (mkdirp), - i.e. `node_modules/` + `node_modules//` appear before - the first entry that needs them; deeper dirs appear at the - tar position that first references them. -- Name mapping : strip the first path component of each tar entry (npm uses - `package/`), prefix with `node_modules//`. -- Compression : 0 (stored) for every entry -> cacheKey suffix `c0`. -- mtime : DOS time of 1984-06-22 21:50:00 (yarn SAFE_TIME=456789000), - written as UTC -> dosdate=0x08D6 dostime=0xAE40. -- Flags : 0x0000 (no data descriptor, no UTF-8 flag for ASCII names). -- Local header : version-needed = 10 for files, 20 for directories; - no extra field, sizes+crc inline (crc=0/sizes=0 for dirs). -- Central dir : version-made-by = 0x033F (UNIX, spec 6.3 -> (3<<8)|63); - internal attrs = 0; external attrs = (unix mode) << 16, - files NORMALIZED to 0o100644, or 0o100755 if tar mode has - any exec bit (yarn discards other perm bits); dirs always - 0o40755 regardless of tar mode; - no extra field, no comment. -- EOCD : single disk, no zip64, no archive comment. -""" -import sys, tarfile, struct - -DOSTIME = 0xAE40 # 21:50:00 -DOSDATE = 0x08D6 # 1984-06-22 - -def rebuild(tgz_path, pkg_name, out_path): - prefix = f"node_modules/{pkg_name}" - entries = [] # (name, is_dir, mode, data) - seen_dirs = set() - - def mkdirp(dirpath): # dirpath WITHOUT trailing slash - parts = dirpath.split('/') - for i in range(1, len(parts) + 1): - d = '/'.join(parts[:i]) + '/' - if d not in seen_dirs: - seen_dirs.add(d) - entries.append((d, True, 0o40755, b'')) - - with tarfile.open(tgz_path, 'r:gz') as tf: - for m in tf: - stripped = '/'.join(m.name.split('/')[1:]).rstrip('/') - if m.isdir(): - mkdirp(prefix + ('/' + stripped if stripped else '')) - elif m.isfile(): - target = f"{prefix}/{stripped}" - mkdirp(target.rsplit('/', 1)[0]) - data = tf.extractfile(m).read() - mode = 0o100755 if (m.mode & 0o111) else 0o100644 - entries.append((target, False, mode, data)) - - import zlib - blob = bytearray(); central = bytearray(); offsets = [] - for name, is_dir, mode, data in entries: - offsets.append(len(blob)) - crc = 0 if is_dir else zlib.crc32(data) & 0xFFFFFFFF - vneed = 20 if is_dir else 10 - nb = name.encode() - blob += struct.pack('<4sHHHHHIIIHH', b'PK\x03\x04', vneed, 0, 0, - DOSTIME, DOSDATE, crc, len(data), len(data), - len(nb), 0) + nb + data - for (name, is_dir, mode, data), lho in zip(entries, offsets): - crc = 0 if is_dir else zlib.crc32(data) & 0xFFFFFFFF - vneed = 20 if is_dir else 10 - nb = name.encode() - central += struct.pack('<4sHHHHHHIIIHHHHHII', b'PK\x01\x02', 0x033F, - vneed, 0, 0, DOSTIME, DOSDATE, crc, len(data), - len(data), len(nb), 0, 0, 0, 0, mode << 16, - lho) + nb - eocd = struct.pack('<4sHHHHIIH', b'PK\x05\x06', 0, 0, len(entries), - len(entries), len(central), len(blob), 0) - with open(out_path, 'wb') as f: - f.write(blob + central + eocd) - -if __name__ == '__main__': - rebuild(sys.argv[1], sys.argv[2], sys.argv[3]) diff --git a/spikes/yarn-classic/README.md b/spikes/yarn-classic/README.md deleted file mode 100644 index 099a370..0000000 --- a/spikes/yarn-classic/README.md +++ /dev/null @@ -1,134 +0,0 @@ -# yarn classic (1.22.x) vendored-tarball spike fixtures - -Spike for socket-patch vendor v2: can a lock-only rewrite make yarn classic install a -patched, committed tarball from `.socket/vendor/npm//-.tgz`, -checksum-verified, with cold caches, offline? - -**Answer: yes.** All claims confirmed (Y5 with one caveat about the unsatisfiable -`^1.3.2` range, see below). - -## Tool versions - -- node v24.12.0 (Darwin 25.5.0, arm64) -- corepack 0.34.5 -- yarn 1.22.22 (via `corepack yarn`, pinned by `"packageManager": "yarn@1.22.22"`) -- yarn 4.12.0 (berry sniff fixture only) -- patched tarball built with macOS bsdtar (`tar czf`, `COPYFILE_DISABLE=1`), - digests via `shasum -a 1` / `openssl dgst -sha512 -binary | base64` - -## The patched artifact - -`left-pad@1.3.0` from registry.npmjs.org, unpacked, marker line -`/* SOCKET-PATCHED left-pad@1.3.0 marker:9f6b2c4e */` prepended to -`package/index.js`, repacked with `package/` prefix. - -- registry tgz sha1: `5b8a3a7765dfe001261dde915589e782f8c94d1e` -- patched tgz sha1: `fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6` -- patched tgz sha512 SRI: `sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw==` - -## Lockfile entry recipe (the v2 rewrite) - -``` -left-pad@^1.3.0: - version "1.3.0" - resolved "file:./.socket/vendor/npm//left-pad-1.3.0.tgz#" - integrity sha512- -``` - -- `resolved` spellings that work: `file:./#` and `./#`. - A path with **no** `./`/`file:` prefix does NOT work: yarn treats it as - registry-relative and requests `https://registry.yarnpkg.com/.socket/...` (404). -- The `#` fragment is the sha1 of the tgz bytes; yarn enforces it even when - the `integrity` line is absent (substituting a wrong tarball fails - `Integrity check failed` either way). -- yarn's own serializer round-trips this entry byte-for-byte (verified by forcing a - lock re-save with `yarn add isarray@2.0.5` + `yarn remove isarray`): every - `after/yarn.lock` here was emitted by yarn 1.22.22 itself, not hand-written. - -## Fixture pairs - -### y1-file-dep-ground-truth/ -Ground truth for how yarn classic natively records a `file:` tarball dep. -`package.json` has `"lp": "file:./lp.tgz"` (lp.tgz = the patched tarball); -`yarn.lock` is exactly what `yarn install` wrote: - -``` -"lp@file:./lp.tgz": - version "1.3.0" - resolved "file:./lp.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" -``` - -Key shape `"@file:./"`, `file:` prefix kept in `resolved`, `#sha1` -fragment present, **no `integrity` line** for native file: deps. - -### y2-lock-rewrite/ (before -> after) -- `before/`: registry project (`left-pad: ^1.3.0`), yarn-generated lock pointing at - `https://registry.yarnpkg.com/...#5b8a3a...` with the registry sha512. -- `after/`: same package.json; lock's left-pad block rewritten to the recipe above; - patched tarball committed at - `.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz`. - -Replay: `rm -rf node_modules && YARN_CACHE_FOLDER=$(mktemp -d) corepack yarn install --offline --frozen-lockfile` --> exit 0, `node_modules/left-pad/index.js` carries the marker, yarn.lock -byte-unchanged. Also passes with HTTP(S)_PROXY pointed at a dead port (zero network). - -### y4-tamper/ (failure fixture) -`after/` is y2's after but `.socket/.../left-pad-1.3.0.tgz` is the **unpatched -registry tarball** (valid gzip, wrong hashes). Frozen install MUST fail, exit 1: - -``` -error Integrity check failed for "left-pad" (computed integrity doesn't match our records, got "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== sha1-W4o6d2Xf4AEmHd6RVYnngvjJTR4=") -``` - -(A raw byte-flip also fails, exit 1, but earlier and uglier — gzip error -`"invalid distance too far back". Mirror tarball appears to be corrupt.`) - -### y5-merged-alias/ (before -> after) -- `before/`: root deps `left-pad: ^1.3.0`, `alias: npm:left-pad@^1.3.0`, and a - folder dep `dep-a` (file:./dep-a) requiring `left-pad: ~1.3.0`, so yarn itself - generates a **merged** block `left-pad@^1.3.0, left-pad@~1.3.0:` plus a separate - alias block `"alias@npm:left-pad@^1.3.0":`. -- `after/`: both blocks' resolved+integrity rewritten to the vendored tarball - (then re-serialized by yarn, byte-identical). - -Replay: both `node_modules/left-pad` and `node_modules/alias` (an aliased copy of -left-pad@1.3.0) carry the marker; lock unchanged. - -Caveat: the claim's literal `left-pad@^1.3.2` range is unsatisfiable (1.3.0 is the -last left-pad ever published), so the merged block was generated with -`^1.3.0, ~1.3.0` instead. Merging behavior is the same: one block, N keys, one -resolved — a single rewrite patches every requester. - -### y8-berry-sniff/ -yarn 4.12.0 project with `nodeLinker: node-modules`. Its yarn.lock starts with a -generated-file comment + `__metadata:` (version 8, cacheKey 10c0) and contains -**no** `# yarn lockfile v1` header. Classic locks always carry -`# yarn lockfile v1`. Sniff rule: `__metadata:` => berry (different rewrite -strategy needed — berry verifies `checksum:` against its own cache format); -`# yarn lockfile v1` => classic (this recipe applies). - -## Behavioral claims verified without a dedicated fixture dir - -- **Y3 warm-cache poisoning**: cache primed with the registry tarball - (`v6/npm-left-pad-1.3.0-5b8a3a...-integrity`), then the vendored install run - against the same cache -> patched bytes installed. Cache entries are keyed - `npm----integrity`, so registry and vendored artifacts get - distinct slots; no poisoning either direction. -- **Y6 offline fresh checkout**: copying only package.json + yarn.lock + .socket - into an empty dir, empty cache, `--offline --frozen-lockfile` -> exit 0, patched. - Re-verified with dead HTTP(S)_PROXY: no network touched for the file: dep. -- **Y7 resolution base**: `corepack yarn --cwd install --frozen-lockfile` - run from an unrelated directory containing a decoy unpatched tarball at the same - relative path -> the decoy is ignored and the project's tarball is used (relative - `resolved` resolves against the project/lockfile dir, not process cwd). Running - from a nested subdir of the project (no --cwd) also resolves correctly. - -## Notes for the tool design - -- Write `resolved "file:./#"` + `integrity sha512-...`. Both hash - layers are enforced by yarn classic on every install, frozen or not, warm or - cold cache. -- `--frozen-lockfile` does not rewrite the lock; a plain `yarn install` keeps the - entry stable, and forced re-serialization preserves it byte-for-byte. -- The lock-only rewrite leaves package.json untouched (`left-pad@^1.3.0` range key - still matches), so no manifest churn. diff --git a/spikes/yarn-classic/y1-file-dep-ground-truth/package.json b/spikes/yarn-classic/y1-file-dep-ground-truth/package.json deleted file mode 100644 index ffc393b..0000000 --- a/spikes/yarn-classic/y1-file-dep-ground-truth/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"t","version":"1.0.0","packageManager":"yarn@1.22.22","dependencies":{"lp":"file:./lp.tgz"}} diff --git a/spikes/yarn-classic/y1-file-dep-ground-truth/yarn.lock b/spikes/yarn-classic/y1-file-dep-ground-truth/yarn.lock deleted file mode 100644 index fda8a6e..0000000 --- a/spikes/yarn-classic/y1-file-dep-ground-truth/yarn.lock +++ /dev/null @@ -1,7 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"lp@file:./lp.tgz": - version "1.3.0" - resolved "file:./lp.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" diff --git a/spikes/yarn-classic/y2-lock-rewrite/after/package.json b/spikes/yarn-classic/y2-lock-rewrite/after/package.json deleted file mode 100644 index cd59d88..0000000 --- a/spikes/yarn-classic/y2-lock-rewrite/after/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "t", - "version": "1.0.0", - "packageManager": "yarn@1.22.22", - "dependencies": { - "left-pad": "^1.3.0" - } -} diff --git a/spikes/yarn-classic/y2-lock-rewrite/after/yarn.lock b/spikes/yarn-classic/y2-lock-rewrite/after/yarn.lock deleted file mode 100644 index 36a6bd3..0000000 --- a/spikes/yarn-classic/y2-lock-rewrite/after/yarn.lock +++ /dev/null @@ -1,8 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -left-pad@^1.3.0: - version "1.3.0" - resolved "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" - integrity sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw== diff --git a/spikes/yarn-classic/y2-lock-rewrite/before/package.json b/spikes/yarn-classic/y2-lock-rewrite/before/package.json deleted file mode 100644 index 48e0783..0000000 --- a/spikes/yarn-classic/y2-lock-rewrite/before/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"t","version":"1.0.0","packageManager":"yarn@1.22.22","dependencies":{"left-pad":"^1.3.0"}} diff --git a/spikes/yarn-classic/y2-lock-rewrite/before/yarn.lock b/spikes/yarn-classic/y2-lock-rewrite/before/yarn.lock deleted file mode 100644 index 9660884..0000000 --- a/spikes/yarn-classic/y2-lock-rewrite/before/yarn.lock +++ /dev/null @@ -1,8 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -left-pad@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" - integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== diff --git a/spikes/yarn-classic/y4-tamper/after/package.json b/spikes/yarn-classic/y4-tamper/after/package.json deleted file mode 100644 index cd59d88..0000000 --- a/spikes/yarn-classic/y4-tamper/after/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "t", - "version": "1.0.0", - "packageManager": "yarn@1.22.22", - "dependencies": { - "left-pad": "^1.3.0" - } -} diff --git a/spikes/yarn-classic/y4-tamper/after/yarn.lock b/spikes/yarn-classic/y4-tamper/after/yarn.lock deleted file mode 100644 index 36a6bd3..0000000 --- a/spikes/yarn-classic/y4-tamper/after/yarn.lock +++ /dev/null @@ -1,8 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -left-pad@^1.3.0: - version "1.3.0" - resolved "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" - integrity sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw== diff --git a/spikes/yarn-classic/y5-merged-alias/after/dep-a/package.json b/spikes/yarn-classic/y5-merged-alias/after/dep-a/package.json deleted file mode 100644 index 5cf9b11..0000000 --- a/spikes/yarn-classic/y5-merged-alias/after/dep-a/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"dep-a","version":"1.0.0","dependencies":{"left-pad":"~1.3.0"}} diff --git a/spikes/yarn-classic/y5-merged-alias/after/package.json b/spikes/yarn-classic/y5-merged-alias/after/package.json deleted file mode 100644 index 5ef79af..0000000 --- a/spikes/yarn-classic/y5-merged-alias/after/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "t", - "version": "1.0.0", - "packageManager": "yarn@1.22.22", - "dependencies": { - "alias": "npm:left-pad@^1.3.0", - "dep-a": "file:./dep-a", - "left-pad": "^1.3.0" - } -} diff --git a/spikes/yarn-classic/y5-merged-alias/after/yarn.lock b/spikes/yarn-classic/y5-merged-alias/after/yarn.lock deleted file mode 100644 index 7867bbf..0000000 --- a/spikes/yarn-classic/y5-merged-alias/after/yarn.lock +++ /dev/null @@ -1,18 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"alias@npm:left-pad@^1.3.0": - version "1.3.0" - resolved "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" - integrity sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw== - -"dep-a@file:./dep-a": - version "1.0.0" - dependencies: - left-pad "~1.3.0" - -left-pad@^1.3.0, left-pad@~1.3.0: - version "1.3.0" - resolved "file:./.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz#fa4cc6e38a9a5bc17a402e910ac6270a16a0e2b6" - integrity sha512-AhUdVqx1bsqgzQOo7owaHwAHqwHbpwHo4Y1U27ucyBdZn2KxEEzoT9kYGApl8gO3eu5oY2TceRVcmbgLXXRmPw== diff --git a/spikes/yarn-classic/y5-merged-alias/before/dep-a/package.json b/spikes/yarn-classic/y5-merged-alias/before/dep-a/package.json deleted file mode 100644 index 5cf9b11..0000000 --- a/spikes/yarn-classic/y5-merged-alias/before/dep-a/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"dep-a","version":"1.0.0","dependencies":{"left-pad":"~1.3.0"}} diff --git a/spikes/yarn-classic/y5-merged-alias/before/package.json b/spikes/yarn-classic/y5-merged-alias/before/package.json deleted file mode 100644 index e08815e..0000000 --- a/spikes/yarn-classic/y5-merged-alias/before/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"t","version":"1.0.0","packageManager":"yarn@1.22.22","dependencies":{"left-pad":"^1.3.0","alias":"npm:left-pad@^1.3.0","dep-a":"file:./dep-a"}} diff --git a/spikes/yarn-classic/y5-merged-alias/before/yarn.lock b/spikes/yarn-classic/y5-merged-alias/before/yarn.lock deleted file mode 100644 index 82dbc36..0000000 --- a/spikes/yarn-classic/y5-merged-alias/before/yarn.lock +++ /dev/null @@ -1,18 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"alias@npm:left-pad@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" - integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== - -"dep-a@file:./dep-a": - version "1.0.0" - dependencies: - left-pad "~1.3.0" - -left-pad@^1.3.0, left-pad@~1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" - integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== diff --git a/spikes/yarn-classic/y8-berry-sniff/.yarnrc.yml b/spikes/yarn-classic/y8-berry-sniff/.yarnrc.yml deleted file mode 100644 index 3186f3f..0000000 --- a/spikes/yarn-classic/y8-berry-sniff/.yarnrc.yml +++ /dev/null @@ -1 +0,0 @@ -nodeLinker: node-modules diff --git a/spikes/yarn-classic/y8-berry-sniff/package.json b/spikes/yarn-classic/y8-berry-sniff/package.json deleted file mode 100644 index 88f1881..0000000 --- a/spikes/yarn-classic/y8-berry-sniff/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "t8", - "version": "1.0.0", - "packageManager": "yarn@4.12.0", - "dependencies": { - "left-pad": "^1.3.0" - } -} diff --git a/spikes/yarn-classic/y8-berry-sniff/yarn.lock b/spikes/yarn-classic/y8-berry-sniff/yarn.lock deleted file mode 100644 index 73a20d1..0000000 --- a/spikes/yarn-classic/y8-berry-sniff/yarn.lock +++ /dev/null @@ -1,21 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10c0 - -"left-pad@npm:^1.3.0": - version: 1.3.0 - resolution: "left-pad@npm:1.3.0" - checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955 - languageName: node - linkType: hard - -"t8@workspace:.": - version: 0.0.0-use.local - resolution: "t8@workspace:." - dependencies: - left-pad: "npm:^1.3.0" - languageName: unknown - linkType: soft From 781eb8ed1303721c237e507b98b53a1d9f3aeca1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 12:36:58 -0400 Subject: [PATCH 29/31] fix(clippy): clear the CI clippy gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lints promoted to errors under `cargo clippy --workspace --all-features -- -D warnings` (the failing CI job): - `large_enum_variant` on `VendorOutcome`: `Done` dwarfs `Refused` because it carries an `ApplyResult` + `Option`. The asymmetry is harmless — it's a one-shot return value, never stored in a collection or hot loop — so `#[allow]` with a justifying comment beats spraying `Box`/deref churn across every backend and router. - `unnecessary_unwrap` in vendor's embedded-VEX path: `is_some()` + `unwrap()` replaced with `if let Some(vex_path)`. Co-Authored-By: Claude Fable 5 --- .../socket-patch-cli/src/commands/vendor.rs | 28 ++++++++++--------- .../socket-patch-core/src/patch/vendor/mod.rs | 9 ++++++ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 4c9b72c..e7fab73 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -300,19 +300,21 @@ pub async fn run(args: VendorArgs) -> i32 { // Embedded VEX: same contract as `apply --vex` — only on success, and a // requested-but-failed VEX flips the exit code. let mut exit = exit; - if exit == 0 && !args.revert && args.vex.vex.is_some() { - let params = args.vex.to_build_params(); - match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { - Ok(summary) => { - env.vex = Some(VexSummary { - path: args.vex.vex.as_ref().unwrap().display().to_string(), - statements: summary.statements, - format: "openvex-0.2.0".to_string(), - }); - } - Err(e) => { - env.mark_error(EnvelopeError::new(e.code, e.message.clone())); - exit = 1; + if exit == 0 && !args.revert { + if let Some(vex_path) = args.vex.vex.as_ref() { + let params = args.vex.to_build_params(); + match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { + Ok(summary) => { + env.vex = Some(VexSummary { + path: vex_path.display().to_string(), + statements: summary.statements, + format: "openvex-0.2.0".to_string(), + }); + } + Err(e) => { + env.mark_error(EnvelopeError::new(e.code, e.message.clone())); + exit = 1; + } } } } diff --git a/crates/socket-patch-core/src/patch/vendor/mod.rs b/crates/socket-patch-core/src/patch/vendor/mod.rs index 433a37a..76055bd 100644 --- a/crates/socket-patch-core/src/patch/vendor/mod.rs +++ b/crates/socket-patch-core/src/patch/vendor/mod.rs @@ -91,6 +91,15 @@ impl VendorWarning { } /// The result of one backend `vendor_*` call. +// +// `large_enum_variant`: `Done` is much bigger than `Refused` because it carries +// the full `ApplyResult` plus an `Option` (which itself holds the +// per-ecosystem `*Meta` records). That asymmetry is harmless here — a +// `VendorOutcome` is a one-shot return value, built once per backend call and +// consumed immediately by the router; it is never stored in a collection or a +// hot loop. Boxing both large fields (what the lint asks for) would only spray +// deref churn across every backend, router, and the CLI for no runtime benefit. +#[allow(clippy::large_enum_variant)] #[derive(Debug)] pub enum VendorOutcome { /// Refused before any write (wrong package manager, unsupported lockfile From 9de9d7725843443401e18cb48b8161b2105d9d27 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 12:55:05 -0400 Subject: [PATCH 30/31] style(clippy): clear remaining --all-targets lints surfaced by rust 1.93 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's clippy job only lints `--workspace --all-features` (lib + bins), but the 1.93 toolchain bump surfaces warn-level lints under `--all-targets` in this PR's files. Cleared so the whole workspace passes `cargo clippy --workspace --all-features --all-targets -- -D warnings`. Machine-applicable (via `cargo clippy --fix`): `map_or(false,..)`→`is_some_and`, `map_or(true,..)`→`is_none_or`, `% n == 0`→`is_multiple_of`, needless borrow/closure removals, struct-field shorthand, `iter().any(==)`→`contains`, `&[x.clone()]`→`std::slice::from_ref(&x)`. Hand fixes where the mechanical suggestion was wrong or lossy: snake_case test fn names; doc paragraphs split from a preceding list with a blank `///` line (indenting would render as a code block); a doc line starting with `+` reworded so markdown doesn't parse it as a nested bullet; `type_complexity` allowed file-wide in the fn-pointer case-table test; and `default_constructed_unit_structs` allowed on the two `*_default_and_new_construct_cleanly` tests whose whole point is to exercise `::default()`. Also clears the two pre-existing `deno_crawler`/`crawler_go_e2e` sites opportunistically (same toolchain-surfaced lints). Co-Authored-By: Claude Fable 5 --- .../socket-patch-cli/tests/api_client_errors_e2e.rs | 4 ++-- crates/socket-patch-cli/tests/cli_global_args.rs | 4 ++++ crates/socket-patch-cli/tests/e2e_gem.rs | 7 +++---- crates/socket-patch-cli/tests/e2e_npm.rs | 2 +- crates/socket-patch-cli/tests/e2e_pypi.rs | 2 +- .../socket-patch-cli/tests/e2e_safety_cargo_build.rs | 6 +++--- crates/socket-patch-cli/tests/e2e_safety_cow.rs | 1 + .../socket-patch-cli/tests/ecosystem_dispatch_e2e.rs | 4 ++-- crates/socket-patch-cli/tests/global_packages_e2e.rs | 8 ++++---- .../socket-patch-cli/tests/in_process_python_envs.rs | 2 +- crates/socket-patch-cli/tests/repair_invariants.rs | 3 ++- crates/socket-patch-core/src/api/blob_fetcher.rs | 2 +- crates/socket-patch-core/src/crawlers/deno_crawler.rs | 3 +++ crates/socket-patch-core/src/crawlers/go_crawler.rs | 2 +- crates/socket-patch-core/src/manifest/schema.rs | 2 +- .../src/patch/vendor/toml_surgery.rs | 11 +++-------- crates/socket-patch-core/src/utils/telemetry.rs | 2 +- crates/socket-patch-core/src/vex/conformance_tests.rs | 1 + crates/socket-patch-core/src/vex/product.rs | 6 +++--- crates/socket-patch-core/tests/crawler_cargo_e2e.rs | 2 +- crates/socket-patch-core/tests/crawler_go_e2e.rs | 5 ++++- crates/socket-patch-core/tests/crawler_maven_e2e.rs | 6 +++--- crates/socket-patch-core/tests/crawler_npm_e2e.rs | 4 ++-- crates/socket-patch-core/tests/crawler_nuget_e2e.rs | 2 +- crates/socket-patch-core/tests/crawler_python_e2e.rs | 2 +- crates/socket-patch-core/tests/crawler_ruby_e2e.rs | 4 ++-- 26 files changed, 52 insertions(+), 45 deletions(-) diff --git a/crates/socket-patch-cli/tests/api_client_errors_e2e.rs b/crates/socket-patch-cli/tests/api_client_errors_e2e.rs index f977209..d58abe8 100644 --- a/crates/socket-patch-cli/tests/api_client_errors_e2e.rs +++ b/crates/socket-patch-cli/tests/api_client_errors_e2e.rs @@ -527,7 +527,7 @@ async fn repair_with_blob_404_marks_failure_in_summary() { "--download-only", ]) .current_dir(tmp.path()) - .env("SOCKET_API_URL", &mock.uri()) + .env("SOCKET_API_URL", mock.uri()) .env("SOCKET_API_TOKEN", "fake-token") .env("SOCKET_ORG_SLUG", ORG_SLUG) .output() @@ -575,7 +575,7 @@ async fn repair_with_blob_404_marks_failure_in_summary() { let has_failed_event = v .get("events") .and_then(|e| e.as_array()) - .map_or(false, |a| a.iter().any(|e| e["action"] == "failed")); + .is_some_and(|a| a.iter().any(|e| e["action"] == "failed")); assert!( has_failed_event, "repair must emit a per-artifact `failed` event for the 404; got: {v}" diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index ea1bd03..3edfcf6 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -10,6 +10,10 @@ //! take an identifier), we supply a dummy value alongside the flag under //! test so clap's parser can complete. +// The case tables below are tuples ending in `fn(&GlobalArgs)` pointers; a +// `type` alias per shape would add more noise than it removes in this test. +#![allow(clippy::type_complexity)] + use std::path::PathBuf; use clap::Parser; diff --git a/crates/socket-patch-cli/tests/e2e_gem.rs b/crates/socket-patch-cli/tests/e2e_gem.rs index 5bf80be..0e2f995 100644 --- a/crates/socket-patch-cli/tests/e2e_gem.rs +++ b/crates/socket-patch-cli/tests/e2e_gem.rs @@ -268,8 +268,7 @@ async fn requested_purls(server: &MockServer) -> Vec { .filter(|r| format!("{}", r.method) == "GET") .filter_map(|r| { let p = r.url.path(); - p.strip_prefix("/patch/by-package/") - .map(|seg| percent_decode(seg)) + p.strip_prefix("/patch/by-package/").map(percent_decode) }) .collect() } @@ -454,7 +453,7 @@ fn test_gem_full_lifecycle() { let files = &patch["files"]; assert!( - files.as_object().map_or(false, |f| !f.is_empty()), + files.as_object().is_some_and(|f| !f.is_empty()), "patch should modify at least one file" ); @@ -487,7 +486,7 @@ fn test_gem_full_lifecycle() { let has_cve = vulns.iter().any(|v| { v["cves"] .as_array() - .map_or(false, |cves| cves.iter().any(|c| c == "CVE-2022-21831")) + .is_some_and(|cves| cves.iter().any(|c| c == "CVE-2022-21831")) }); assert!(has_cve, "vulnerability list should include CVE-2022-21831"); diff --git a/crates/socket-patch-cli/tests/e2e_npm.rs b/crates/socket-patch-cli/tests/e2e_npm.rs index bf963a7..fa43262 100644 --- a/crates/socket-patch-cli/tests/e2e_npm.rs +++ b/crates/socket-patch-cli/tests/e2e_npm.rs @@ -195,7 +195,7 @@ fn test_npm_full_lifecycle() { let has_cve = vulns.iter().any(|v| { v["cves"] .as_array() - .map_or(false, |cves| cves.iter().any(|c| c == "CVE-2021-44906")) + .is_some_and(|cves| cves.iter().any(|c| c == "CVE-2021-44906")) }); assert!(has_cve, "vulnerability list should include CVE-2021-44906"); diff --git a/crates/socket-patch-cli/tests/e2e_pypi.rs b/crates/socket-patch-cli/tests/e2e_pypi.rs index 04bc1e1..50c91fe 100644 --- a/crates/socket-patch-cli/tests/e2e_pypi.rs +++ b/crates/socket-patch-cli/tests/e2e_pypi.rs @@ -290,7 +290,7 @@ fn test_pypi_full_lifecycle() { let has_cve = vulns.iter().any(|v| { v["cves"] .as_array() - .map_or(false, |cves| cves.iter().any(|c| c == "CVE-2026-25580")) + .is_some_and(|cves| cves.iter().any(|c| c == "CVE-2026-25580")) }); assert!(has_cve, "vulnerability list should include CVE-2026-25580"); diff --git a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs index 2d37498..ebf496f 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs @@ -28,9 +28,9 @@ //! 2. **Negative control**: mutate the source file without running //! apply, run `cargo check` — fails with "checksum changed". //! Proves cargo actually verifies. -//! 3. **Sidecar round trip**: synthesize a `.socket/manifest.json` -//! + after-hash blob, run `socket-patch apply`, run `cargo check` -//! — succeeds. The sidecar fixup is the load-bearing piece. +//! 3. **Sidecar round trip**: synthesize a `.socket/manifest.json` plus an +//! after-hash blob, run `socket-patch apply`, run `cargo check` — it +//! succeeds. The sidecar fixup is the load-bearing piece. //! 4. **`package` field preserved**: assert //! `.cargo-checksum.json`'s `"package"` key survives the rewrite //! unchanged (cargo doesn't verify it at build time, but we diff --git a/crates/socket-patch-cli/tests/e2e_safety_cow.rs b/crates/socket-patch-cli/tests/e2e_safety_cow.rs index 3d7cc99..3a9ebdf 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_cow.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_cow.rs @@ -128,6 +128,7 @@ fn assert_applied(env: &serde_json::Value, purl: &str, expected_paths: &[&str]) /// * the atomic writer (`apply::write_atomic`) stages `.socket-stage-*`, /// * **CoW** (`cow::write_via_stage_rename`, the hardlink and symlink /// branches) stages `.socket-cow-*`. +/// /// Both must be renamed-over on success or unlinked on failure, so a /// completed apply — success OR clean failure — must leave neither prefix /// behind. diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index 90157bd..aa30c82 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -152,7 +152,7 @@ fn assert_apply_dispatched(code: i32, env: &Value, ecosystem: &str, expected_pur ); for purl in expected_purls { let found = events.iter().any(|e| { - e["purl"] == Value::from(*purl) + e["purl"] == *purl && e["action"] == "skipped" && e["errorCode"] == "package_not_installed" }); @@ -192,7 +192,7 @@ fn assert_apply_not_dispatched(env: &Value, ecosystem: &str, out_of_scope_purls: events.len() ); for purl in out_of_scope_purls { - let leaked = events.iter().any(|e| e["purl"] == Value::from(*purl)); + let leaked = events.iter().any(|e| e["purl"] == *purl); assert!( !leaked, "apply --ecosystems={ecosystem}: out-of-scope PURL {purl} leaked into events — the --ecosystems filter did not exclude it; env={env}" diff --git a/crates/socket-patch-cli/tests/global_packages_e2e.rs b/crates/socket-patch-cli/tests/global_packages_e2e.rs index f79cad1..28104e0 100644 --- a/crates/socket-patch-cli/tests/global_packages_e2e.rs +++ b/crates/socket-patch-cli/tests/global_packages_e2e.rs @@ -172,7 +172,7 @@ fn assert_rollback_noop(stdout: &str) { #[test] fn apply_global_resolves_real_npm_prefix() { let tmp = tempfile::tempdir().unwrap(); - write_manifest(&tmp.path(), "pkg:npm/__global_test__@1.0.0"); + write_manifest(tmp.path(), "pkg:npm/__global_test__@1.0.0"); let out = Command::new(binary()) .args(["apply", "--global", "--offline", "--json", "--silent"]) @@ -192,7 +192,7 @@ fn apply_global_resolves_real_npm_prefix() { #[test] fn rollback_global_resolves_real_npm_prefix() { let tmp = tempfile::tempdir().unwrap(); - write_manifest(&tmp.path(), "pkg:npm/__rollback_global__@1.0.0"); + write_manifest(tmp.path(), "pkg:npm/__rollback_global__@1.0.0"); let out = Command::new(binary()) .args(["rollback", "--global", "--offline", "--json", "--silent"]) @@ -354,7 +354,7 @@ fn apply_global_with_empty_path_handles_missing_npm() { // deterministic "package_not_installed" outcome as a resolved-but- // empty global tree. let tmp = tempfile::tempdir().unwrap(); - write_manifest(&tmp.path(), "pkg:npm/__missing_npm__@1.0.0"); + write_manifest(tmp.path(), "pkg:npm/__missing_npm__@1.0.0"); let out = Command::new(binary()) .args(["apply", "--global", "--offline", "--json", "--silent"]) @@ -376,7 +376,7 @@ fn apply_global_with_empty_path_handles_missing_npm() { #[test] fn rollback_global_with_empty_path_handles_missing_npm() { let tmp = tempfile::tempdir().unwrap(); - write_manifest(&tmp.path(), "pkg:npm/__missing_npm__@1.0.0"); + write_manifest(tmp.path(), "pkg:npm/__missing_npm__@1.0.0"); let out = Command::new(binary()) .args(["rollback", "--global", "--offline", "--json", "--silent"]) 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 9245ac0..10fff88 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -106,7 +106,7 @@ fn default_args(cwd: &Path, api_url: String) -> ScanArgs { yes: true, global: false, global_prefix: None, - api_url: api_url, + api_url, api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), download_mode: "diff".to_string(), diff --git a/crates/socket-patch-cli/tests/repair_invariants.rs b/crates/socket-patch-cli/tests/repair_invariants.rs index 2c9239c..657de83 100644 --- a/crates/socket-patch-cli/tests/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair_invariants.rs @@ -27,6 +27,7 @@ const ORG_SLUG: &str = "test-org"; /// manifest-not-found / override assertions would be meaningless; /// * `SOCKET_DOWNLOAD_ONLY` / `SOCKET_DOWNLOAD_MODE` / `SOCKET_DRY_RUN` /// could flip the cleanup-vs-download branch out from under the test. +/// /// We scrub the whole set and then re-set only the handful a given test /// deliberately controls. const SOCKET_ENV_VARS: &[&str] = &[ @@ -495,7 +496,7 @@ async fn repair_online_downloads_missing_blob() { "file", "--download-only", ]) - .env("SOCKET_API_URL", &mock.uri()) + .env("SOCKET_API_URL", mock.uri()) .env("SOCKET_API_TOKEN", "fake-token-for-test") .env("SOCKET_ORG_SLUG", ORG_SLUG) .output() diff --git a/crates/socket-patch-core/src/api/blob_fetcher.rs b/crates/socket-patch-core/src/api/blob_fetcher.rs index cb3897c..daa5f79 100644 --- a/crates/socket-patch-core/src/api/blob_fetcher.rs +++ b/crates/socket-patch-core/src/api/blob_fetcher.rs @@ -585,7 +585,7 @@ mod tests { files.insert( format!("package/file{}.js", i), PatchFileInfo { - before_hash: format!("before{}{}", "0".repeat(58), format!("{:06}", i)), + before_hash: format!("before{}{:06}", "0".repeat(58), i), after_hash: ah.to_string(), }, ); diff --git a/crates/socket-patch-core/src/crawlers/deno_crawler.rs b/crates/socket-patch-core/src/crawlers/deno_crawler.rs index 6a275ef..493a92a 100644 --- a/crates/socket-patch-core/src/crawlers/deno_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/deno_crawler.rs @@ -309,6 +309,9 @@ mod tests { assert!(!is_deno_project(tmp.path()).await); } + // The whole point of this test is to exercise `::default()`, so the + // `default_constructed_unit_structs` lint is deliberately allowed here. + #[allow(clippy::default_constructed_unit_structs)] #[tokio::test] async fn deno_crawler_default_and_new_construct_cleanly() { let _a = DenoCrawler::default(); diff --git a/crates/socket-patch-core/src/crawlers/go_crawler.rs b/crates/socket-patch-core/src/crawlers/go_crawler.rs index 11413b5..8765ccc 100644 --- a/crates/socket-patch-core/src/crawlers/go_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/go_crawler.rs @@ -915,7 +915,7 @@ mod tests { let crawler = GoCrawler::new(); let qualified = "pkg:golang/github.com/gin-gonic/gin@v1.9.1?type=module".to_string(); let result = crawler - .find_by_purls(dir.path(), &[qualified.clone()]) + .find_by_purls(dir.path(), std::slice::from_ref(&qualified)) .await .unwrap(); diff --git a/crates/socket-patch-core/src/manifest/schema.rs b/crates/socket-patch-core/src/manifest/schema.rs index abb8c53..aac2ce2 100644 --- a/crates/socket-patch-core/src/manifest/schema.rs +++ b/crates/socket-patch-core/src/manifest/schema.rs @@ -545,7 +545,7 @@ mod tests { let json = r#"{ "patches": {}, "setup": {} }"#; let manifest: PatchManifest = serde_json::from_str(json).unwrap(); // The empty object parses into a (logically empty) config... - assert!(manifest.setup.as_ref().map_or(true, SetupConfig::is_empty)); + assert!(manifest.setup.as_ref().is_none_or(SetupConfig::is_empty)); // ...but must not survive into the serialized form. let reserialized = serde_json::to_string(&manifest).unwrap(); assert!( diff --git a/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs b/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs index f3f5ee1..9e4915d 100644 --- a/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs +++ b/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs @@ -237,8 +237,7 @@ mod tests { fn find_unit_span_selects_the_matching_package_unit() { // The first unit includes its [package.*] sub-table but not the // trailing blank separator. - let span = - find_unit_span(LOCK, |lines| lines.iter().any(|l| *l == "name = \"proj\"")).unwrap(); + let span = find_unit_span(LOCK, |lines| lines.contains(&"name = \"proj\"")).unwrap(); let unit = &LOCK[span]; assert!(unit.starts_with("[[package]]")); assert!(unit.contains("[package.metadata]"), "sub-table included"); @@ -248,18 +247,14 @@ mod tests { ); // The second (last) unit ends at the last non-blank line. - let span = - find_unit_span(LOCK, |lines| lines.iter().any(|l| *l == "name = \"six\"")).unwrap(); + let span = find_unit_span(LOCK, |lines| lines.contains(&"name = \"six\"")).unwrap(); assert_eq!( &LOCK[span], "[[package]]\nname = \"six\"\nversion = \"1.16.0\"" ); // No match → None. - assert!(find_unit_span(LOCK, |lines| lines - .iter() - .any(|l| *l == "name = \"absent\"")) - .is_none()); + assert!(find_unit_span(LOCK, |lines| lines.contains(&"name = \"absent\"")).is_none()); } #[test] diff --git a/crates/socket-patch-core/src/utils/telemetry.rs b/crates/socket-patch-core/src/utils/telemetry.rs index 43c8872..4063819 100644 --- a/crates/socket-patch-core/src/utils/telemetry.rs +++ b/crates/socket-patch-core/src/utils/telemetry.rs @@ -1128,7 +1128,7 @@ mod tests { /// arithmetic — so a regression in either is caught. fn brute_days_to_ymd(days: u64) -> (u64, u64, u64) { fn is_leap(y: u64) -> bool { - (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 + (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400) } let mut rem = days; let mut y = 1970u64; diff --git a/crates/socket-patch-core/src/vex/conformance_tests.rs b/crates/socket-patch-core/src/vex/conformance_tests.rs index 571f274..8f25cbb 100644 --- a/crates/socket-patch-core/src/vex/conformance_tests.rs +++ b/crates/socket-patch-core/src/vex/conformance_tests.rs @@ -84,6 +84,7 @@ fn sample_doc() -> Document { /// transpose to collapse: /// * two PURLs into one product with TWO subcomponents, and /// * the duplicated `CVE-DUP` into a single alias. +/// /// The uniqueness/dedup conformance invariants below are vacuous /// against `sample_doc`; they only have teeth against a merged /// statement. diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index ae40485..0735c41 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -1129,9 +1129,9 @@ mod tests { ); } - /// When multiple manifests are present but NONE parse, there is no - /// product to surface and therefore no "using X" warning to emit - /// (it would name a manifest that wasn't actually used). + // When multiple manifests are present but NONE parse, there is no + // product to surface and therefore no "using X" warning to emit + // (it would name a manifest that wasn't actually used). // ── Regression: TOML single-quoted (literal) string values ──────── // TOML permits `key = 'value'` (literal strings) as well as // `key = "value"`. The scanner previously only accepted the diff --git a/crates/socket-patch-core/tests/crawler_cargo_e2e.rs b/crates/socket-patch-core/tests/crawler_cargo_e2e.rs index a69f2c8..9b7fab9 100644 --- a/crates/socket-patch-core/tests/crawler_cargo_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_cargo_e2e.rs @@ -102,7 +102,7 @@ fn parse_cargo_toml_ignores_lines_before_package_section() { /// for symmetry. #[test] fn cargo_crawler_default_and_new_construct_cleanly() { - let _a = CargoCrawler::default(); + let _a = CargoCrawler; let _b = CargoCrawler::new(); } diff --git a/crates/socket-patch-core/tests/crawler_go_e2e.rs b/crates/socket-patch-core/tests/crawler_go_e2e.rs index 60e9d83..b99157e 100644 --- a/crates/socket-patch-core/tests/crawler_go_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_go_e2e.rs @@ -228,6 +228,9 @@ async fn crawl_all_handles_unreadable_cache_path() { /// `GoCrawler::default()` should forward to `new()` — and the two must be /// behaviorally identical, not merely both constructible. +// The whole point of this test is to exercise `::default()`, so the +// `default_constructed_unit_structs` lint is deliberately allowed here. +#[allow(clippy::default_constructed_unit_structs)] #[tokio::test] async fn go_crawler_default_and_new_construct_cleanly() { let tmp = tempfile::tempdir().unwrap(); @@ -294,7 +297,7 @@ async fn find_by_purls_module_dir_missing_returns_empty() { let crawler = GoCrawler; let missing_purl = "pkg:golang/github.com/gin-gonic/gin@v9.9.9".to_string(); let result = crawler - .find_by_purls(tmp.path(), &[missing_purl.clone()]) + .find_by_purls(tmp.path(), std::slice::from_ref(&missing_purl)) .await .unwrap(); assert!( diff --git a/crates/socket-patch-core/tests/crawler_maven_e2e.rs b/crates/socket-patch-core/tests/crawler_maven_e2e.rs index 94eda61..7f63ae0 100644 --- a/crates/socket-patch-core/tests/crawler_maven_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_maven_e2e.rs @@ -69,7 +69,7 @@ fn parse_pom_well_formed_extracts_coordinates() { } #[test] -fn parse_pom_missing_groupId_returns_none() { +fn parse_pom_missing_group_id_returns_none() { let pom = r#" commons-lang3 @@ -165,7 +165,7 @@ fn parse_pom_property_reference_version_returns_none() { /// reference — must NOT be accepted as a fallback groupId (line 86-87 /// skip arm). #[test] -fn parse_pom_missing_artifactId_returns_none() { +fn parse_pom_missing_artifact_id_returns_none() { let pom = r#" org.apache.commons @@ -195,7 +195,7 @@ fn parse_pom_split_tag_returns_none() { /// `MavenCrawler::default()` should forward to `new()`. #[test] fn maven_crawler_default_and_new_construct_cleanly() { - let _a = MavenCrawler::default(); + let _a = MavenCrawler; let _b = MavenCrawler::new(); } diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index b0ec52c..86d7098 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -155,7 +155,7 @@ async fn read_package_json_empty_version_returns_none() { #[test] fn npm_crawler_new_and_default_construct_cleanly() { let _a = NpmCrawler::new(); - let _b = NpmCrawler::default(); + let _b = NpmCrawler; } // ── get_node_modules_paths ───────────────────────────────────── @@ -494,7 +494,7 @@ async fn find_by_purls_resolves_qualified_purl_keyed_by_input() { let crawler = NpmCrawler; let qualified = "pkg:npm/lodash@4.17.21?extension=tgz".to_string(); let result = crawler - .find_by_purls(&nm, &[qualified.clone()]) + .find_by_purls(&nm, std::slice::from_ref(&qualified)) .await .unwrap(); diff --git a/crates/socket-patch-core/tests/crawler_nuget_e2e.rs b/crates/socket-patch-core/tests/crawler_nuget_e2e.rs index b05d187..06cdf91 100644 --- a/crates/socket-patch-core/tests/crawler_nuget_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_nuget_e2e.rs @@ -597,7 +597,7 @@ async fn crawl_all_missing_pkg_path_returns_empty() { #[test] fn nuget_crawler_default_and_new_construct_cleanly() { - let _a = NuGetCrawler::default(); + let _a = NuGetCrawler; let _b = NuGetCrawler::new(); } diff --git a/crates/socket-patch-core/tests/crawler_python_e2e.rs b/crates/socket-patch-core/tests/crawler_python_e2e.rs index 5108422..e58c99b 100644 --- a/crates/socket-patch-core/tests/crawler_python_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_python_e2e.rs @@ -706,7 +706,7 @@ async fn crawl_all_handles_unreadable_site_packages() { /// `PythonCrawler::default()` should forward to `new()`. #[test] fn python_crawler_default_and_new_construct_cleanly() { - let _a = PythonCrawler::default(); + let _a = PythonCrawler; let _b = PythonCrawler::new(); } diff --git a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs index f834a57..64e92c9 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -149,7 +149,7 @@ async fn find_by_purls_invalid_purl_skipped() { let crawler = RubyCrawler; let non_gem = "pkg:not-gem/rails@7.1.0".to_string(); let result = crawler - .find_by_purls(tmp.path(), &[non_gem.clone()]) + .find_by_purls(tmp.path(), std::slice::from_ref(&non_gem)) .await .unwrap(); assert!( @@ -410,7 +410,7 @@ async fn crawl_all_handles_unreadable_gem_dir() { /// `RubyCrawler::default()` should forward to `new()`. #[test] fn ruby_crawler_default_and_new_construct_cleanly() { - let _a = RubyCrawler::default(); + let _a = RubyCrawler; let _b = RubyCrawler::new(); } From 6d7de2a439136cee4a74578a6e515022b44d21d0 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 10 Jun 2026 14:17:40 -0400 Subject: [PATCH 31/31] test(vendor): make unsupported-ecosystem test feature-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unsupported_ecosystem_purl_is_currently_dropped_silently` hard-coded a default-features assumption — that a `pkg:nuget/...` purl is dropped silently because nuget is compiled out. But CI runs `cargo test --workspace --all-features` (nuget compiled in), where vendor correctly recognizes the purl, finds it unvendorable, and emits a `vendor_unsupported_ecosystem` skip event. The test wasn't feature-gated, so it failed under --all-features — taking down the test (×3 OS), test-release, and coverage jobs. Production behavior is correct; only the test was wrong. Rewrote it (`..._is_a_benign_skip`) to assert the invariant that holds on every feature set — the nuget purl is never `applied`, npm still vendors, exit 0 — and to branch on `cfg!(feature = "nuget")` for how the purl surfaces (explicit skip when compiled in, silent drop when compiled out). Verified green under both `--all-features` and default features. Co-Authored-By: Claude Fable 5 --- .../tests/in_process_vendor.rs | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 3173533..89e856e 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -473,16 +473,19 @@ async fn revert_works_without_manifest() { // 6. unsupported-ecosystem purls // ───────────────────────────────────────────────────────────────────── -/// Contract behavior (CLI_CONTRACT.md "Vendor command contract"): PURLs of -/// COMPILED-OUT ecosystems are invisible to `vendor` exactly as they are to -/// `apply` — on this build (default features — no `nuget`), a `pkg:nuget/...` -/// manifest entry is dropped by `partition_purls` before vendor's -/// is_vendorable partition, producing no event. The -/// `vendor_unsupported_ecosystem` skip fires only for ecosystems the build -/// recognizes but cannot vendor (e.g. maven/nuget purls on feature-enabled -/// builds). The npm patch still vendors and the run exits 0. +/// Contract behavior (CLI_CONTRACT.md "Vendor command contract"): a PURL of an +/// ecosystem `vendor` cannot vendor is a benign skip — it never fails the run, +/// and the supported npm patch still vendors. How the `pkg:nuget/...` entry +/// surfaces depends on whether this build compiled the `nuget` ecosystem in: +/// * compiled out (default features): the purl is unknown, dropped by +/// `partition_purls` before vendor's is_vendorable partition → no event. +/// * compiled in (`--all-features`, as CI runs): the purl is recognized but +/// not vendorable → a `skipped` event carrying `vendor_unsupported_ecosystem`. +/// +/// Either way the nuget purl is never `applied`, the npm patch vendors, and the +/// run exits 0. #[tokio::test] -async fn unsupported_ecosystem_purl_is_currently_dropped_silently() { +async fn unsupported_ecosystem_purl_is_a_benign_skip() { let fx = npm_fixture_with_purls(&[PURL, "pkg:nuget/Foo.Bar@1.0.0"]); let (code, env) = vendor_cli(fx.root(), &[]); assert_eq!(code, 0, "benign skip must not fail the run: {env:#}"); @@ -490,13 +493,32 @@ async fn unsupported_ecosystem_purl_is_currently_dropped_silently() { let applied = find_event(&env, "applied", None); assert_eq!(applied["purl"], PURL); assert_eq!(env["summary"]["applied"], 1); - // The compiled-out purl vanishes without a trace (the gap being pinned). + + let nuget_event = events(&env) + .iter() + .find(|e| e["purl"].as_str().is_some_and(|p| p.contains("nuget"))) + .cloned(); + // The nuget purl is never vendored, on any feature set. assert!( - !events(&env) - .iter() - .any(|e| { e["purl"].as_str().is_some_and(|p| p.contains("nuget")) }), - "current behavior: no event for the compiled-out nuget purl: {env:#}" - ); + nuget_event + .as_ref() + .is_none_or(|e| e["action"] != "applied"), + "nuget purl must never be applied: {env:#}" + ); + + if cfg!(feature = "nuget") { + // Recognized but not vendorable ⇒ an explicit, informative skip. + let ev = nuget_event.expect("nuget compiled in ⇒ explicit skip event"); + assert_eq!(ev["action"], "skipped", "{env:#}"); + assert_eq!(ev["errorCode"], "vendor_unsupported_ecosystem", "{env:#}"); + } else { + // Compiled out ⇒ the unknown purl is dropped before vendor sees it. + assert!( + nuget_event.is_none(), + "nuget compiled out ⇒ no event: {env:#}" + ); + } + assert!(fx.tgz_path().is_file(), "the npm patch still vendors"); }