Skip to content

v1 fetch integration: Go (#396) + Rust (#397) fetch lanes + strict test CA (#421) - #422

Merged
EricAndrechek merged 43 commits into
v1from
v1-integration-fetch
Oct 3, 2026
Merged

EricAndrechek merged 43 commits into
v1from
v1-integration-fetch

Conversation

@EricAndrechek

Copy link
Copy Markdown
Member

One integration PR for three green v1 PRs, merged with --no-ff (no rebase), so they share one CI run. Merged one at a time, each would leave the other two behind under strict protection, forcing a re-sync and a fresh CI run per merge.

No merge conflicts. Gates run on this branch, each rc=0:

  • support-matrix.sh --check
  • lint-actions, lint-public, lint-cited-paths
  • check-selftests-wired
  • gen-constants --check
  • policy-merge-check --check-carve-out

🤖 Generated with Claude Code

EricAndrechek and others added 30 commits October 1, 2026 20:29
Implements go/internal/ocifetch, the Go half of the v1 OCI fetch layer
against the frozen seam (lane 0A's constants_gen.go, docs/guides/fetch-v1.md):

- http.go: retries with one shared table, Retry-After in both forms (and
  refused once it exceeds the retry schedule's own total budget), redirects
  with Authorization dropped cross-origin, the anonymous Bearer-token flow
  for mirrors, and the static CHTYPES_DOWNLOAD_TOKEN sent only to configured
  hosts.
- oci.go: resolve an image index by tag, select the platform manifest,
  verify it by digest, and the tag/digest 404 policies (next-base-then-
  unpublished vs next-base-then-retry-on-last).
- referrers.go / dsse.go: referrers-API-or-fallback-tag discovery, DSSE's
  pre-authentication encoding and ed25519 verification against the trusted
  key list, and the in-toto statement checks (abi, os, arch, version-within-
  request, subject-equals-layer).
- unpack.go: zstd decode with the window-log cap honored before the decoder
  allocates for it, and tar extraction refusing anything but regular files
  and directories, with the unpacked-bytes cap enforced as it is written.
- layout.go: the OCI image layout cache (temp-then-rename blobs, an
  index.json read-check-rename loop that survives a concurrent writer) and
  each unpacked directory's own verified.json record as the source of truth
  for offline reads.
- lock.go: schema 3, refusing a v0 (schema 1/2) or wrong-ABI lock outright.
- ensure.go: the seam's four entry points (Ensure, ResolveInstalled,
  ListInstalled, VerifyInstalled, FetchSigned) and --frozen/--offline/update.
- errors.go: the ten shared v1 codes over the generated exit-code table.
- conformance_test.go: TestConformanceV1, which skips loudly without
  CHTYPES_V1_CONFORMANCE; the fixtures and scripted server it depends on
  have not landed from the fixtures lane yet, so it is written against the
  documented case/report shape and will need a pass once they exist.

go.mod/go.sum add github.com/klauspost/compress, pinned, for the zstd
decoder (the zstd encoder used by fixture generation is a separate module).

64 unit tests exercise every piece directly, including a full fake-registry
integration test of the happy path, an index.json concurrent-writer race,
every tar refusal rule, and the retry/Retry-After/redirect/token behavior
over real HTTP round trips — all green, including under -race.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the Rust binding's v1 fetch layer behind a new, off-by-default
`fetch-v1` feature: HTTP with retries/redirects/the anonymous Bearer-token
flow, OCI resolve (image index + manifest, the version-spelling rules),
referrer-based trust (DSSE + ed25519 against the pinned release key, with a
duplicate-key JSON check), verify-then-decompress zstd (ruzstd, looping over
frames, enforcing the window-log cap) and tar unpack (regular files and
directories only), an OCI-layout cache with atomic installs, and a schema-3
lock file. The seam (`ensure`, `resolve_installed`, `list_installed`,
`verify_installed`, `fetch_signed`) matches the plan's §1.3 contract.

MSRV rises from 1.85 to 1.87 (ruling): ruzstd 0.9.0, the v1 zstd decoder,
declares `rust-version = "1.87"`. `rust/Cargo.toml` and the v1-conformance
matrix's rust leg are updated to match; noted in `rust/CHANGELOG.md`.

Referrer discovery also covers an edge case measured on the staging
registry: a 200 response with no matching-artifactType referrer can be
edge-cache staleness rather than genuine absence, so a zero-candidate
result from the referrers API always also tries the fallback tag before a
trust decision is final.

`rust/src/ocifetch` is wired into `lib.rs` as a single private `mod`
declaration (one line), per the lane's scope: nothing else in this crate
calls it yet, pending the FFI/switch lanes. `tests/ocifetch_conformance.rs`
reaches it by recompiling the module tree under its own crate root
(`#[path]`), the only way an integration test can reach a private `mod`;
it reads `CHTYPES_V1_CONFORMANCE` and skips loudly when unset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Every manifest GET (by tag, by digest, and the referrers fallback tag)
  now sends exactly Accept: image.index, image.manifest, per the delivery
  side's review — dropped the bundle media type, which never belongs in a
  manifest Accept header.
- Reworded the referrers-fallback doc comment: the fallback is for mirrors
  that may not proxy the referrers API faithfully, not edge-cache
  staleness on our own host (corrected per the delivery side).
- tests/ocifetch_conformance.rs: build the lane-0B server script's path
  from components rather than one string literal, so lint-cited-paths.sh
  does not read the forward reference (0B has not merged yet) as a dead
  repository path.
- rust/src/ocifetch/constants.rs: cargo fmt expanded the struct-literal
  arrays rustfmt's own run had already touched; left in the rustfmt-
  canonical form per this lane's brief. This is the known conflict between
  rustfmt and scripts/fetch-v1/gen-constants.py's own emitted style (not
  this lane's file to fix) — see MERGE NOTES.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two corrections from the delivery side's review:

- Every GET …/manifests/<ref>, by tag and by digest, now sends
  Accept: application/vnd.oci.image.index.v1+json,
  application/vnd.oci.image.manifest.v1+json. Our host ignores it; a mirror
  may need it for content negotiation. Covers resolveIndex,
  fetchManifestByDigest, the referrers fallback tag's own manifests/ GET,
  fetchReferrerContent, FetchSigned and the --frozen path — every one of
  them, not only the tag-based resolve.
- referrers.go's comment no longer frames the fallback-tag fallback as
  working around a cache lag on our own host (that endpoint is served
  no-store): it is for mirrors, which may serve only the fallback tag and
  never the referrers API at all. The fallback logic itself is unchanged.

Also removes five dead citations of scripts/fetch-v1/server.py (lane 0B's
scripted server, not yet on this branch) that lint-cited-paths.sh correctly
flagged once the new test files were tracked — CI caught this on the first
push; fixed and re-verified clean (lint-cited-paths, lint-public,
lint-spelling, policy-merge-check --check-carve-out,
check-selftests-wired.py, gen-constants.py --check).

67 tests pass (two new ones assert the Accept header is sent/absent as
configured); go vet, golangci-lint and gofmt all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found by re-reading ensure.go end to end: resolveOptions already parsed
Options.HookBeforeIndexRename into resolvedOptions.hookBeforeIndexRename,
but both real addIndexEntry call sites in ensure.go hardcoded nil instead
of using it, so the option had no effect on an actual Ensure call — only
layout_test.go's own direct addIndexEntry test exercised the race-survival
logic itself.

session now carries hookBeforeIndexRename (set from resolvedOptions in
newSession) and both installManifest and the already-installed shortcut
pass it through. A new integration test, TestEnsureInvokesHookBeforeIndexRename,
proves the hook actually fires during a real Ensure call — the gap existing
tests could not have caught, since they called addIndexEntry directly
rather than through the public entry point the conformance runner will use
for the index-race-reapply case.

68 tests pass; go vet, golangci-lint and gofmt all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
VerifiedStatement now carries the digests of the referrer manifest and
layer that held the verified bundle (set by referrers.rs, which is what
fetched them — dsse.rs only ever sees the layer's bytes). ensure.rs no
longer discards them: Digests.bundle (the lock's "bundle" entry, re-fetched
and re-verified under --frozen) and the new Digests.bundle_manifest field
are both populated from the real verified statement instead of being left
None/empty, in every path that builds a Resolved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
scripts/support-matrix.sh reads rust/Cargo.toml's rust-version to populate
docs/support.md's Rust row; this PR's 1.85 -> 1.87 bump left the committed
file stale, failing the required docs check. Mechanical regeneration, no
other row changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ed predicate

A second, independent review of ensure.rs found two real bugs:

- find_installed and monotonic_warning compared ClickHouse versions as
  plain strings. Components are not zero-padded, so "26.10.1.5" sorted
  BELOW "26.9.3.38" ('1' < '9' lexically) despite being the newer version
  -- --offline/resolve_installed could pick an older build across any
  digit-width boundary (9->10, 99->100), which is routine for these
  version strings. Now compared as [u64; 4] (version_key). Build strings
  stay a plain string compare -- they are fixed-width per spec, so that
  was already correct.
- A verified (non-null) predicate missing library_sha256/library/
  library_bytes silently skipped the library hash/size check entirely
  (library_sha256.as_str().unwrap_or_default() -> "" -> the `if
  !is_empty()` guard never ran) instead of refusing a malformed-but-signed
  artifact. library_fields() now requires all three and fails
  ArtifactCorrupt if any is absent; ensure_frozen's install path, which
  never checked the library at all, now does (verify_unpacked_library,
  shared with ensure_online).

Also: the "already installed" short-circuit (both ensure_online and
ensure_frozen) checked only that verified.json exists, not that the
library file it names is actually on disk -- a record surviving after the
library was removed or corrupted out of band would report success with a
library_path that does not resolve, breaking ensure()'s own "the library
file exists on disk" guarantee on the cache-hit path. is_fully_installed()
now checks both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… MSRV bump itself broke integration.rs

v1-constants vs rustfmt: scripts/fetch-v1/gen-constants.py (lane 0A's file,
untouched here) and rustfmt wanted different formatting of constants.rs's
struct-literal arrays. Fixed in-lane instead, the standard approach for
generated code: #[rustfmt::skip] on `pub mod constants;` in mod.rs, then
constants.rs restored to the generator's exact output (gen-constants.py
--write). rustfmt follows the mod declaration, so this also covers
tests/ocifetch_conformance.rs's #[path] recompilation of the same file.
Confirmed: `cargo fmt --check` and `gen-constants.py --check` both pass now.

tests/integration.rs:1479, clippy::manual_is_multiple_of: this is NOT
unrelated toolchain drift as I'd reported -- it's a direct consequence of
this PR's own rust-version bump. Measured: the lint is MSRV-aware (checked
by toggling a scratch crate's rust-version between 1.85, where it is
silent, and 1.87, where it fires as a hard error under -D warnings), and
`is_multiple_of` is the stable method this bump newly permits suggesting.
#394 passed at the old MSRV; this PR's 1.85 -> 1.87 is what exposed it
here. Fixed at the call site (`n % 2 == 0` -> `n.is_multiple_of(2)`), valid
at 1.87. `cargo clippy --all-targets -- -D warnings` is clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	.github/workflows/v1.yml
mirror-failover origin alternation, the generic-fetch convention, and
requests.max/none_matching checking

Merges lane 0B's fixtures/server/parity/v1-conformance-job-body PR (#401)
and the FFI lane's ABI description work (#395, abi-v1/, unrelated to this
lane) into this branch.

