Gunnar

nordisk/holger

.nornir/checksum-deploy-design.md

<!-- nornir:class: design -->

# Checksum-verify-on-deploy (`X-Checksum-*`)

> The write-path integrity gate: when a client uploads an artifact and advertises
> its digest, Holger recomputes that digest over the bytes it actually received and
> **rejects a mismatch before any backend sees the write**. Holger's pitch is "the
> bytes are what you think they are"; this makes it true at the front door, not just
> at rest.

![write-path checksum gate](assets/checksum-deploy-flow.svg)

## Why

Holger already guarantees integrity *at rest* (blake3 per-chunk verify on read,
sha256 in the Cargo index, OCI digest verify) and *on pull-through* (upstream bytes
are checked against the upstream's advertised sha256 before caching). The one hole
was **on deploy**: a corrupted or truncated PUT — a flaky airgap USB copy, a
half-written CI upload, a MITM on a mis-configured plaintext hop — was accepted as-is.
Artifactory closes this with *checksum-deploy*: the client sends `X-Checksum-Sha256`
and the server verifies. Holger now does the same.

## Contract

- **Headers:** `X-Checksum-Sha256` and/or `X-Checksum-Sha512`, hex-encoded digest of
  the request body. Case-insensitive names (HTTP) and case-insensitive hex compare.
- **Opt-in:** a missing or empty header is a no-op — the write proceeds exactly as
  before. This keeps every existing client working; verification is a guarantee you
  *add*, not a handshake you must perform.
- **Strong digests only:** sha256 and sha512. `sha1`/`md5` are intentionally
  unsupported — a matching weak digest is not an integrity guarantee, and honouring
  them would invite a false sense of safety. (The workspace already standardises on
  sha256/blake3.)
- **On mismatch:** the write is rejected with `400` and a `sha256 checksum mismatch`
  / `sha512 checksum mismatch` body. The event is audited (ident, action, repo, path,
  peer, `400`, byte count, detail) via the same `AuditLog` seam as every other write.
- **Ordering:** the check runs *after* auth/RBAC and the read-only-repo gate, and
  *after* the body is collected (so the size cap still bounds memory), but *before*
  `handle_http2_request` dispatches to the backend. A rejected upload never reaches
  storage.

## Where it lives

`server/lib/src/exposed/http.rs`:

- `verify_upload_checksum(want_sha256, want_sha512, body) -> Result<(), (u16, &str)>`
  — the pure helper (no I/O), unit-tested in the same module.
- `handle(...)` captures the two header values *before* `req.into_body()` consumes
  the request, then calls the helper on the collected body just before dispatch.

Reuses the crate's existing `sha2` + `hex` deps (already present for pull-through
cache verification) — no new dependency.

## Scope / non-goals

- **HTTP native-protocol door only.** The gRPC path (`grpc.rs`) uploads typed
  `ArtifactId` + bytes; a gRPC checksum field is a follow-up (the proto would carry
  an optional `expected_sha256`). Noted, not built.
- No checksum *storage* / sidecar synthesis (`.sha1`/`.md5` files) — that is the
  Maven-metadata gap (`feature-complete-gaps.md` §1), separate work.

## Tests

Eight unit tests in `exposed::http::tests` (`checksum_*`): absent header, empty
header, sha256 match, uppercase-hex match, sha256 mismatch, garbage/short digest,
sha512 match+mismatch, both-digests match. Pure-function coverage, no network — runs
in the default `cargo test -p holger-server-lib` pass (the tests-in-code convention,
matching the sibling `rbac_*` tests in the same module).