ensure.rs / referrers.rs:
- A pre-seeded index.json entry (a layout with no corresponding verified.json
  yet) is now actually installable offline, from local blobs only, per
  docs/guides/fetch-v1.md §1 and §10's "Cache fixtures and installed.json":
  find_index_candidate picks the best candidate by platform and by the
  index's own org.opencontainers.image.ref.name annotation,
  install_from_local_blobs verifies and unpacks it without any network call
  (find_installed and the offline+lock path in resolve_offline both use
  it), and referrers::find_local_referrer reconstructs the signature
  referrer by scanning blobs/sha256/ for a manifest whose own `subject`
  points back at the target digest -- a plain OCI layout's index.json lists
  only the artifact manifest itself, never its referrers.

rust/tests/ocifetch_conformance.rs:
- Fixed the lock fixture path to locks/inputs/<name>.json (it was
  locks/<name>.json, guessed before lane 0B's real layout existed).
- Implemented the installed.json pre-seed convention: before a case with
  one runs, each listed digest is installed from the staged cache's own
  local blobs via the same install_from_local_blobs.
- `{base}` occurrences in request.bases now alternate between the server's
  two origins for http transport (first -> primary, second -> the second
  origin server.py already starts for redirect cases). This is what makes
  mirror-failover-5xx/mirror-failover-digest-404 solvable at all: both
  entries are the textually identical "{base}" template, and the second
  origin's own http-script routes are empty for those case ids, so it falls
  straight through to the real tree once the first origin's retry budget is
  exhausted. Confirmed against retry-exhausted, which uses the IDENTICAL
  503x5 script on a single base and correctly fails -- the only difference
  for the mirror cases is the second, distinct origin.
- The generic-fetch convention (a goldens-/fixtures- case id calls
  fetch_signed instead of ensure(); its library_sha256 checks against the
  fetched content's own hash, there being no "library" file for this
  content).
- requests.max/none_matching are now actually checked, by reading each
  case's request log from the server's own GET /_log/s-<id> endpoint (http
  transport only -- there is no request log for a file:// read). A small,
  specific pattern matcher covers the fixture set's actual vocabulary
  (a GET .*<suffix> wildcard, a bare substring, and the one negative-
  lookahead pattern fixtures-digest-pin-no-tag-fallback needs) rather than
  a general regex engine, which this crate has no dependency on.

Still not implemented, reported rather than faked (see hand-back MERGE
NOTES): before_index_rename_hook, the lock_after fixture comparison, and
writing a lock for every platform the index offers (lock-write-all-platforms).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…arison

ensure(): a request with update:true but lock_write:false never rewrote the
lock at all (the write was gated on lock_write alone). Plan §6: "update
re-resolves every locked request against the current index and rewrites
the lock" -- update's whole point is to refresh a stale entry, so it must
write regardless of lock_write. Fixed: the write now happens on
lock_write || update (update-re-resolves).

The conformance runner's expect.lock_after was an unconditional reported
gap; it now actually compares the lock file the case just wrote against
locks/expected/<name>.json (parsed as JSON before comparing, so key order
never matters).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-all-platforms)

Plan §6: "Writing a lock for every platform the index offers... downloads
and verifies every platform's bundle but fetches the layer only for the
host's own platform." ensure()'s lock_write path previously recorded only
the single requested platform.

lock_entries_for_all_platforms re-fetches the index (one extra request,
acceptable -- lock_write is not a hot path), reuses the already-resolved
host platform's data as-is (no extra fetch), and for every other platform
fetches its manifest by digest and verifies its signature referrer without
ever fetching its layer blob, matching lock-write-all-platforms' own
assertion (zero layer GETs for the two non-host platform digests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eep in real time

v1-conformance (rust 1.87) and (rust stable) were cancelled after the full
15-minute job timeout, with no report artifact uploaded at all -- the test
process was still running, not merely slow. Root cause: the runner always
passed Options.clock = None, so every retry/backoff case (ensure.rs's http
client has no idea it is under test) slept for REAL seconds via
std::thread::sleep. Summed across the suite's several dozen retry-shaped
cases (retry-5xx, retry-408, retry-429-*, retry-after-*, retry-exhausted at
4+8+16+32=60s alone, both mirror-failover cases at another 60s each, plus
cold-compile time for ring/rustls/ruzstd with no cargo cache in this job),
real wall-clock time plausibly exceeded 15 minutes outright. Go/Python/TS's
suites have no such per-attempt real-sleep cost and passed comfortably in
the same run.

The seam was built for exactly this (plan §1.1.1: "every binding's fetch
core takes an injected sleep(seconds) and now(), so retry cases run
instantly") but the runner never actually injected one. FakeClock now
does: sleep() only records the duration into a shared Vec, which the
runner then checks exactly against expect.sleeps (not implemented before
this commit either) instead of a real thread::sleep. Wired into both
ensure() and fetch_signed() -- the latter was separately hardcoding
RealClock regardless of Options.clock, now fixed to honor it the same way
resources() already did.

One case, stall-timeout-retried, still costs one real ~60s wait: the
server genuinely holds the connection open, and detecting that is ureq's
own OS-level read-timeout, not a sleep() call this Clock can intercept.
Acceptable on its own; removing the other several hundred seconds of
backoff sleeps should bring the suite comfortably under the job's 15-minute
budget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- conformance_test.go: wire Options.SystemDirs from the fixture's own
  system-dir copies, instead of leaving it unset (which silently fell
  back to the real machine's SystemCacheDirs). This was the cause of
  system-dir-readonly's only failure.
- ensure.go: ensureOnline's monotonic-build check now runs BEFORE the
  layer is fetched/installed, using the signed predicate's own
  version/build, and keeps the existing (newer) installed entry rather
  than installing the registry's older one and only warning about it
  after the fact. Matches monotonic-warning's documented intent ("the
  existing (newer) install is kept, with a warning").

Also carried over from the previous round's review feedback: the Accept
header on every manifest GET, the referrers-empty-then-fallback-tag
trust rule, and the referrers.go comment reword — already in this
branch before this commit.

14 (case, transport) pairs still fail; all traced to fixture/script
defects outside this lane's scope (genfixtures/*, server.py) and
reported separately rather than fixed here, per brief.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Lane 0B's round-3 fixes (merged #407) moved mirror-failover-5xx/
-digest-404 onto two genuinely independent bases ({base}, {base2})
served from server.py's two origins, each with its own response-
sequence cursor. expandBase now also substitutes {base2} against the
server's second port for http transport (docs/guides/fetch-v1.md's
"{base2}" section).

With #407's other fixes (real-version tags for existing-install-noop/
index-race-reapply, predicate-wrong-version's numeric spelling, an
actually-oversize zstd window, preseed-oras dropped while its layout
is absent, corrected redirect templates), the full local conformance
run is now 139/139 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ssed 139/139

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
…notonic keep, 401/403 mapping, refused-connection skips retry, index.json race loop, registry transport

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
…'s config blob and reports itself unsigned

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
…he patch release

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
… the sandbox (25 passed, 0 failed)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
EricAndrechek and others added 13 commits October 3, 2026 12:34
…t MSRV or stable), not the compiler version

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
…port from v1-network, which does not run a suite yet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
… v1-network)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
The CA had no keyUsage, which Python's default (VERIFY_X509_STRICT) context
refuses. Generate CA and server certificates with the proper extensions, and
verify with openssl -x509_strict plus a Python default-context handshake.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NdkF6p8Q3qkdxKCgbdjgb
@EricAndrechek
EricAndrechek marked this pull request as ready for review October 3, 2026 17:42
@EricAndrechek
EricAndrechek merged commit fa84a40 into v1 Oct 3, 2026
51 of 53 checks passed
@EricAndrechek
EricAndrechek deleted the v1-integration-fetch branch October 3, 2026 17:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant