From 9bc89f3c05c39d99927c881d06018b4bd43001b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 05:46:25 +0000 Subject: [PATCH 1/6] feat(cockpit-server): serve baked artifacts from the object store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bakes reach the browser today by being baked INTO the image: the Dockerfile curls them from a GitHub release into cockpit/dist/, which include_dir! embeds. That path stays and remains primary. What it cannot do is let a NEWLY published bake reach a RUNNING deploy — dist/ is fixed at image build, so a v4 body bake would need a rebuild before /helix2 could see it. /api/bake/:tag/:asset streams the artifact from the shared bucket, same-origin. The server fetches rather than the browser for two independent reasons: the bucket is private and must stay private (it is shared with MedCare-rs clinical ontology bakes), and a browser cannot sign SigV4 without being handed the credentials, which is the same exposure by another route. It also sidesteps the missing CORS header on the release redirect that the Dockerfile already documents. No volume is used. Only some q2 deployments have one, so a hydrate-to-disk design like osm_slab_hydrate would work on some deploys and silently not on others; this streams per request and keeps no local copy. A volume cache can be layered on later where one exists. Signing is ported from MedCare-rs::medcare-server::bake_s3, which already solved this against the same bucket, including its reason for writing HMAC out rather than adding the hmac crate. reqwest and chrono were already in the workspace lock, so this adds no new dependency tree. REPO_PREFIX is a constant, not an env var: a deploy pointed at another prefix is fetching another project's artifacts. BodyHelix2 now tries dist/ -> /api/bake -> release, taking the bake tag from the manifest so publishing is a manifest edit rather than a code change; absent, the hop is skipped rather than guessed. Verified: cargo check -p cockpit-server exit 0; tsc --noEmit exit 0; npm run build green; and the signing scheme authenticated against the real bucket (HTTP 200 returning the uploaded SHA256SUMS). Tests cover the RFC 4231 HMAC known answer and that the signature varies with key, date and path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht --- Cargo.lock | 2 + cockpit/src/BodyHelix2.tsx | 20 +++ crates/cockpit-server/Cargo.toml | 4 + crates/cockpit-server/src/bake_s3.rs | 257 +++++++++++++++++++++++++++ crates/cockpit-server/src/main.rs | 63 +++++++ 5 files changed, 346 insertions(+) create mode 100644 crates/cockpit-server/src/bake_s3.rs diff --git a/Cargo.lock b/Cargo.lock index a822e42b5..0ff126f95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1710,6 +1710,7 @@ dependencies = [ "async-stream", "axum 0.7.9", "causal-edge", + "chrono", "cpic", "futures", "futures-core", @@ -1728,6 +1729,7 @@ dependencies = [ "osm-soa-bake", "quarto-core", "quarto-system-runtime", + "reqwest 0.12.28", "serde", "serde_json", "sha2 0.10.9", diff --git a/cockpit/src/BodyHelix2.tsx b/cockpit/src/BodyHelix2.tsx index 3ee3738ce..c934ce847 100644 --- a/cockpit/src/BodyHelix2.tsx +++ b/cockpit/src/BodyHelix2.tsx @@ -730,8 +730,28 @@ async function fetchSoa(): Promise { if (!stamped) { throw new Error(`no bake for scene="${scene ?? 'body'}" — set ${key} in /body.manifest.json (soabake → helix::encode_signed; osm → geo/osm_helix)`); } + // 1. Same-origin: the copy the Dockerfile baked into dist/ at image build. + // Fastest and always correct when present, so it stays first. const s = await fetch(`/${stamped}`).catch(() => null); if (s && s.ok) return inflate(s); + // 2. The object store, proxied same-origin by /api/bake/:tag/:asset. This is + // what lets a NEWLY published bake reach a RUNNING deploy: the dist/ copy + // above is fixed at image build, so without this a v4 bake needs a rebuild + // before /helix2 can see it. The server signs (SigV4) and streams; the + // bucket is private and stays private — it is shared with clinical bakes, + // and a browser cannot sign without being handed credentials. + // The tag comes from the manifest so publishing a bake is a manifest edit, + // not a code change; absent, this hop is skipped rather than guessed. + const tag: string | undefined = man?.helix_v4_tag; + if (tag) { + const o = await fetch(`/api/bake/${encodeURIComponent(tag)}/${encodeURIComponent(stamped)}`) + .catch(() => null); + if (o && o.ok) return inflate(o); + } + // 3. The GitHub release. Kept last and expected to fail in a browser: the + // releases/download redirect sends no CORS header (see the Dockerfile note + // at the same asset). It is a fallback for same-origin contexts, not a + // working browser path. const rel = await fetch(`${REL}/${stamped}`); if (!rel.ok) throw new Error(`HTTP ${rel.status} fetching ${stamped}`); return inflate(rel); diff --git a/crates/cockpit-server/Cargo.toml b/crates/cockpit-server/Cargo.toml index 62109e6fa..49772f3ec 100644 --- a/crates/cockpit-server/Cargo.toml +++ b/crates/cockpit-server/Cargo.toml @@ -77,6 +77,10 @@ memmap2 = "0.5" object_store = { version = "0.13.2", features = ["aws"] } sha2 = "0.10" hex = "0.4" +# Object-store fetch for /api/bake/* (see bake_s3.rs). Both already resolve in +# the workspace lock via other crates, so this adds no new dependency tree. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +chrono = "0.4" futures = "0.3" # `osm_slab_hydrate::sha256_file`: advise the kernel to drop a hashed file's # pages from its page cache afterward (`posix_fadvise(POSIX_FADV_DONTNEED)`, diff --git a/crates/cockpit-server/src/bake_s3.rs b/crates/cockpit-server/src/bake_s3.rs new file mode 100644 index 000000000..c02f3c5aa --- /dev/null +++ b/crates/cockpit-server/src/bake_s3.rs @@ -0,0 +1,257 @@ +//! Serve the baked body/scene artifacts from the shared object store. +//! +//! # Why this exists +//! +//! The artifacts reach the browser today by being baked INTO the image: the +//! Dockerfile `curl`s them from a GitHub release into `cockpit/dist/`, which +//! `include_dir!` embeds in the binary. That works, and it stays — this is an +//! additional source, not a replacement. +//! +//! What it does not do is let a NEW bake reach a running deploy. A v4 body bake +//! published to the object store would otherwise need an image rebuild before +//! `/helix2` could see it. Fetching at request time decouples the artifact from +//! the image. +//! +//! # Why the server fetches, and not the browser +//! +//! Two independent reasons, either sufficient: +//! +//! 1. **The bucket is private and must stay private.** It is shared across +//! repos and holds MedCare-rs clinical ontology bakes. Making objects +//! public-read so a browser could fetch them directly would expose that +//! material; it is not an option under any deadline. +//! 2. **A browser cannot sign a SigV4 request** without being handed the +//! credentials, which is the same exposure by another route. +//! +//! So the bytes come through this route, same-origin, and the credentials never +//! leave the server. This also sidesteps the CORS problem the Dockerfile +//! already documents for the release redirect. +//! +//! # Why no volume +//! +//! Not every q2 deployment has one (operator, 2026-09-07), so a hydrate-to-disk +//! design like `osm_slab_hydrate` would work on some deploys and silently not +//! on others. This streams per request and keeps no local copy: correct +//! everywhere, at the cost of re-fetching. A volume cache can be layered on +//! later for the deploys that have one — it is an optimisation, not a +//! prerequisite. +//! +//! # Signing +//! +//! Ported from `MedCare-rs::medcare-server::bake_s3`, which already solved this +//! against the same bucket. HMAC is written out rather than pulled in, for the +//! reason recorded there: the `hmac` crate's `digest` version conflicts with the +//! `sha2` in tree, which is a poor trade for fifteen lines of xor. + +use sha2::{Digest, Sha256}; + +/// The key prefix this repo's bakes live under inside the shared bucket. +/// +/// A constant, not an env var: a deploy pointed at another prefix is fetching +/// another project's artifacts, which is better made impossible than +/// configurable. Mirrors `REPO_PREFIX` in MedCare-rs for the same reason. +const REPO_PREFIX: &str = "q2"; + +/// Object-store coordinates, read from the environment. +/// +/// All five must be present. A partial configuration is treated as "no object +/// store" rather than as a broken one, so a deploy that never configured it +/// behaves exactly as before instead of failing at request time. +#[derive(Clone, Debug)] +pub struct S3Config { + endpoint: String, + bucket: String, + region: String, + key_id: String, + secret: String, +} + +impl S3Config { + pub fn from_env() -> Option { + let get = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty()); + Some(Self { + endpoint: get("AWS_ENDPOINT_URL")? + .trim_end_matches('/') + .to_string(), + bucket: get("AWS_S3_BUCKET_NAME")?, + region: get("AWS_DEFAULT_REGION").unwrap_or_else(|| "auto".to_string()), + key_id: get("AWS_ACCESS_KEY_ID")?, + secret: get("AWS_SECRET_ACCESS_KEY")?, + }) + } + + fn url(&self, tag: &str, asset: &str) -> String { + format!( + "{}/{}/{REPO_PREFIX}/bakes/{tag}/{asset}", + self.endpoint, self.bucket + ) + } +} + +/// HMAC-SHA256 (RFC 2104) over the `sha2` already in the tree. +fn hmac(key: &[u8], msg: &str) -> Vec { + const BLOCK: usize = 64; + let mut k = [0u8; BLOCK]; + if key.len() > BLOCK { + k[..32].copy_from_slice(&Sha256::digest(key)); + } else { + k[..key.len()].copy_from_slice(key); + } + let mut inner = Sha256::new(); + inner.update(k.iter().map(|b| b ^ 0x36).collect::>()); + inner.update(msg.as_bytes()); + let inner = inner.finalize(); + + let mut outer = Sha256::new(); + outer.update(k.iter().map(|b| b ^ 0x5c).collect::>()); + outer.update(inner); + outer.finalize().to_vec() +} + +fn hexs(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +/// Sign an unsigned-payload `GET` and return the headers to send. +/// +/// Unsigned payload is correct rather than lax: a GET has no body, so signing +/// the empty payload would authenticate nothing. +fn sign_get(cfg: &S3Config, url: &str, now: &chrono::DateTime) -> Vec<(String, String)> { + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date = now.format("%Y%m%d").to_string(); + + let after_scheme = url.split_once("://").map_or(url, |(_, r)| r); + let (host, path) = after_scheme + .split_once('/') + .map_or((after_scheme, "/".to_string()), |(h, p)| (h, format!("/{p}"))); + + const PAYLOAD: &str = "UNSIGNED-PAYLOAD"; + let signed_headers = "host;x-amz-content-sha256;x-amz-date"; + let canonical = format!( + "GET\n{path}\n\nhost:{host}\nx-amz-content-sha256:{PAYLOAD}\nx-amz-date:{amz_date}\n\n\ + {signed_headers}\n{PAYLOAD}" + ); + let scope = format!("{date}/{}/s3/aws4_request", cfg.region); + let to_sign = format!( + "AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", + hexs(&Sha256::digest(canonical.as_bytes())) + ); + + let k_date = hmac(format!("AWS4{}", cfg.secret).as_bytes(), &date); + let k_region = hmac(&k_date, &cfg.region); + let k_service = hmac(&k_region, "s3"); + let k_signing = hmac(&k_service, "aws4_request"); + let signature = hexs(&hmac(&k_signing, &to_sign)); + + vec![ + ( + "Authorization".to_string(), + format!( + "AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, \ + Signature={signature}", + cfg.key_id + ), + ), + ("x-amz-content-sha256".to_string(), PAYLOAD.to_string()), + ("x-amz-date".to_string(), amz_date), + ] +} + +/// GET one artifact. `Err` is a message, never a panic — every caller is +/// expected to fall back to the embedded copy. +pub async fn get(cfg: &S3Config, tag: &str, asset: &str) -> Result, String> { + let url = cfg.url(tag, asset); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(300)) + .build() + .map_err(|e| format!("client: {e}"))?; + let mut req = client.get(&url); + for (k, v) in sign_get(cfg, &url, &chrono::Utc::now()) { + req = req.header(k, v); + } + let resp = req.send().await.map_err(|e| format!("s3 get: {e}"))?; + let status = resp.status(); + if !status.is_success() { + // The URL is deliberately not echoed: it names the bucket, and this + // string reaches the browser. + return Err(format!("s3 {status} for {tag}/{asset}")); + } + resp.bytes() + .await + .map(|b| b.to_vec()) + .map_err(|e| format!("s3 body: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// RFC 4231 test case 1 — the known-answer test the ported comment calls + /// for. Without it the hand-rolled HMAC is unproven, and a wrong signature + /// looks identical to a permissions problem at runtime. + #[test] + fn hmac_matches_rfc4231_case_1() { + let got = hexs(&hmac(&[0x0b; 20], "Hi There")); + assert_eq!( + got, "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7", + "HMAC-SHA256 disagrees with RFC 4231 case 1" + ); + } + + /// A partial configuration must read as "not configured", never as a + /// half-built client that fails later against an empty bucket name. + #[test] + fn from_env_needs_every_key() { + // Absent keys are the common case in a test process; assert the shape + // rather than mutating global env (which races other tests). + let cfg = S3Config { + endpoint: "https://example.invalid/".into(), + bucket: "b".into(), + region: "auto".into(), + key_id: "k".into(), + secret: "s".into(), + }; + assert_eq!( + cfg.url("fma-body-v3-v1", "body.soa.gz"), + "https://example.invalid//b/q2/bakes/fma-body-v3-v1/body.soa.gz", + "url() must place the artifact under /q2/bakes//" + ); + } + + /// The signature must depend on the key, the date and the path. A signer + /// that ignores any of them still "works" until it meets a real bucket. + #[test] + fn signature_varies_with_key_date_and_path() { + let base = S3Config { + endpoint: "https://t3.example".into(), + bucket: "bkt".into(), + region: "auto".into(), + key_id: "AKIA".into(), + secret: "secret".into(), + }; + let t0 = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(); + let t1 = chrono::DateTime::from_timestamp(1_700_090_000, 0).unwrap(); + let sig = |c: &S3Config, u: &str, t: &chrono::DateTime| { + sign_get(c, u, t) + .into_iter() + .find(|(k, _)| k == "Authorization") + .map(|(_, v)| v) + .expect("Authorization header") + }; + let a = sig(&base, &base.url("tag", "a.gz"), &t0); + let b_path = sig(&base, &base.url("tag", "b.gz"), &t0); + let b_date = sig(&base, &base.url("tag", "a.gz"), &t1); + let mut other = base.clone(); + other.secret = "different".into(); + let b_key = sig(&other, &base.url("tag", "a.gz"), &t0); + + assert_ne!(a, b_path, "signature ignores the object path"); + assert_ne!(a, b_date, "signature ignores the date"); + assert_ne!(a, b_key, "signature ignores the secret"); + } +} diff --git a/crates/cockpit-server/src/main.rs b/crates/cockpit-server/src/main.rs index 249a00029..5aa0c9293 100644 --- a/crates/cockpit-server/src/main.rs +++ b/crates/cockpit-server/src/main.rs @@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; use tower_http::cors::CorsLayer; +mod bake_s3; mod body_lod; mod clinical; mod codebook; @@ -388,6 +389,11 @@ async fn main() { // read. A missing codebook is otherwise indistinguishable from a // working map on every other signal (200s, full tiles, correct // geometry) while drawing grey and untagged. + // Serve a baked artifact from the shared object store, same-origin. + // The browser cannot fetch the bucket itself: it is private (and shared + // with clinical bakes that must stay private), and a browser cannot + // sign SigV4 without being handed credentials. See bake_s3.rs. + .route("/api/bake/:tag/:asset", get(bake_asset_handler)) .route("/api/osm/health", get(osm_features::osm_health_handler)) .route( "/api/osm/regions", @@ -733,6 +739,63 @@ async fn garmin_contour_handler( // ── Static file handler with SPA fallback ──────────────────────────────────── /// Serves embedded Vite build files. Falls back to index.html for SPA routing. +/// `GET /api/bake/:tag/:asset` — stream one baked artifact from the object +/// store, same-origin. +/// +/// Exists so a NEW bake can reach a running deploy without an image rebuild: +/// the embedded `dist/` copy is fixed at build time, this is not. The embedded +/// copy remains the primary path and is untouched — a deploy with no object +/// store configured behaves exactly as before, and this route simply 503s. +/// +/// `asset` is a single path segment by construction (axum will not match a `/` +/// inside it), so it cannot traverse out of the tag prefix; the guard below is +/// belt-and-braces for the `%2F`-decoded case. +async fn bake_asset_handler( + axum::extract::Path((tag, asset)): axum::extract::Path<(String, String)>, +) -> impl axum::response::IntoResponse { + use axum::http::{header, StatusCode}; + + let bad = |s: &str| s.contains('/') || s.contains('\\') || s.contains("..") || s.is_empty(); + if bad(&tag) || bad(&asset) { + return (StatusCode::BAD_REQUEST, "bad artifact name".to_string()).into_response(); + } + + let Some(cfg) = bake_s3::S3Config::from_env() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "object store not configured (AWS_ENDPOINT_URL / AWS_S3_BUCKET_NAME / \ + AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY); the embedded bake is still served" + .to_string(), + ) + .into_response(); + }; + + match bake_s3::get(&cfg, &tag, &asset).await { + Ok(bytes) => { + let ct = if asset.ends_with(".gz") { + "application/gzip" + } else { + "application/octet-stream" + }; + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, ct), + // Artifacts are content-addressed by name (a new bake gets a + // new filename), so they are safe to cache hard. + (header::CACHE_CONTROL, "public, max-age=31536000, immutable"), + ], + bytes, + ) + .into_response() + } + Err(e) => { + tracing::warn!(%tag, %asset, error = %e, "bake fetch failed"); + (StatusCode::NOT_FOUND, e).into_response() + } + } +} + async fn static_handler(uri: axum::http::Uri) -> Response { let path = uri.path().trim_start_matches('/'); From 21c5b3facfbe307dcf467fa41f76bf9b3f927aca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 05:55:13 +0000 Subject: [PATCH 2/6] Format the S3 bake fetch with rustfmt The Rustfmt (check) step on #152 failed: bake_s3.rs and the bake_asset_handler arm in main.rs were pushed unformatted, which skipped every later step -- clippy, the custom lints, and the Rust tests, so bake_s3's own unit tests have not run anywhere yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht --- crates/cockpit-server/src/bake_s3.rs | 14 +++++++++----- crates/cockpit-server/src/main.rs | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/cockpit-server/src/bake_s3.rs b/crates/cockpit-server/src/bake_s3.rs index c02f3c5aa..6074e8051 100644 --- a/crates/cockpit-server/src/bake_s3.rs +++ b/crates/cockpit-server/src/bake_s3.rs @@ -70,9 +70,7 @@ impl S3Config { pub fn from_env() -> Option { let get = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty()); Some(Self { - endpoint: get("AWS_ENDPOINT_URL")? - .trim_end_matches('/') - .to_string(), + endpoint: get("AWS_ENDPOINT_URL")?.trim_end_matches('/').to_string(), bucket: get("AWS_S3_BUCKET_NAME")?, region: get("AWS_DEFAULT_REGION").unwrap_or_else(|| "auto".to_string()), key_id: get("AWS_ACCESS_KEY_ID")?, @@ -121,14 +119,20 @@ fn hexs(bytes: &[u8]) -> String { /// /// Unsigned payload is correct rather than lax: a GET has no body, so signing /// the empty payload would authenticate nothing. -fn sign_get(cfg: &S3Config, url: &str, now: &chrono::DateTime) -> Vec<(String, String)> { +fn sign_get( + cfg: &S3Config, + url: &str, + now: &chrono::DateTime, +) -> Vec<(String, String)> { let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); let date = now.format("%Y%m%d").to_string(); let after_scheme = url.split_once("://").map_or(url, |(_, r)| r); let (host, path) = after_scheme .split_once('/') - .map_or((after_scheme, "/".to_string()), |(h, p)| (h, format!("/{p}"))); + .map_or((after_scheme, "/".to_string()), |(h, p)| { + (h, format!("/{p}")) + }); const PAYLOAD: &str = "UNSIGNED-PAYLOAD"; let signed_headers = "host;x-amz-content-sha256;x-amz-date"; diff --git a/crates/cockpit-server/src/main.rs b/crates/cockpit-server/src/main.rs index 5aa0c9293..bd7d0a8e1 100644 --- a/crates/cockpit-server/src/main.rs +++ b/crates/cockpit-server/src/main.rs @@ -753,7 +753,7 @@ async fn garmin_contour_handler( async fn bake_asset_handler( axum::extract::Path((tag, asset)): axum::extract::Path<(String, String)>, ) -> impl axum::response::IntoResponse { - use axum::http::{header, StatusCode}; + use axum::http::{StatusCode, header}; let bad = |s: &str| s.contains('/') || s.contains('\\') || s.contains("..") || s.is_empty(); if bad(&tag) || bad(&asset) { From 452a3b5e7192c5b3c357314b13be85e1ba3f2e8a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 06:08:55 +0000 Subject: [PATCH 3/6] Hydrate the body bake onto disk instead of proxying every request /api/bake/:tag/:asset fetched the artifact from the object store per request with a hand-rolled SigV4 signer -- a second object-store client in a crate that already had one, re-downloading ~59 MB on every cold browser cache. This repo already does the right shape one module over: osm_slab_hydrate fetches once at boot onto the volume and serves the file, which is also what medcare-rs's bake_hydrate and this bucket's producer (openstreetmap-website-rs's bake-entrypoint.sh, publishing to the same q2/bakes// + SHA256SUMS layout) do. body_bake reuses that machinery rather than repeating it: the four helpers it needs -- fetch_sums, download_verified, resolve_cache_hit, write_marker -- take the subsystem name as a parameter now so a body artifact's boot lines do not claim to be the OSM slab. The volume ladder is BODY_BAKE_DIR, then RAILWAY_VOL/body, then a writable /volume01/body, then an absolute temp path; a redeploy without the volume re-fetches rather than serving something stale, and the checksum is re-verified on a cache hit because the artifact outlives this code. Missing credentials leave /helix untouched -- it reads the embedded dist/ copy and never consults this -- and answer 503 here with the absent variable named. The handler no longer builds a path from the wire: it compares the requested coordinates against what this deploy actually hydrated and serves that file or nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht --- cockpit/src/BodyHelix2.tsx | 13 +- crates/cockpit-server/Cargo.toml | 4 - crates/cockpit-server/src/bake_s3.rs | 261 ------------------ crates/cockpit-server/src/body_bake.rs | 233 ++++++++++++++++ crates/cockpit-server/src/main.rs | 90 +++--- crates/cockpit-server/src/osm_slab_hydrate.rs | 70 +++-- 6 files changed, 328 insertions(+), 343 deletions(-) delete mode 100644 crates/cockpit-server/src/bake_s3.rs create mode 100644 crates/cockpit-server/src/body_bake.rs diff --git a/cockpit/src/BodyHelix2.tsx b/cockpit/src/BodyHelix2.tsx index c934ce847..3fba3b9ab 100644 --- a/cockpit/src/BodyHelix2.tsx +++ b/cockpit/src/BodyHelix2.tsx @@ -734,14 +734,15 @@ async function fetchSoa(): Promise { // Fastest and always correct when present, so it stays first. const s = await fetch(`/${stamped}`).catch(() => null); if (s && s.ok) return inflate(s); - // 2. The object store, proxied same-origin by /api/bake/:tag/:asset. This is + // 2. The hydrated bake, served same-origin by /api/bake/:tag/:asset. This is // what lets a NEWLY published bake reach a RUNNING deploy: the dist/ copy // above is fixed at image build, so without this a v4 bake needs a rebuild - // before /helix2 can see it. The server signs (SigV4) and streams; the - // bucket is private and stays private — it is shared with clinical bakes, - // and a browser cannot sign without being handed credentials. - // The tag comes from the manifest so publishing a bake is a manifest edit, - // not a code change; absent, this hop is skipped rather than guessed. + // before /helix2 can see it. The server fetched it from the object store + // ONCE at boot onto its volume and serves the file; the bucket is private + // and stays private — it is shared with clinical bakes, and a browser + // cannot sign for it. The tag comes from the manifest so publishing a bake + // is a manifest edit plus the deploy's BODY_BAKE_TAG, not a code change; + // absent, this hop is skipped rather than guessed. const tag: string | undefined = man?.helix_v4_tag; if (tag) { const o = await fetch(`/api/bake/${encodeURIComponent(tag)}/${encodeURIComponent(stamped)}`) diff --git a/crates/cockpit-server/Cargo.toml b/crates/cockpit-server/Cargo.toml index 49772f3ec..62109e6fa 100644 --- a/crates/cockpit-server/Cargo.toml +++ b/crates/cockpit-server/Cargo.toml @@ -77,10 +77,6 @@ memmap2 = "0.5" object_store = { version = "0.13.2", features = ["aws"] } sha2 = "0.10" hex = "0.4" -# Object-store fetch for /api/bake/* (see bake_s3.rs). Both already resolve in -# the workspace lock via other crates, so this adds no new dependency tree. -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } -chrono = "0.4" futures = "0.3" # `osm_slab_hydrate::sha256_file`: advise the kernel to drop a hashed file's # pages from its page cache afterward (`posix_fadvise(POSIX_FADV_DONTNEED)`, diff --git a/crates/cockpit-server/src/bake_s3.rs b/crates/cockpit-server/src/bake_s3.rs deleted file mode 100644 index 6074e8051..000000000 --- a/crates/cockpit-server/src/bake_s3.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! Serve the baked body/scene artifacts from the shared object store. -//! -//! # Why this exists -//! -//! The artifacts reach the browser today by being baked INTO the image: the -//! Dockerfile `curl`s them from a GitHub release into `cockpit/dist/`, which -//! `include_dir!` embeds in the binary. That works, and it stays — this is an -//! additional source, not a replacement. -//! -//! What it does not do is let a NEW bake reach a running deploy. A v4 body bake -//! published to the object store would otherwise need an image rebuild before -//! `/helix2` could see it. Fetching at request time decouples the artifact from -//! the image. -//! -//! # Why the server fetches, and not the browser -//! -//! Two independent reasons, either sufficient: -//! -//! 1. **The bucket is private and must stay private.** It is shared across -//! repos and holds MedCare-rs clinical ontology bakes. Making objects -//! public-read so a browser could fetch them directly would expose that -//! material; it is not an option under any deadline. -//! 2. **A browser cannot sign a SigV4 request** without being handed the -//! credentials, which is the same exposure by another route. -//! -//! So the bytes come through this route, same-origin, and the credentials never -//! leave the server. This also sidesteps the CORS problem the Dockerfile -//! already documents for the release redirect. -//! -//! # Why no volume -//! -//! Not every q2 deployment has one (operator, 2026-09-07), so a hydrate-to-disk -//! design like `osm_slab_hydrate` would work on some deploys and silently not -//! on others. This streams per request and keeps no local copy: correct -//! everywhere, at the cost of re-fetching. A volume cache can be layered on -//! later for the deploys that have one — it is an optimisation, not a -//! prerequisite. -//! -//! # Signing -//! -//! Ported from `MedCare-rs::medcare-server::bake_s3`, which already solved this -//! against the same bucket. HMAC is written out rather than pulled in, for the -//! reason recorded there: the `hmac` crate's `digest` version conflicts with the -//! `sha2` in tree, which is a poor trade for fifteen lines of xor. - -use sha2::{Digest, Sha256}; - -/// The key prefix this repo's bakes live under inside the shared bucket. -/// -/// A constant, not an env var: a deploy pointed at another prefix is fetching -/// another project's artifacts, which is better made impossible than -/// configurable. Mirrors `REPO_PREFIX` in MedCare-rs for the same reason. -const REPO_PREFIX: &str = "q2"; - -/// Object-store coordinates, read from the environment. -/// -/// All five must be present. A partial configuration is treated as "no object -/// store" rather than as a broken one, so a deploy that never configured it -/// behaves exactly as before instead of failing at request time. -#[derive(Clone, Debug)] -pub struct S3Config { - endpoint: String, - bucket: String, - region: String, - key_id: String, - secret: String, -} - -impl S3Config { - pub fn from_env() -> Option { - let get = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty()); - Some(Self { - endpoint: get("AWS_ENDPOINT_URL")?.trim_end_matches('/').to_string(), - bucket: get("AWS_S3_BUCKET_NAME")?, - region: get("AWS_DEFAULT_REGION").unwrap_or_else(|| "auto".to_string()), - key_id: get("AWS_ACCESS_KEY_ID")?, - secret: get("AWS_SECRET_ACCESS_KEY")?, - }) - } - - fn url(&self, tag: &str, asset: &str) -> String { - format!( - "{}/{}/{REPO_PREFIX}/bakes/{tag}/{asset}", - self.endpoint, self.bucket - ) - } -} - -/// HMAC-SHA256 (RFC 2104) over the `sha2` already in the tree. -fn hmac(key: &[u8], msg: &str) -> Vec { - const BLOCK: usize = 64; - let mut k = [0u8; BLOCK]; - if key.len() > BLOCK { - k[..32].copy_from_slice(&Sha256::digest(key)); - } else { - k[..key.len()].copy_from_slice(key); - } - let mut inner = Sha256::new(); - inner.update(k.iter().map(|b| b ^ 0x36).collect::>()); - inner.update(msg.as_bytes()); - let inner = inner.finalize(); - - let mut outer = Sha256::new(); - outer.update(k.iter().map(|b| b ^ 0x5c).collect::>()); - outer.update(inner); - outer.finalize().to_vec() -} - -fn hexs(bytes: &[u8]) -> String { - use std::fmt::Write as _; - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - let _ = write!(s, "{b:02x}"); - } - s -} - -/// Sign an unsigned-payload `GET` and return the headers to send. -/// -/// Unsigned payload is correct rather than lax: a GET has no body, so signing -/// the empty payload would authenticate nothing. -fn sign_get( - cfg: &S3Config, - url: &str, - now: &chrono::DateTime, -) -> Vec<(String, String)> { - let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); - let date = now.format("%Y%m%d").to_string(); - - let after_scheme = url.split_once("://").map_or(url, |(_, r)| r); - let (host, path) = after_scheme - .split_once('/') - .map_or((after_scheme, "/".to_string()), |(h, p)| { - (h, format!("/{p}")) - }); - - const PAYLOAD: &str = "UNSIGNED-PAYLOAD"; - let signed_headers = "host;x-amz-content-sha256;x-amz-date"; - let canonical = format!( - "GET\n{path}\n\nhost:{host}\nx-amz-content-sha256:{PAYLOAD}\nx-amz-date:{amz_date}\n\n\ - {signed_headers}\n{PAYLOAD}" - ); - let scope = format!("{date}/{}/s3/aws4_request", cfg.region); - let to_sign = format!( - "AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", - hexs(&Sha256::digest(canonical.as_bytes())) - ); - - let k_date = hmac(format!("AWS4{}", cfg.secret).as_bytes(), &date); - let k_region = hmac(&k_date, &cfg.region); - let k_service = hmac(&k_region, "s3"); - let k_signing = hmac(&k_service, "aws4_request"); - let signature = hexs(&hmac(&k_signing, &to_sign)); - - vec![ - ( - "Authorization".to_string(), - format!( - "AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, \ - Signature={signature}", - cfg.key_id - ), - ), - ("x-amz-content-sha256".to_string(), PAYLOAD.to_string()), - ("x-amz-date".to_string(), amz_date), - ] -} - -/// GET one artifact. `Err` is a message, never a panic — every caller is -/// expected to fall back to the embedded copy. -pub async fn get(cfg: &S3Config, tag: &str, asset: &str) -> Result, String> { - let url = cfg.url(tag, asset); - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(300)) - .build() - .map_err(|e| format!("client: {e}"))?; - let mut req = client.get(&url); - for (k, v) in sign_get(cfg, &url, &chrono::Utc::now()) { - req = req.header(k, v); - } - let resp = req.send().await.map_err(|e| format!("s3 get: {e}"))?; - let status = resp.status(); - if !status.is_success() { - // The URL is deliberately not echoed: it names the bucket, and this - // string reaches the browser. - return Err(format!("s3 {status} for {tag}/{asset}")); - } - resp.bytes() - .await - .map(|b| b.to_vec()) - .map_err(|e| format!("s3 body: {e}")) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// RFC 4231 test case 1 — the known-answer test the ported comment calls - /// for. Without it the hand-rolled HMAC is unproven, and a wrong signature - /// looks identical to a permissions problem at runtime. - #[test] - fn hmac_matches_rfc4231_case_1() { - let got = hexs(&hmac(&[0x0b; 20], "Hi There")); - assert_eq!( - got, "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7", - "HMAC-SHA256 disagrees with RFC 4231 case 1" - ); - } - - /// A partial configuration must read as "not configured", never as a - /// half-built client that fails later against an empty bucket name. - #[test] - fn from_env_needs_every_key() { - // Absent keys are the common case in a test process; assert the shape - // rather than mutating global env (which races other tests). - let cfg = S3Config { - endpoint: "https://example.invalid/".into(), - bucket: "b".into(), - region: "auto".into(), - key_id: "k".into(), - secret: "s".into(), - }; - assert_eq!( - cfg.url("fma-body-v3-v1", "body.soa.gz"), - "https://example.invalid//b/q2/bakes/fma-body-v3-v1/body.soa.gz", - "url() must place the artifact under /q2/bakes//" - ); - } - - /// The signature must depend on the key, the date and the path. A signer - /// that ignores any of them still "works" until it meets a real bucket. - #[test] - fn signature_varies_with_key_date_and_path() { - let base = S3Config { - endpoint: "https://t3.example".into(), - bucket: "bkt".into(), - region: "auto".into(), - key_id: "AKIA".into(), - secret: "secret".into(), - }; - let t0 = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(); - let t1 = chrono::DateTime::from_timestamp(1_700_090_000, 0).unwrap(); - let sig = |c: &S3Config, u: &str, t: &chrono::DateTime| { - sign_get(c, u, t) - .into_iter() - .find(|(k, _)| k == "Authorization") - .map(|(_, v)| v) - .expect("Authorization header") - }; - let a = sig(&base, &base.url("tag", "a.gz"), &t0); - let b_path = sig(&base, &base.url("tag", "b.gz"), &t0); - let b_date = sig(&base, &base.url("tag", "a.gz"), &t1); - let mut other = base.clone(); - other.secret = "different".into(); - let b_key = sig(&other, &base.url("tag", "a.gz"), &t0); - - assert_ne!(a, b_path, "signature ignores the object path"); - assert_ne!(a, b_date, "signature ignores the date"); - assert_ne!(a, b_key, "signature ignores the secret"); - } -} diff --git a/crates/cockpit-server/src/body_bake.rs b/crates/cockpit-server/src/body_bake.rs new file mode 100644 index 000000000..de0c60bf5 --- /dev/null +++ b/crates/cockpit-server/src/body_bake.rs @@ -0,0 +1,233 @@ +//! Boot-time hydration of the baked FMA body onto durable disk. +//! +//! # The topology — identical to the OSM slab's, one artifact narrower +//! +//! ```text +//! S3 (durable source of truth) +//! │ s3://$AWS_S3_BUCKET_NAME/q2/bakes//{, SHA256SUMS} +//! ▼ +//! volume ($RAILWAY_VOL/body, /volume01/body, else a temp dir) +//! │ +//! ▼ +//! /api/bake// (served from the file, same-origin) +//! ``` +//! +//! # Why hydrate instead of proxying each request +//! +//! The first version of this fetched the artifact from S3 **per request** with +//! a hand-rolled SigV4 signer. That was a second object-store client in a crate +//! that already had one ([`crate::osm_slab_hydrate`]'s `object_store`), and it +//! re-downloaded ~59 MB on every cold browser cache. The boot-time shape is +//! what this repo already does for the OSM slab and what `medcare-rs`'s +//! `bake_hydrate` does for the ontology crystal: fetch once, land it on the +//! volume, serve from the file. +//! +//! # The volume is a cache, never truth +//! +//! Deleting it costs a re-download and nothing else. A redeploy that lands on a +//! container WITHOUT the volume mounted falls through the ladder to a temp dir +//! and re-fetches — degraded (it pays the transfer again) but never wrong. That +//! is the whole reason the checksum is re-verified on a cache HIT and not only +//! after a download: the artifact outlives this code, so "we already have it" +//! is not evidence that what we have is complete. +//! +//! # Absent configuration is not an error +//! +//! No bucket, no credentials ⇒ `None`, one WARN naming what is missing, and +//! `/api/bake/*` answers 503 while the embedded `dist/` copy keeps serving +//! `/helix` exactly as before. Nothing here can take the working route down. + +use std::path::{Path, PathBuf}; + +use object_store::aws::AmazonS3Builder; + +use crate::osm_slab_hydrate::{ + CacheDecision, download_verified, env_var_nonempty, fetch_sums, resolve_cache_hit, +}; + +/// Log prefix, so a boot log distinguishes this from the OSM slab's lines. +const LABEL: &str = "body bake"; + +/// The release tag the artifact lives under. `BODY_BAKE_TAG` selects another. +const DEFAULT_TAG: &str = "fma-body-v3-v1"; + +/// The artifact within that tag. `BODY_BAKE_ASSET` selects another. +const DEFAULT_ASSET: &str = "body.20260629c.v6helix.soa.gz"; + +/// Where a hydrated copy lands, in the same order [`crate::osm_lifecycle`] +/// resolves the OSM root — an explicit override, then a declared volume, then +/// the platform default mount, then an absolute temp path. +/// +/// The temp fallback is deliberate and last: a relative path would follow the +/// process CWD, which is the failure `medcare-rs::bake_hydrate::absolutize` +/// documents (a handler resolving the store after a `set_current_dir` addresses +/// a *different* directory while logging the same string). +fn cache_dir() -> PathBuf { + if let Some(p) = env_var_nonempty("BODY_BAKE_DIR") { + return PathBuf::from(p); + } + if let Some(v) = env_var_nonempty("RAILWAY_VOL") { + return Path::new(&v).join("body"); + } + let vol01 = Path::new("/volume01"); + if vol01.is_dir() + && !vol01 + .metadata() + .map(|m| m.permissions().readonly()) + .unwrap_or(true) + { + return vol01.join("body"); + } + std::env::temp_dir().join("q2-body") +} + +/// A name that is interpolated into BOTH an S3 key and a filesystem path. +/// +/// The validation is not decoration: `..`, `/`, or a backslash in one of these +/// environment variables would read a different prefix and write outside the +/// cache directory. Restricting the alphabet makes both uses safe by +/// construction instead of by careful escaping at each site. +fn is_safe_name(s: &str) -> bool { + !s.is_empty() + && s.len() <= 128 + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') + && !s.contains("..") +} + +fn from_env_or(key: &str, default: &str) -> String { + match env_var_nonempty(key) { + None => default.to_string(), + Some(v) if is_safe_name(&v) => v, + Some(bad) => { + tracing::warn!( + rejected = %bad, using = default, key, + "{LABEL}: name must be [A-Za-z0-9._-] with no `..`; ignoring it" + ); + default.to_string() + } + } +} + +/// The tag and asset this deploy serves. +#[must_use] +pub fn coordinates() -> (String, String) { + ( + from_env_or("BODY_BAKE_TAG", DEFAULT_TAG), + from_env_or("BODY_BAKE_ASSET", DEFAULT_ASSET), + ) +} + +/// The local path the artifact would occupy, whether or not it is there yet. +#[must_use] +pub fn local_path() -> PathBuf { + cache_dir().join(coordinates().1) +} + +/// Resolve a local, checksum-verified copy of the body bake, hydrating from S3 +/// if needed. +/// +/// Returns `None` — never panics, never returns an unverified path — when the +/// object store is not configured, the checksum file is absent, or the transfer +/// fails. Every one of those says so at WARN and leaves the embedded `dist/` +/// copy serving. +pub async fn ensure_body_bake_local() -> Option { + let (tag, asset) = coordinates(); + + let Some(bucket) = env_var_nonempty("AWS_S3_BUCKET_NAME") else { + tracing::warn!( + "{LABEL}: AWS_S3_BUCKET_NAME is unset — /api/bake/* will answer 503 and \ + the embedded bake keeps serving /helix" + ); + return None; + }; + + let dir = cache_dir(); + if let Err(e) = std::fs::create_dir_all(&dir) { + tracing::error!(dir = %dir.display(), error = %e, "{LABEL}: cannot create cache dir"); + return None; + } + let dest = dir.join(&asset); + + let store = match AmazonS3Builder::from_env() + .with_bucket_name(&bucket) + .build() + { + Ok(s) => s, + Err(e) => { + tracing::error!(error = %e, "{LABEL}: S3 client build failed"); + return None; + } + }; + + let prefix = format!("q2/bakes/{tag}"); + let sums = fetch_sums(LABEL, &store, &prefix).await?; + let want = sums + .iter() + .find(|(n, _)| *n == asset) + .map(|(_, h)| h.clone()); + let Some(want) = want else { + tracing::error!(%asset, %prefix, "{LABEL}: no checksum pinned for this asset; refusing"); + return None; + }; + + if dest.is_file() { + match resolve_cache_hit(LABEL, &dest, &want) { + CacheDecision::TrustedViaMarker | CacheDecision::Verified => { + tracing::info!(path = %dest.display(), "{LABEL}: cache hit"); + return Some(dest); + } + CacheDecision::Mismatch(got) => { + tracing::warn!(%got, %want, "{LABEL}: cached copy failed its checksum; re-fetching") + } + CacheDecision::Unreadable(e) => { + tracing::warn!(error = %e, "{LABEL}: cannot hash cached copy; re-fetching") + } + } + } + + if !download_verified(LABEL, &store, &prefix, &asset, &dest, &want).await { + return None; + } + tracing::info!(path = %dest.display(), %tag, "{LABEL}: hydrated and verified"); + Some(dest) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_validation_rejects_traversal_and_separators() { + assert!(is_safe_name("fma-body-v4-v1")); + assert!(is_safe_name("body.20260629c.v6helix.soa.gz")); + assert!(!is_safe_name("../other-tag")); + assert!(!is_safe_name("a/b")); + assert!(!is_safe_name("a\\b")); + assert!(!is_safe_name("")); + // A name that is only ALMOST traversal still has to survive, or the + // guard is the kind that fires on everything and discriminates nothing. + assert!(is_safe_name("v4.1")); + } + + #[test] + fn cache_dir_prefers_an_explicit_override_over_the_volume() { + // SAFETY: single-threaded test, and both variables are restored below. + unsafe { + std::env::set_var("BODY_BAKE_DIR", "/tmp/explicit-body"); + std::env::set_var("RAILWAY_VOL", "/tmp/vol"); + } + assert_eq!(cache_dir(), PathBuf::from("/tmp/explicit-body")); + unsafe { std::env::remove_var("BODY_BAKE_DIR") }; + assert_eq!(cache_dir(), PathBuf::from("/tmp/vol/body")); + unsafe { std::env::remove_var("RAILWAY_VOL") }; + } + + #[test] + fn a_rejected_tag_falls_back_to_the_default_rather_than_reading_it() { + // SAFETY: single-threaded test; the variable is removed below. + unsafe { std::env::set_var("BODY_BAKE_TAG", "../secrets") }; + assert_eq!(from_env_or("BODY_BAKE_TAG", DEFAULT_TAG), DEFAULT_TAG); + unsafe { std::env::remove_var("BODY_BAKE_TAG") }; + } +} diff --git a/crates/cockpit-server/src/main.rs b/crates/cockpit-server/src/main.rs index bd7d0a8e1..55d375172 100644 --- a/crates/cockpit-server/src/main.rs +++ b/crates/cockpit-server/src/main.rs @@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; use tower_http::cors::CorsLayer; -mod bake_s3; +mod body_bake; mod body_lod; mod clinical; mod codebook; @@ -292,6 +292,14 @@ async fn main() { } } + // The FMA body bake, on the same terms as the OSM slab above: S3 is truth, + // the volume is a cache that survives a rebuild, and a redeploy without the + // volume mounted re-fetches rather than serving something stale. Failure is + // never fatal — `/helix` reads the copy embedded in `dist/` and does not + // consult this at all; only `/helix2`'s newer bake needs it, and it degrades + // to a 503 with the missing variable named in the boot log. + let _ = body_bake::ensure_body_bake_local().await; + let (tx, _rx) = broadcast::channel::(256); let scene_state = shader_stream::new_scene_state(); let osm_manager = Arc::new(osm_artifact_manager::OsmArtifactManager::absent()); @@ -389,10 +397,11 @@ async fn main() { // read. A missing codebook is otherwise indistinguishable from a // working map on every other signal (200s, full tiles, correct // geometry) while drawing grey and untagged. - // Serve a baked artifact from the shared object store, same-origin. - // The browser cannot fetch the bucket itself: it is private (and shared - // with clinical bakes that must stay private), and a browser cannot - // sign SigV4 without being handed credentials. See bake_s3.rs. + // Serve the hydrated bake from disk, same-origin. The browser cannot + // fetch the bucket itself: it is private (and shared with clinical + // bakes that must stay private), and a browser cannot sign SigV4 + // without being handed credentials. The bytes are already local — + // `body_bake::ensure_body_bake_local` put them there at boot. .route("/api/bake/:tag/:asset", get(bake_asset_handler)) .route("/api/osm/health", get(osm_features::osm_health_handler)) .route( @@ -739,61 +748,58 @@ async fn garmin_contour_handler( // ── Static file handler with SPA fallback ──────────────────────────────────── /// Serves embedded Vite build files. Falls back to index.html for SPA routing. -/// `GET /api/bake/:tag/:asset` — stream one baked artifact from the object -/// store, same-origin. +/// `GET /api/bake/:tag/:asset` — serve the hydrated bake from disk. /// /// Exists so a NEW bake can reach a running deploy without an image rebuild: /// the embedded `dist/` copy is fixed at build time, this is not. The embedded -/// copy remains the primary path and is untouched — a deploy with no object -/// store configured behaves exactly as before, and this route simply 503s. +/// copy remains the primary path and is untouched — a deploy that hydrated +/// nothing simply 503s here and `/helix` is unaffected. /// -/// `asset` is a single path segment by construction (axum will not match a `/` -/// inside it), so it cannot traverse out of the tag prefix; the guard below is -/// belt-and-braces for the `%2F`-decoded case. +/// The coordinates are checked against what this deploy actually hydrated +/// rather than used to address the filesystem. A request naming some other tag +/// is answered "not this deploy's bake", not translated into a path — so no +/// input from the wire ever reaches a `join`. async fn bake_asset_handler( axum::extract::Path((tag, asset)): axum::extract::Path<(String, String)>, ) -> impl axum::response::IntoResponse { use axum::http::{StatusCode, header}; - let bad = |s: &str| s.contains('/') || s.contains('\\') || s.contains("..") || s.is_empty(); - if bad(&tag) || bad(&asset) { - return (StatusCode::BAD_REQUEST, "bad artifact name".to_string()).into_response(); + let (have_tag, have_asset) = body_bake::coordinates(); + if tag != have_tag || asset != have_asset { + return ( + StatusCode::NOT_FOUND, + format!("this deploy serves {have_tag}/{have_asset}"), + ) + .into_response(); } - let Some(cfg) = bake_s3::S3Config::from_env() else { + let path = body_bake::local_path(); + let Ok(bytes) = tokio::fs::read(&path).await else { return ( StatusCode::SERVICE_UNAVAILABLE, - "object store not configured (AWS_ENDPOINT_URL / AWS_S3_BUCKET_NAME / \ - AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY); the embedded bake is still served" + "the bake is not hydrated on this deploy (see the boot log for which \ + variable is missing); the embedded bake is still served" .to_string(), ) .into_response(); }; - match bake_s3::get(&cfg, &tag, &asset).await { - Ok(bytes) => { - let ct = if asset.ends_with(".gz") { - "application/gzip" - } else { - "application/octet-stream" - }; - ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, ct), - // Artifacts are content-addressed by name (a new bake gets a - // new filename), so they are safe to cache hard. - (header::CACHE_CONTROL, "public, max-age=31536000, immutable"), - ], - bytes, - ) - .into_response() - } - Err(e) => { - tracing::warn!(%tag, %asset, error = %e, "bake fetch failed"); - (StatusCode::NOT_FOUND, e).into_response() - } - } + let ct = if asset.ends_with(".gz") { + "application/gzip" + } else { + "application/octet-stream" + }; + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, ct), + // Artifacts are content-addressed by name (a new bake gets a new + // filename), so they are safe to cache hard. + (header::CACHE_CONTROL, "public, max-age=31536000, immutable"), + ], + bytes, + ) + .into_response() } async fn static_handler(uri: axum::http::Uri) -> Response { diff --git a/crates/cockpit-server/src/osm_slab_hydrate.rs b/crates/cockpit-server/src/osm_slab_hydrate.rs index 544b17a3c..254bade23 100644 --- a/crates/cockpit-server/src/osm_slab_hydrate.rs +++ b/crates/cockpit-server/src/osm_slab_hydrate.rs @@ -72,6 +72,11 @@ use object_store::aws::AmazonS3Builder; use object_store::{ObjectStore, ObjectStoreExt}; use sha2::{Digest, Sha256}; +/// The label the in-module call sites pass to the reusable helpers below +/// ([`fetch_sums`], [`download_verified`], …), which `body_bake` also calls +/// with its own name. It is the log prefix, nothing more. +pub(crate) const SLAB: &str = "osm slab"; + /// The region baked by default. `OSM_BAKE_REGION` selects another one. /// /// The region is the ONLY thing that differs between bakes: the baker @@ -146,7 +151,7 @@ fn cache_dir(vol: &str) -> PathBuf { /// in), and that must fail the SAME way as the variable not existing at all — /// not attempt a real S3 call with an empty bucket name, which fails later, /// differently, and less legibly than "not configured". -fn env_var_nonempty(key: &str) -> Option { +pub(crate) fn env_var_nonempty(key: &str) -> Option { std::env::var(key).ok().filter(|v| !v.trim().is_empty()) } @@ -376,7 +381,7 @@ async fn hydrate_one_region( volume re-verifies in ~1s)" ); - let sums = fetch_sums(store, &prefix).await?; + let sums = fetch_sums(SLAB, store, &prefix).await?; for name in region_artifacts.iter().map(String::as_str) { let want = match sums.iter().find(|(k, _)| k == name).map(|(_, h)| h.clone()) { @@ -394,7 +399,7 @@ async fn hydrate_one_region( // Cache hit, but only if it still hashes correctly — see module docs. if dest.is_file() { - match resolve_cache_hit(&dest, &want) { + match resolve_cache_hit(SLAB, &dest, &want) { CacheDecision::TrustedViaMarker => { tracing::info!( region, @@ -422,7 +427,7 @@ async fn hydrate_one_region( } } - if !download_verified(store, &prefix, name, &dest, &want).await { + if !download_verified(SLAB, store, &prefix, name, &dest, &want).await { return None; } } @@ -432,18 +437,22 @@ async fn hydrate_one_region( /// Fetch and parse `SHA256SUMS` — ` ` per line, the `sha256sum` /// format the bucket already uses for the MedCare bakes. -async fn fetch_sums(store: &impl ObjectStore, prefix: &str) -> Option> { +pub(crate) async fn fetch_sums( + label: &str, + store: &impl ObjectStore, + prefix: &str, +) -> Option> { let path = object_store::path::Path::from(format!("{prefix}/SHA256SUMS")); let bytes = match store.get(&path).await { Ok(r) => match r.bytes().await { Ok(b) => b, Err(e) => { - tracing::error!(error = %e, "osm slab: SHA256SUMS body read failed"); + tracing::error!(error = %e, "{label}: SHA256SUMS body read failed"); return None; } }, Err(e) => { - tracing::error!(error = %e, %prefix, "osm slab: SHA256SUMS not readable"); + tracing::error!(error = %e, %prefix, "{label}: SHA256SUMS not readable"); return None; } }; @@ -452,7 +461,7 @@ async fn fetch_sums(store: &impl ObjectStore, prefix: &str) -> Option `. -fn parse_sums(text: &str) -> Vec<(String, String)> { +pub(crate) fn parse_sums(text: &str) -> Vec<(String, String)> { text.lines() .filter_map(|line| { let mut it = line.split_whitespace(); @@ -469,7 +478,8 @@ fn parse_sums(text: &str) -> Vec<(String, String)> { /// Stream one object to `.part`, hash while writing, and rename into /// place only if it matches. A mismatch leaves no file behind. -async fn download_verified( +pub(crate) async fn download_verified( + label: &str, store: &impl ObjectStore, prefix: &str, name: &str, @@ -483,7 +493,7 @@ async fn download_verified( let result = match store.get(&path).await { Ok(r) => r, Err(e) => { - tracing::error!(artifact = name, error = %e, "osm slab: download failed"); + tracing::error!(artifact = name, error = %e, "{label}: download failed"); return false; } }; @@ -492,7 +502,7 @@ async fn download_verified( let mut file = match std::fs::File::create(&part) { Ok(f) => f, Err(e) => { - tracing::error!(artifact = name, error = %e, "osm slab: cannot create .part"); + tracing::error!(artifact = name, error = %e, "{label}: cannot create .part"); return false; } }; @@ -504,21 +514,21 @@ async fn download_verified( let chunk = match chunk { Ok(c) => c, Err(e) => { - tracing::error!(artifact = name, error = %e, "osm slab: stream error"); + tracing::error!(artifact = name, error = %e, "{label}: stream error"); let _ = std::fs::remove_file(&part); return false; } }; hasher.update(&chunk); if let Err(e) = file.write_all(&chunk) { - tracing::error!(artifact = name, error = %e, "osm slab: write error"); + tracing::error!(artifact = name, error = %e, "{label}: write error"); let _ = std::fs::remove_file(&part); return false; } written += chunk.len() as u64; } if let Err(e) = file.flush() { - tracing::error!(artifact = name, error = %e, "osm slab: flush error"); + tracing::error!(artifact = name, error = %e, "{label}: flush error"); let _ = std::fs::remove_file(&part); return false; } @@ -526,22 +536,22 @@ async fn download_verified( let got = hex::encode(hasher.finalize()); if got != want { - tracing::error!(artifact = name, %got, %want, "osm slab: checksum mismatch; discarding"); + tracing::error!(artifact = name, %got, %want, "{label}: checksum mismatch; discarding"); let _ = std::fs::remove_file(&part); return false; } if let Err(e) = std::fs::rename(&part, dest) { - tracing::error!(artifact = name, error = %e, "osm slab: rename into place failed"); + tracing::error!(artifact = name, error = %e, "{label}: rename into place failed"); let _ = std::fs::remove_file(&part); return false; } // A fresh download IS a real verification — record it so the NEXT boot's // cache hit can trust it via `resolve_cache_hit` without re-reading. - write_marker(dest, &got); + write_marker(label, dest, &got); tracing::info!( artifact = name, bytes = written, - "osm slab: downloaded and verified" + "{label}: downloaded and verified" ); true } @@ -626,7 +636,7 @@ fn marker_path(dest: &Path) -> PathBuf { /// changed at all: the marker's digest no longer equals the freshly /// fetched `want`, so this declines and `sha256_file` runs for real, /// which then correctly reports a mismatch and triggers a re-download. -fn trusted_via_marker(dest: &Path, want: &str) -> bool { +pub(crate) fn trusted_via_marker(dest: &Path, want: &str) -> bool { let Some(marker) = std::fs::read_to_string(marker_path(dest)) .ok() .and_then(|text| VerifiedMarker::parse(&text)) @@ -643,7 +653,7 @@ fn trusted_via_marker(dest: &Path, want: &str) -> bool { /// proven-correct identity a later boot's [`trusted_via_marker`] can trust. /// Failure to write is logged, never fatal: the next boot simply re-hashes, /// which is exactly today's behaviour without this whole mechanism. -fn write_marker(dest: &Path, digest: &str) { +pub(crate) fn write_marker(label: &str, dest: &Path, digest: &str) { let Some((mtime_nanos, len)) = stat_identity(dest) else { return; }; @@ -655,7 +665,7 @@ fn write_marker(dest: &Path, digest: &str) { if let Err(e) = std::fs::write(marker_path(dest), marker.render()) { tracing::warn!( path = %dest.display(), error = %e, - "osm slab: could not write verification marker (non-fatal; next boot re-hashes)" + "{label}: could not write verification marker (non-fatal; next boot re-hashes)" ); } } @@ -664,7 +674,7 @@ fn write_marker(dest: &Path, digest: &str) { /// [`ensure_slab_local`]'s loop needs to log and branch on. Split out from /// that loop so it is directly testable without env vars or an S3 stub — /// see `resolve_cache_hit_trusts_a_matching_marker_without_hashing` below. -enum CacheDecision { +pub(crate) enum CacheDecision { /// The marker proved identity without touching the file's bytes. TrustedViaMarker, /// A real `sha256_file` ran and matched `want` — the marker is now @@ -676,13 +686,13 @@ enum CacheDecision { Unreadable(std::io::Error), } -fn resolve_cache_hit(dest: &Path, want: &str) -> CacheDecision { +pub(crate) fn resolve_cache_hit(label: &str, dest: &Path, want: &str) -> CacheDecision { if trusted_via_marker(dest, want) { return CacheDecision::TrustedViaMarker; } match sha256_file(dest) { Ok(got) if got == want => { - write_marker(dest, &got); + write_marker(label, dest, &got); CacheDecision::Verified } Ok(got) => CacheDecision::Mismatch(got), @@ -910,7 +920,7 @@ not-a-hash junk.txt let p = write_temp_artifact(&dir, "artifact.bin", b"original content"); let (mtime_nanos, len) = stat_identity(&p).expect("stat"); let digest = sha256_file(&p).expect("hash"); - write_marker(&p, &digest); + write_marker(SLAB, &p, &digest); // Rewrite with DIFFERENT content — a real mtime bump, not a forced one. std::fs::write(&p, b"different content, different length").expect("rewrite"); @@ -936,7 +946,7 @@ not-a-hash junk.txt std::fs::create_dir_all(&dir).unwrap(); let p = write_temp_artifact(&dir, "artifact.bin", b"stable content"); let digest = sha256_file(&p).expect("hash"); - write_marker(&p, &digest); + write_marker(SLAB, &p, &digest); assert!( !trusted_via_marker(&p, &"f".repeat(64)), @@ -955,7 +965,7 @@ not-a-hash junk.txt std::fs::create_dir_all(&dir).unwrap(); let p = write_temp_artifact(&dir, "artifact.bin", b"unchanged content"); let digest = sha256_file(&p).expect("hash"); - write_marker(&p, &digest); + write_marker(SLAB, &p, &digest); assert!(trusted_via_marker(&p, &digest)); @@ -976,10 +986,10 @@ not-a-hash junk.txt std::fs::create_dir_all(&dir).unwrap(); let p = write_temp_artifact(&dir, "artifact.bin", b"trust me, i'm unchanged"); let digest = sha256_file(&p).expect("hash"); - write_marker(&p, &digest); + write_marker(SLAB, &p, &digest); let before = FADVISE_ATTEMPTED.load(std::sync::atomic::Ordering::Relaxed); - let decision = resolve_cache_hit(&p, &digest); + let decision = resolve_cache_hit(SLAB, &p, &digest); let after = FADVISE_ATTEMPTED.load(std::sync::atomic::Ordering::Relaxed); assert!(matches!(decision, CacheDecision::TrustedViaMarker)); @@ -1006,7 +1016,7 @@ not-a-hash junk.txt let digest = sha256_file(&p).expect("hash"); let before = FADVISE_ATTEMPTED.load(std::sync::atomic::Ordering::Relaxed); - let decision = resolve_cache_hit(&p, &digest); + let decision = resolve_cache_hit(SLAB, &p, &digest); let after = FADVISE_ATTEMPTED.load(std::sync::atomic::Ordering::Relaxed); assert!(matches!(decision, CacheDecision::Verified)); From 66600833b046b0465a9d07889ebc4f58505222c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 06:09:27 +0000 Subject: [PATCH 4/6] Drop chrono and reqwest from the lock The two deps the deleted hand-rolled SigV4 signer needed; nothing else in the crate graph pulls them for cockpit-server. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht --- Cargo.lock | 2 -- 1 file changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ff126f95..a822e42b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1710,7 +1710,6 @@ dependencies = [ "async-stream", "axum 0.7.9", "causal-edge", - "chrono", "cpic", "futures", "futures-core", @@ -1729,7 +1728,6 @@ dependencies = [ "osm-soa-bake", "quarto-core", "quarto-system-runtime", - "reqwest 0.12.28", "serde", "serde_json", "sha2 0.10.9", From 6e529817810e3a65d2a317549ac6b72120cf82e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 06:14:59 +0000 Subject: [PATCH 5/6] Give the v4 bake one name, held in one place /api/bake/:tag/:asset made the client read a tag and filename from body.manifest.json while the server read the same two things from its own environment. Two places naming one artifact: a deploy could set either without the other, and the result was a 404 in which both halves looked correct. Neither was set, so /helix2 could not work no matter which one a deploy filled in. The server already knows what it hydrated, so the route takes no coordinates at all and answers with that file; the client asks for "the bake" and there is nothing to keep in sync. The filename rides along in Content-Disposition and the ETag, so a caller can still see WHICH bake it received -- it just cannot disagree about it. helix_v4_latest and helix_v4_tag are gone from the client; a named scene (?scene=osm) still resolves through the manifest, because those bakes ship in dist/ and are not what the object-store hydrate is for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht --- cockpit/src/BodyHelix2.tsx | 70 +++++++++++++++++-------------- crates/cockpit-server/src/main.rs | 58 +++++++++++++------------ 2 files changed, 70 insertions(+), 58 deletions(-) diff --git a/cockpit/src/BodyHelix2.tsx b/cockpit/src/BodyHelix2.tsx index 3fba3b9ab..872b9865d 100644 --- a/cockpit/src/BodyHelix2.tsx +++ b/cockpit/src/BodyHelix2.tsx @@ -3,16 +3,21 @@ // FMA v4 bake work — re-addressed cascade, laterality on the edge, vessel radii — // can NEVER regress the working /helix anatomy body. // -// It differs from BodyHelix in exactly ONE line of behaviour: the manifest key. -// /helix reads `helix_latest`; /helix2 reads `helix_v4_latest`. Both resolve -// through the SAME loader, the SAME BSO2 ver-6 decoder and the SAME Signed360 -// shading, so a v4 bake is compared against v3 with the renderer held constant — -// any visible difference is the BAKE, never the viewer. +// It differs from BodyHelix in exactly ONE line of behaviour: WHERE the bake +// comes from. /helix reads the manifest's `helix_latest` and the copy baked into +// dist/ at image build; /helix2 reads `/api/bake`, the artifact the server +// hydrated from the object store at boot. Both resolve through the SAME loader, +// the SAME BSO2 ver-6 decoder and the SAME Signed360 shading, so a v4 bake is +// compared against v3 with the renderer held constant — any visible difference +// is the BAKE, never the viewer. // -// Until a v4 bake exists and `helix_v4_latest` is set in /body.manifest.json, -// /helix2 says so verbatim (the CANONICAL-ONLY no-fallback rule below is -// deliberately inherited: silently falling back to the v3 artifact would make -// /helix2 a copy of /helix that LOOKS like a successful v4 render). +// That split is what makes a NEW bake reachable without an image rebuild, and +// it is deliberately ONE-SIDED: the deploy's own `BODY_BAKE_ASSET` is the only +// name involved, so there is no second place to keep in sync. Until a v4 bake +// is hydrated, /helix2 reports what the server said (the CANONICAL-ONLY +// no-fallback rule below is deliberately inherited: silently falling back to +// the v3 artifact would make /helix2 a copy of /helix that LOOKS like a +// successful v4 render). // // Nothing here writes: `helix_latest` is untouched, so /helix keeps working and // the release asset medcare-rs SHA256-pins stays exactly as published. @@ -725,34 +730,35 @@ async function fetchSoa(): Promise { } return inflate(r); } - const key = scene ? `${scene}_latest` : 'helix_v4_latest'; + // THE BAKE THIS DEPLOY SERVES HAS ONE NAME, AND THE SERVER HOLDS IT. + // + // An earlier version of this function asked the manifest for BOTH a filename + // (`helix_v4_latest`) and a tag (`helix_v4_tag`) while the server independently + // read `BODY_BAKE_ASSET`/`BODY_BAKE_TAG` — two places naming one artifact, so a + // deploy could satisfy one and not the other and the route would 404 with both + // halves looking correct. There is now exactly one place: the server's own + // environment. `/api/bake` takes no coordinates and answers with whatever it + // hydrated, so there is nothing for the client to keep in sync and nothing to + // set here at all. + if (!scene) { + const o = await fetch('/api/bake').catch(() => null); + if (o && o.ok) return inflate(o); + const why = o ? await o.text().catch(() => `HTTP ${o.status}`) : 'the request failed'; + throw new Error(`no v4 bake on this deploy: ${why}`); + } + // A named scene still resolves through the manifest — those bakes ship in + // dist/ and are not what the object-store hydrate is for. + const key = `${scene}_latest`; const stamped: string | undefined = man?.[key]; if (!stamped) { - throw new Error(`no bake for scene="${scene ?? 'body'}" — set ${key} in /body.manifest.json (soabake → helix::encode_signed; osm → geo/osm_helix)`); + throw new Error(`no bake for scene="${scene}" — set ${key} in /body.manifest.json (soabake → helix::encode_signed; osm → geo/osm_helix)`); } - // 1. Same-origin: the copy the Dockerfile baked into dist/ at image build. - // Fastest and always correct when present, so it stays first. const s = await fetch(`/${stamped}`).catch(() => null); if (s && s.ok) return inflate(s); - // 2. The hydrated bake, served same-origin by /api/bake/:tag/:asset. This is - // what lets a NEWLY published bake reach a RUNNING deploy: the dist/ copy - // above is fixed at image build, so without this a v4 bake needs a rebuild - // before /helix2 can see it. The server fetched it from the object store - // ONCE at boot onto its volume and serves the file; the bucket is private - // and stays private — it is shared with clinical bakes, and a browser - // cannot sign for it. The tag comes from the manifest so publishing a bake - // is a manifest edit plus the deploy's BODY_BAKE_TAG, not a code change; - // absent, this hop is skipped rather than guessed. - const tag: string | undefined = man?.helix_v4_tag; - if (tag) { - const o = await fetch(`/api/bake/${encodeURIComponent(tag)}/${encodeURIComponent(stamped)}`) - .catch(() => null); - if (o && o.ok) return inflate(o); - } - // 3. The GitHub release. Kept last and expected to fail in a browser: the - // releases/download redirect sends no CORS header (see the Dockerfile note - // at the same asset). It is a fallback for same-origin contexts, not a - // working browser path. + // The GitHub release. Kept last and expected to fail in a browser: the + // releases/download redirect sends no CORS header (see the Dockerfile note + // at the same asset). It is a fallback for same-origin contexts, not a + // working browser path. const rel = await fetch(`${REL}/${stamped}`); if (!rel.ok) throw new Error(`HTTP ${rel.status} fetching ${stamped}`); return inflate(rel); diff --git a/crates/cockpit-server/src/main.rs b/crates/cockpit-server/src/main.rs index 55d375172..75f151ba5 100644 --- a/crates/cockpit-server/src/main.rs +++ b/crates/cockpit-server/src/main.rs @@ -402,7 +402,7 @@ async fn main() { // bakes that must stay private), and a browser cannot sign SigV4 // without being handed credentials. The bytes are already local — // `body_bake::ensure_body_bake_local` put them there at boot. - .route("/api/bake/:tag/:asset", get(bake_asset_handler)) + .route("/api/bake", get(bake_asset_handler)) .route("/api/osm/health", get(osm_features::osm_health_handler)) .route( "/api/osm/regions", @@ -748,42 +748,38 @@ async fn garmin_contour_handler( // ── Static file handler with SPA fallback ──────────────────────────────────── /// Serves embedded Vite build files. Falls back to index.html for SPA routing. -/// `GET /api/bake/:tag/:asset` — serve the hydrated bake from disk. +/// `GET /api/bake` — serve the artifact this deploy hydrated. /// /// Exists so a NEW bake can reach a running deploy without an image rebuild: /// the embedded `dist/` copy is fixed at build time, this is not. The embedded -/// copy remains the primary path and is untouched — a deploy that hydrated -/// nothing simply 503s here and `/helix` is unaffected. +/// copy remains the primary path for `/helix` and is untouched — a deploy that +/// hydrated nothing simply 503s here. /// -/// The coordinates are checked against what this deploy actually hydrated -/// rather than used to address the filesystem. A request naming some other tag -/// is answered "not this deploy's bake", not translated into a path — so no -/// input from the wire ever reaches a `join`. -async fn bake_asset_handler( - axum::extract::Path((tag, asset)): axum::extract::Path<(String, String)>, -) -> impl axum::response::IntoResponse { +/// **It takes no coordinates on purpose.** The first version was +/// `/api/bake/:tag/:asset`, with the client reading the tag and filename from +/// `body.manifest.json` while the server read them from its own environment — +/// two places naming one artifact, either of which could be set without the +/// other, producing a 404 in which both halves looked right. The server already +/// knows what it fetched; asking the client to agree added a way to disagree +/// and nothing else. One name, one place: `BODY_BAKE_ASSET`. +/// +/// The filename travels in `Content-Disposition` so a caller that wants to +/// know WHICH bake it received can read it, rather than having to assert it. +async fn bake_asset_handler() -> impl axum::response::IntoResponse { use axum::http::{StatusCode, header}; - let (have_tag, have_asset) = body_bake::coordinates(); - if tag != have_tag || asset != have_asset { - return ( - StatusCode::NOT_FOUND, - format!("this deploy serves {have_tag}/{have_asset}"), - ) - .into_response(); - } - let path = body_bake::local_path(); let Ok(bytes) = tokio::fs::read(&path).await else { return ( StatusCode::SERVICE_UNAVAILABLE, - "the bake is not hydrated on this deploy (see the boot log for which \ - variable is missing); the embedded bake is still served" + "no bake is hydrated on this deploy (the boot log names the missing \ + variable); /helix still serves the bake embedded in the image" .to_string(), ) .into_response(); }; + let (tag, asset) = body_bake::coordinates(); let ct = if asset.ends_with(".gz") { "application/gzip" } else { @@ -792,10 +788,20 @@ async fn bake_asset_handler( ( StatusCode::OK, [ - (header::CONTENT_TYPE, ct), - // Artifacts are content-addressed by name (a new bake gets a new - // filename), so they are safe to cache hard. - (header::CACHE_CONTROL, "public, max-age=31536000, immutable"), + (header::CONTENT_TYPE, ct.to_string()), + // Named, not addressed: the caller can see which bake this is + // without a second variable that could name a different one. + ( + header::CONTENT_DISPOSITION, + format!("inline; filename=\"{asset}\""), + ), + (header::ETAG, format!("\"{tag}/{asset}\"")), + // The name changes when the bake changes, and the ETag carries it, + // so the bytes behind this URL are safe to cache hard. + ( + header::CACHE_CONTROL, + "public, max-age=31536000, immutable".to_string(), + ), ], bytes, ) From d02eed7e6737f79a36043c594b40d29f6dcfcd74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 06:28:11 +0000 Subject: [PATCH 6/6] Fork the v4 bake path so it shares nothing with what already works The v4 hydrate was built on top of the old path in three ways, each of which could have broken a working route. It defaulted to the v3 tag and filename, so /helix2 -- the route that exists precisely so a v4 bake can be compared against v3 with the renderer held constant -- would have fetched and rendered the v3 bake on any configured deploy. There is now no v3 slot in this module and no defaults at all: unconfigured is unconfigured, and the route says so. It reached the map's hydration by making osm_slab_hydrate's helpers generic, putting the working 1.29 GiB slab on the same code as an experimental body bake. That file is restored byte-identical to main and this module carries its own copy, which is the cheaper half of the trade. It read the ambient AWS_* and RAILWAY_VOL, so a v4 credential or volume change would have moved where the map looks. Every setting is now BODY_BAKE_V4_*, endpoint and keys included, in its own body-v4 directory, on its own /api/bake/v4 route. Set them to the same values if you want; the point is that they are separable. Two review findings from the same family are fixed with it: only a path that PASSED its checksum is served (a cached copy that failed and whose re-download also failed is deleted rather than left to be served on file existence alone), and ServeFile streams the ~59 MB artifact instead of reading it into a Vec per request. BodyHelix.tsx, BodyV3.tsx, GeoHelix.tsx, main.tsx, body.manifest.json, osm_slab_hydrate.rs, osm_lance.rs, osm_features.rs, osm_lifecycle.rs and body_lod.rs are all byte-identical to main. main.rs gains 38 lines and loses none. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht --- cockpit/src/BodyHelix2.tsx | 25 +- crates/cockpit-server/src/body_bake.rs | 233 --------- crates/cockpit-server/src/body_bake_v4.rs | 493 ++++++++++++++++++ crates/cockpit-server/src/main.rs | 91 +--- crates/cockpit-server/src/osm_slab_hydrate.rs | 70 ++- 5 files changed, 563 insertions(+), 349 deletions(-) delete mode 100644 crates/cockpit-server/src/body_bake.rs create mode 100644 crates/cockpit-server/src/body_bake_v4.rs diff --git a/cockpit/src/BodyHelix2.tsx b/cockpit/src/BodyHelix2.tsx index 872b9865d..f146eb532 100644 --- a/cockpit/src/BodyHelix2.tsx +++ b/cockpit/src/BodyHelix2.tsx @@ -5,15 +5,16 @@ // // It differs from BodyHelix in exactly ONE line of behaviour: WHERE the bake // comes from. /helix reads the manifest's `helix_latest` and the copy baked into -// dist/ at image build; /helix2 reads `/api/bake`, the artifact the server +// dist/ at image build; /helix2 reads `/api/bake/v4`, the artifact the server // hydrated from the object store at boot. Both resolve through the SAME loader, // the SAME BSO2 ver-6 decoder and the SAME Signed360 shading, so a v4 bake is // compared against v3 with the renderer held constant — any visible difference // is the BAKE, never the viewer. // // That split is what makes a NEW bake reachable without an image rebuild, and -// it is deliberately ONE-SIDED: the deploy's own `BODY_BAKE_ASSET` is the only -// name involved, so there is no second place to keep in sync. Until a v4 bake +// it is deliberately ONE-SIDED: the deploy's own `BODY_BAKE_V4_*` variables are +// the only names involved, and they are v4's alone — this route shares no +// variable, directory, module, or line of code with /helix or with the map. Until a v4 bake // is hydrated, /helix2 reports what the server said (the CANONICAL-ONLY // no-fallback rule below is deliberately inherited: silently falling back to // the v3 artifact would make /helix2 a copy of /helix that LOOKS like a @@ -732,16 +733,16 @@ async function fetchSoa(): Promise { } // THE BAKE THIS DEPLOY SERVES HAS ONE NAME, AND THE SERVER HOLDS IT. // - // An earlier version of this function asked the manifest for BOTH a filename - // (`helix_v4_latest`) and a tag (`helix_v4_tag`) while the server independently - // read `BODY_BAKE_ASSET`/`BODY_BAKE_TAG` — two places naming one artifact, so a - // deploy could satisfy one and not the other and the route would 404 with both - // halves looking correct. There is now exactly one place: the server's own - // environment. `/api/bake` takes no coordinates and answers with whatever it - // hydrated, so there is nothing for the client to keep in sync and nothing to - // set here at all. + // `/api/bake/v4` is v4's own route, backed by v4's own module, v4's own + // directory and v4's own BODY_BAKE_V4_* variables. It cannot serve, fall back + // to, or be defaulted to the v3 bake — an earlier version defaulted to exactly + // that and would have rendered v3 here as if it were v4. + // + // The coordinates live ONLY in the server's environment. An earlier version + // also read a filename and tag from the manifest, so a deploy could satisfy + // one place and not the other and 404 with both halves looking correct. if (!scene) { - const o = await fetch('/api/bake').catch(() => null); + const o = await fetch('/api/bake/v4').catch(() => null); if (o && o.ok) return inflate(o); const why = o ? await o.text().catch(() => `HTTP ${o.status}`) : 'the request failed'; throw new Error(`no v4 bake on this deploy: ${why}`); diff --git a/crates/cockpit-server/src/body_bake.rs b/crates/cockpit-server/src/body_bake.rs deleted file mode 100644 index de0c60bf5..000000000 --- a/crates/cockpit-server/src/body_bake.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Boot-time hydration of the baked FMA body onto durable disk. -//! -//! # The topology — identical to the OSM slab's, one artifact narrower -//! -//! ```text -//! S3 (durable source of truth) -//! │ s3://$AWS_S3_BUCKET_NAME/q2/bakes//{, SHA256SUMS} -//! ▼ -//! volume ($RAILWAY_VOL/body, /volume01/body, else a temp dir) -//! │ -//! ▼ -//! /api/bake// (served from the file, same-origin) -//! ``` -//! -//! # Why hydrate instead of proxying each request -//! -//! The first version of this fetched the artifact from S3 **per request** with -//! a hand-rolled SigV4 signer. That was a second object-store client in a crate -//! that already had one ([`crate::osm_slab_hydrate`]'s `object_store`), and it -//! re-downloaded ~59 MB on every cold browser cache. The boot-time shape is -//! what this repo already does for the OSM slab and what `medcare-rs`'s -//! `bake_hydrate` does for the ontology crystal: fetch once, land it on the -//! volume, serve from the file. -//! -//! # The volume is a cache, never truth -//! -//! Deleting it costs a re-download and nothing else. A redeploy that lands on a -//! container WITHOUT the volume mounted falls through the ladder to a temp dir -//! and re-fetches — degraded (it pays the transfer again) but never wrong. That -//! is the whole reason the checksum is re-verified on a cache HIT and not only -//! after a download: the artifact outlives this code, so "we already have it" -//! is not evidence that what we have is complete. -//! -//! # Absent configuration is not an error -//! -//! No bucket, no credentials ⇒ `None`, one WARN naming what is missing, and -//! `/api/bake/*` answers 503 while the embedded `dist/` copy keeps serving -//! `/helix` exactly as before. Nothing here can take the working route down. - -use std::path::{Path, PathBuf}; - -use object_store::aws::AmazonS3Builder; - -use crate::osm_slab_hydrate::{ - CacheDecision, download_verified, env_var_nonempty, fetch_sums, resolve_cache_hit, -}; - -/// Log prefix, so a boot log distinguishes this from the OSM slab's lines. -const LABEL: &str = "body bake"; - -/// The release tag the artifact lives under. `BODY_BAKE_TAG` selects another. -const DEFAULT_TAG: &str = "fma-body-v3-v1"; - -/// The artifact within that tag. `BODY_BAKE_ASSET` selects another. -const DEFAULT_ASSET: &str = "body.20260629c.v6helix.soa.gz"; - -/// Where a hydrated copy lands, in the same order [`crate::osm_lifecycle`] -/// resolves the OSM root — an explicit override, then a declared volume, then -/// the platform default mount, then an absolute temp path. -/// -/// The temp fallback is deliberate and last: a relative path would follow the -/// process CWD, which is the failure `medcare-rs::bake_hydrate::absolutize` -/// documents (a handler resolving the store after a `set_current_dir` addresses -/// a *different* directory while logging the same string). -fn cache_dir() -> PathBuf { - if let Some(p) = env_var_nonempty("BODY_BAKE_DIR") { - return PathBuf::from(p); - } - if let Some(v) = env_var_nonempty("RAILWAY_VOL") { - return Path::new(&v).join("body"); - } - let vol01 = Path::new("/volume01"); - if vol01.is_dir() - && !vol01 - .metadata() - .map(|m| m.permissions().readonly()) - .unwrap_or(true) - { - return vol01.join("body"); - } - std::env::temp_dir().join("q2-body") -} - -/// A name that is interpolated into BOTH an S3 key and a filesystem path. -/// -/// The validation is not decoration: `..`, `/`, or a backslash in one of these -/// environment variables would read a different prefix and write outside the -/// cache directory. Restricting the alphabet makes both uses safe by -/// construction instead of by careful escaping at each site. -fn is_safe_name(s: &str) -> bool { - !s.is_empty() - && s.len() <= 128 - && s.bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') - && !s.contains("..") -} - -fn from_env_or(key: &str, default: &str) -> String { - match env_var_nonempty(key) { - None => default.to_string(), - Some(v) if is_safe_name(&v) => v, - Some(bad) => { - tracing::warn!( - rejected = %bad, using = default, key, - "{LABEL}: name must be [A-Za-z0-9._-] with no `..`; ignoring it" - ); - default.to_string() - } - } -} - -/// The tag and asset this deploy serves. -#[must_use] -pub fn coordinates() -> (String, String) { - ( - from_env_or("BODY_BAKE_TAG", DEFAULT_TAG), - from_env_or("BODY_BAKE_ASSET", DEFAULT_ASSET), - ) -} - -/// The local path the artifact would occupy, whether or not it is there yet. -#[must_use] -pub fn local_path() -> PathBuf { - cache_dir().join(coordinates().1) -} - -/// Resolve a local, checksum-verified copy of the body bake, hydrating from S3 -/// if needed. -/// -/// Returns `None` — never panics, never returns an unverified path — when the -/// object store is not configured, the checksum file is absent, or the transfer -/// fails. Every one of those says so at WARN and leaves the embedded `dist/` -/// copy serving. -pub async fn ensure_body_bake_local() -> Option { - let (tag, asset) = coordinates(); - - let Some(bucket) = env_var_nonempty("AWS_S3_BUCKET_NAME") else { - tracing::warn!( - "{LABEL}: AWS_S3_BUCKET_NAME is unset — /api/bake/* will answer 503 and \ - the embedded bake keeps serving /helix" - ); - return None; - }; - - let dir = cache_dir(); - if let Err(e) = std::fs::create_dir_all(&dir) { - tracing::error!(dir = %dir.display(), error = %e, "{LABEL}: cannot create cache dir"); - return None; - } - let dest = dir.join(&asset); - - let store = match AmazonS3Builder::from_env() - .with_bucket_name(&bucket) - .build() - { - Ok(s) => s, - Err(e) => { - tracing::error!(error = %e, "{LABEL}: S3 client build failed"); - return None; - } - }; - - let prefix = format!("q2/bakes/{tag}"); - let sums = fetch_sums(LABEL, &store, &prefix).await?; - let want = sums - .iter() - .find(|(n, _)| *n == asset) - .map(|(_, h)| h.clone()); - let Some(want) = want else { - tracing::error!(%asset, %prefix, "{LABEL}: no checksum pinned for this asset; refusing"); - return None; - }; - - if dest.is_file() { - match resolve_cache_hit(LABEL, &dest, &want) { - CacheDecision::TrustedViaMarker | CacheDecision::Verified => { - tracing::info!(path = %dest.display(), "{LABEL}: cache hit"); - return Some(dest); - } - CacheDecision::Mismatch(got) => { - tracing::warn!(%got, %want, "{LABEL}: cached copy failed its checksum; re-fetching") - } - CacheDecision::Unreadable(e) => { - tracing::warn!(error = %e, "{LABEL}: cannot hash cached copy; re-fetching") - } - } - } - - if !download_verified(LABEL, &store, &prefix, &asset, &dest, &want).await { - return None; - } - tracing::info!(path = %dest.display(), %tag, "{LABEL}: hydrated and verified"); - Some(dest) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn name_validation_rejects_traversal_and_separators() { - assert!(is_safe_name("fma-body-v4-v1")); - assert!(is_safe_name("body.20260629c.v6helix.soa.gz")); - assert!(!is_safe_name("../other-tag")); - assert!(!is_safe_name("a/b")); - assert!(!is_safe_name("a\\b")); - assert!(!is_safe_name("")); - // A name that is only ALMOST traversal still has to survive, or the - // guard is the kind that fires on everything and discriminates nothing. - assert!(is_safe_name("v4.1")); - } - - #[test] - fn cache_dir_prefers_an_explicit_override_over_the_volume() { - // SAFETY: single-threaded test, and both variables are restored below. - unsafe { - std::env::set_var("BODY_BAKE_DIR", "/tmp/explicit-body"); - std::env::set_var("RAILWAY_VOL", "/tmp/vol"); - } - assert_eq!(cache_dir(), PathBuf::from("/tmp/explicit-body")); - unsafe { std::env::remove_var("BODY_BAKE_DIR") }; - assert_eq!(cache_dir(), PathBuf::from("/tmp/vol/body")); - unsafe { std::env::remove_var("RAILWAY_VOL") }; - } - - #[test] - fn a_rejected_tag_falls_back_to_the_default_rather_than_reading_it() { - // SAFETY: single-threaded test; the variable is removed below. - unsafe { std::env::set_var("BODY_BAKE_TAG", "../secrets") }; - assert_eq!(from_env_or("BODY_BAKE_TAG", DEFAULT_TAG), DEFAULT_TAG); - unsafe { std::env::remove_var("BODY_BAKE_TAG") }; - } -} diff --git a/crates/cockpit-server/src/body_bake_v4.rs b/crates/cockpit-server/src/body_bake_v4.rs new file mode 100644 index 000000000..a0d1ed2f3 --- /dev/null +++ b/crates/cockpit-server/src/body_bake_v4.rs @@ -0,0 +1,493 @@ +//! Boot-time hydration of the **v4** FMA body bake — `/helix2` only. +//! +//! # This file touches nothing that already works +//! +//! It is a standalone fork in the sense `BodyHelix2.tsx` is a fork of +//! `BodyHelix.tsx` (#64): the v4 work gets its own copy of everything so it can +//! never regress a working route. Concretely, and non-negotiably: +//! +//! - **`/helix` is untouched.** It reads `helix_latest` from the manifest and +//! the copy baked into `dist/` at image build. Nothing here is on that path, +//! so a bug here cannot show up there. +//! - **`/osm` is untouched.** An earlier version of this module made +//! [`crate::osm_slab_hydrate`]'s helpers generic to share them. That put the +//! working map's hydration on the same code as an experimental body bake — a +//! defect in the shared half would have broken the map. The duplication below +//! is deliberate and is the cheaper half of that trade. +//! - **Producers are not referenced here, at all.** This module fetches an +//! already-published artifact and writes it to a read-only cache. It never +//! runs a baker, never reads a baker's inputs, and never writes into a +//! directory a baker writes to. Bake sources and bake outputs live on the +//! producer side; this side only ever consumes a published artifact. +//! - **It shares no variable NAME with anything.** Every setting is +//! `BODY_BAKE_V4_*`, including its own object-store credentials — it does +//! NOT read the ambient `AWS_*` or `RAILWAY_VOL` that the map path uses. +//! Paste the same values in if you like; the point is that changing this +//! deploy's v4 settings cannot move anything else, and vice versa. This is +//! also what makes the module liftable into its own crate later: it has no +//! configuration entanglement to unpick. +//! - **There is no v3 slot.** Not an oversight: a v3 slot here would be a +//! second way to serve the shipped body, adjacent to the v4 one, and the +//! whole reason `/helix2` exists is that the two must be separable. v3 is +//! served by the path that already serves it. +//! +//! # Topology +//! +//! ```text +//! S3 (durable source of truth, published by the baker — a different system) +//! │ $BODY_BAKE_V4_ENDPOINT/$BODY_BAKE_V4_BUCKET +//! │ /q2/bakes/$BODY_BAKE_V4_TAG/{$BODY_BAKE_V4_ASSET, SHA256SUMS} +//! ▼ +//! volume ($BODY_BAKE_V4_DIR, /volume01/body-v4, else temp) +//! │ +//! ▼ +//! GET /api/bake/v4 (streamed from the file, same-origin) +//! ``` +//! +//! # No defaults, ever +//! +//! Neither variable has a default. A default artifact name is a bake this +//! deploy did not choose, and the only artifact that exists to default TO is +//! the v3 one — which is how a first version of this module came to point the +//! v4 route at the v3 bake. Unconfigured means unconfigured: the route says so +//! and serves nothing. +//! +//! # Only a VERIFIED path is ever served +//! +//! The verified path is published to [`verified_path`] after the checksum +//! passes, and the handler reads only that. Serving on file-existence alone +//! would hand out the bytes of a cached copy whose checksum FAILED and whose +//! replacement download then also failed — a file that is present, stale, and +//! wrong. A cached copy that fails its checksum is deleted before the retry, +//! so a failed hydrate leaves nothing behind to serve. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use object_store::aws::AmazonS3Builder; +use object_store::{ObjectStore, ObjectStoreExt}; +use sha2::{Digest, Sha256}; + +/// Log prefix. Distinct from the OSM slab's so a boot log never conflates them. +const LABEL: &str = "body bake v4"; + +/// The verified artifact, published once at boot. Absent = nothing to serve. +static VERIFIED: OnceLock = OnceLock::new(); + +/// The checksum-verified artifact, or `None` when this deploy has none. +/// +/// The ONLY accessor the request path may use. A path that exists on disk is +/// not evidence it verified. +#[must_use] +pub fn verified_path() -> Option<&'static Path> { + VERIFIED.get().map(PathBuf::as_path) +} + +/// `std::env::var`, with an empty value treated as absent — a platform +/// variable can exist as a row that was never filled in, and that must fail +/// exactly like an unset one rather than attempt a doomed call with an empty +/// bucket name. +fn env_var_nonempty(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.trim().is_empty()) +} + +/// A name interpolated into BOTH an S3 key and a filesystem path. +/// +/// Not decoration: `..`, `/`, or a backslash here would read a different +/// prefix and write outside the cache directory. Restricting the alphabet +/// makes both uses safe by construction rather than by careful escaping at +/// each site. +fn is_safe_name(s: &str) -> bool { + !s.is_empty() + && s.len() <= 128 + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') + && !s.contains("..") +} + +fn checked(key: &str) -> Option { + match env_var_nonempty(key) { + None => None, + Some(v) if is_safe_name(&v) => Some(v), + Some(bad) => { + tracing::warn!( + rejected = %bad, key, + "{LABEL}: name must be [A-Za-z0-9._-] with no `..`; ignoring it" + ); + None + } + } +} + +/// The v4 tag and asset, or `None` when this deploy does not serve v4. +fn coordinates() -> Option<(String, String)> { + Some((checked("BODY_BAKE_V4_TAG")?, checked("BODY_BAKE_V4_ASSET")?)) +} + +/// The v4 object store, built from v4-only variables. +/// +/// `AmazonS3Builder::from_env()` is deliberately NOT used: it reads the ambient +/// `AWS_*` that the map path also reads, which would make one deploy's v4 +/// credentials and another subsystem's the same knob. Every field is named +/// here so the two can never be the same setting — set them to the same values +/// if that is what you want. +fn build_store() -> Option { + let endpoint = env_var_nonempty("BODY_BAKE_V4_ENDPOINT")?; + let bucket = env_var_nonempty("BODY_BAKE_V4_BUCKET")?; + let key_id = env_var_nonempty("BODY_BAKE_V4_ACCESS_KEY_ID")?; + let secret = env_var_nonempty("BODY_BAKE_V4_SECRET_ACCESS_KEY")?; + let region = env_var_nonempty("BODY_BAKE_V4_REGION").unwrap_or_else(|| "auto".to_string()); + + match AmazonS3Builder::new() + .with_endpoint(endpoint) + .with_bucket_name(bucket) + .with_access_key_id(key_id) + .with_secret_access_key(secret) + .with_region(region) + .build() + { + Ok(s) => Some(s), + Err(e) => { + tracing::error!(error = %e, "{LABEL}: S3 client build failed"); + None + } + } +} + +/// Which required v4 variables are absent, so one boot line settles it. +fn missing_vars() -> Vec<&'static str> { + [ + "BODY_BAKE_V4_TAG", + "BODY_BAKE_V4_ASSET", + "BODY_BAKE_V4_ENDPOINT", + "BODY_BAKE_V4_BUCKET", + "BODY_BAKE_V4_ACCESS_KEY_ID", + "BODY_BAKE_V4_SECRET_ACCESS_KEY", + ] + .into_iter() + .filter(|k| env_var_nonempty(k).is_none()) + .collect() +} + +/// The v4 cache directory. Its own leaf — `body-v4` — so nothing this module +/// writes can land beside, or overwrite, an artifact of any other generation. +/// +/// The temp fallback is absolute on purpose: a relative path would follow the +/// process CWD, so a later `set_current_dir` would silently address a +/// different directory while logging the same string. +fn cache_dir() -> PathBuf { + if let Some(p) = env_var_nonempty("BODY_BAKE_V4_DIR") { + return PathBuf::from(p); + } + // Deliberately NOT `RAILWAY_VOL`: that variable already steers the map's + // cache, and a v4 experiment must not be able to move where the map looks. + // A deploy whose volume is mounted elsewhere sets BODY_BAKE_V4_DIR. + let vol01 = Path::new("/volume01"); + if vol01.is_dir() + && !vol01 + .metadata() + .map(|m| m.permissions().readonly()) + .unwrap_or(true) + { + return vol01.join("body-v4"); + } + std::env::temp_dir().join("q2-body-v4") +} + +/// Parse `sha256sum` output: ` ` per line, tolerating the `*name` +/// binary marker. +fn parse_sums(text: &str) -> Vec<(String, String)> { + text.lines() + .filter_map(|line| { + let mut it = line.split_whitespace(); + let hash = it.next()?; + let name = it.next()?.trim_start_matches('*'); + (hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())) + .then(|| (name.to_string(), hash.to_ascii_lowercase())) + }) + .collect() +} + +/// SHA-256 of a file, streamed — the artifact is ~59 MB and must not be read +/// into memory to be hashed. +fn sha256_file(path: &Path) -> std::io::Result { + use std::io::Read; + let mut f = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buf = vec![0u8; 1 << 20]; + loop { + let n = f.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(hex::encode(hasher.finalize())) +} + +/// Hydrate the v4 bake and publish its verified path. +/// +/// Never panics, never publishes an unverified path, and never touches any +/// other route's artifacts. Called once at boot, before the listener binds. +pub async fn ensure_local() { + let missing = missing_vars(); + if !missing.is_empty() { + tracing::info!( + missing = %missing.join(", "), + "{LABEL}: not configured — /api/bake/v4 answers 503; /helix is unaffected" + ); + return; + } + let Some((tag, asset)) = coordinates() else { + return; + }; + + let dir = cache_dir(); + if let Err(e) = std::fs::create_dir_all(&dir) { + tracing::error!(dir = %dir.display(), error = %e, "{LABEL}: cannot create cache dir"); + return; + } + let dest = dir.join(&asset); + + let Some(store) = build_store() else { + return; + }; + + let prefix = format!("q2/bakes/{tag}"); + let Some(want) = fetch_want(&store, &prefix, &asset).await else { + return; + }; + + // A cache hit is re-verified, not trusted: the failure this guards is a + // half-written file from a container killed mid-download, which is exactly + // what a "we already have it" check waves through. + if dest.is_file() { + match sha256_file(&dest) { + Ok(got) if got == want => { + tracing::info!(path = %dest.display(), "{LABEL}: cache hit, checksum verified"); + publish(dest); + return; + } + Ok(got) => { + tracing::warn!(%got, %want, "{LABEL}: cached copy failed its checksum; discarding"); + } + Err(e) => { + tracing::warn!(error = %e, "{LABEL}: cannot hash cached copy; discarding"); + } + } + // Delete BEFORE the retry. If the retry also fails, an unverified file + // must not be left where anything could pick it up. + if let Err(e) = std::fs::remove_file(&dest) { + tracing::error!(error = %e, "{LABEL}: cannot remove the bad cached copy; refusing"); + return; + } + } + + if download_verified(&store, &prefix, &asset, &dest, &want).await { + tracing::info!(path = %dest.display(), %tag, "{LABEL}: hydrated and verified"); + publish(dest); + } +} + +fn publish(path: PathBuf) { + if VERIFIED.set(path).is_err() { + tracing::warn!("{LABEL}: hydrate ran twice; keeping the first verified path"); + } +} + +/// The pinned digest for `asset`, from the `SHA256SUMS` beside it. +async fn fetch_want(store: &impl ObjectStore, prefix: &str, asset: &str) -> Option { + let path = object_store::path::Path::from(format!("{prefix}/SHA256SUMS")); + let bytes = match store.get(&path).await { + Ok(r) => match r.bytes().await { + Ok(b) => b, + Err(e) => { + tracing::error!(error = %e, "{LABEL}: SHA256SUMS body read failed"); + return None; + } + }, + Err(e) => { + tracing::error!(error = %e, %prefix, "{LABEL}: SHA256SUMS not readable"); + return None; + } + }; + let sums = parse_sums(&String::from_utf8_lossy(&bytes)); + match sums.iter().find(|(n, _)| n == asset) { + Some((_, h)) => Some(h.clone()), + None => { + tracing::error!(%asset, %prefix, "{LABEL}: no checksum pinned for this asset; refusing"); + None + } + } +} + +/// Stream one object to `.part`, hashing while writing, and rename into +/// place only on a match. A mismatch leaves no file behind. +async fn download_verified( + store: &impl ObjectStore, + prefix: &str, + name: &str, + dest: &Path, + want: &str, +) -> bool { + use futures::StreamExt; + use std::io::Write; + + let path = object_store::path::Path::from(format!("{prefix}/{name}")); + let result = match store.get(&path).await { + Ok(r) => r, + Err(e) => { + tracing::error!(artifact = name, error = %e, "{LABEL}: download failed"); + return false; + } + }; + + let part = dest.with_extension("part"); + let mut file = match std::fs::File::create(&part) { + Ok(f) => f, + Err(e) => { + tracing::error!(artifact = name, error = %e, "{LABEL}: cannot create .part"); + return false; + } + }; + + let mut hasher = Sha256::new(); + let mut stream = result.into_stream(); + while let Some(chunk) = stream.next().await { + let chunk = match chunk { + Ok(c) => c, + Err(e) => { + tracing::error!(artifact = name, error = %e, "{LABEL}: stream error"); + let _ = std::fs::remove_file(&part); + return false; + } + }; + hasher.update(&chunk); + if let Err(e) = file.write_all(&chunk) { + tracing::error!(artifact = name, error = %e, "{LABEL}: write error"); + let _ = std::fs::remove_file(&part); + return false; + } + } + if let Err(e) = file.flush() { + tracing::error!(artifact = name, error = %e, "{LABEL}: flush error"); + let _ = std::fs::remove_file(&part); + return false; + } + drop(file); + + let got = hex::encode(hasher.finalize()); + if got != want { + tracing::error!(artifact = name, %got, %want, "{LABEL}: checksum mismatch; discarding"); + let _ = std::fs::remove_file(&part); + return false; + } + if let Err(e) = std::fs::rename(&part, dest) { + tracing::error!(artifact = name, error = %e, "{LABEL}: rename into place failed"); + let _ = std::fs::remove_file(&part); + return false; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_validation_rejects_traversal_and_separators() { + assert!(is_safe_name("fma-body-v4-v1")); + assert!(is_safe_name("body.20260629c.v6helix.soa.gz")); + assert!(!is_safe_name("../other-tag")); + assert!(!is_safe_name("a/b")); + assert!(!is_safe_name("a\\b")); + assert!(!is_safe_name("")); + // A name that is only ALMOST traversal must still pass, or the guard + // fires on everything and discriminates nothing. + assert!(is_safe_name("v4.1")); + } + + #[test] + fn there_is_no_default_artifact() { + // The defect this pins: a first version defaulted to the v3 tag and + // filename, so the v4 route would have served the v3 bake on any + // configured deploy. Unset must stay unset. + // SAFETY: single-threaded test; both variables are removed, not set. + unsafe { + std::env::remove_var("BODY_BAKE_V4_TAG"); + std::env::remove_var("BODY_BAKE_V4_ASSET"); + } + assert!(coordinates().is_none()); + } + + #[test] + fn a_rejected_tag_does_not_fall_back_to_some_other_bake() { + // SAFETY: single-threaded test; the variables are removed below. + unsafe { + std::env::set_var("BODY_BAKE_V4_TAG", "../secrets"); + std::env::set_var("BODY_BAKE_V4_ASSET", "body.soa.gz"); + } + assert!( + coordinates().is_none(), + "a rejected name must not resolve at all" + ); + unsafe { + std::env::remove_var("BODY_BAKE_V4_TAG"); + std::env::remove_var("BODY_BAKE_V4_ASSET"); + } + } + + #[test] + fn the_cache_dir_is_v4_specific_and_ignores_the_map_s_volume_variable() { + // SAFETY: single-threaded test; the variables are restored below. + unsafe { + std::env::set_var("BODY_BAKE_V4_DIR", "/tmp/explicit-v4"); + std::env::set_var("RAILWAY_VOL", "/tmp/vol"); + } + assert_eq!(cache_dir(), PathBuf::from("/tmp/explicit-v4")); + unsafe { std::env::remove_var("BODY_BAKE_V4_DIR") }; + // With no v4 directory set, RAILWAY_VOL must NOT be consulted — it + // steers the map's cache, and this path may not move that. + assert_ne!(cache_dir(), PathBuf::from("/tmp/vol/body-v4")); + unsafe { std::env::remove_var("RAILWAY_VOL") }; + } + + #[test] + fn no_v4_setting_shares_a_name_with_the_map_or_the_ambient_aws_config() { + // The crossed-wire guard: every name this module reads must be + // v4-scoped, so changing a v4 setting cannot move anything else. + for key in [ + "BODY_BAKE_V4_TAG", + "BODY_BAKE_V4_ASSET", + "BODY_BAKE_V4_ENDPOINT", + "BODY_BAKE_V4_BUCKET", + "BODY_BAKE_V4_ACCESS_KEY_ID", + "BODY_BAKE_V4_SECRET_ACCESS_KEY", + "BODY_BAKE_V4_REGION", + "BODY_BAKE_V4_DIR", + ] { + assert!(key.starts_with("BODY_BAKE_V4_"), "{key} is not v4-scoped"); + } + assert_eq!( + missing_vars().len(), + 6, + "with nothing set, every required v4 variable must be reported missing" + ); + } + + #[test] + fn nothing_is_served_before_a_verified_hydrate() { + assert!(verified_path().is_none()); + } + + #[test] + fn parse_sums_reads_the_sha256sum_format() { + let text = " \nabc short\n\ + e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 body.soa.gz\n\ + e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 *other.gz\n"; + let sums = parse_sums(text); + assert_eq!(sums.len(), 2, "the short-hash line must be rejected"); + assert_eq!(sums[0].0, "body.soa.gz"); + assert_eq!(sums[1].0, "other.gz"); + } +} diff --git a/crates/cockpit-server/src/main.rs b/crates/cockpit-server/src/main.rs index 75f151ba5..a9b9aabc3 100644 --- a/crates/cockpit-server/src/main.rs +++ b/crates/cockpit-server/src/main.rs @@ -28,8 +28,9 @@ use futures_core::Stream; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; use tower_http::cors::CorsLayer; +use tower_http::services::ServeFile; -mod body_bake; +mod body_bake_v4; mod body_lod; mod clinical; mod codebook; @@ -297,8 +298,9 @@ async fn main() { // volume mounted re-fetches rather than serving something stale. Failure is // never fatal — `/helix` reads the copy embedded in `dist/` and does not // consult this at all; only `/helix2`'s newer bake needs it, and it degrades - // to a 503 with the missing variable named in the boot log. - let _ = body_bake::ensure_body_bake_local().await; + // to a 503 with the missing variables named in the boot log. It shares no + // variable, directory, or code with the map hydrate above. + body_bake_v4::ensure_local().await; let (tx, _rx) = broadcast::channel::(256); let scene_state = shader_stream::new_scene_state(); @@ -397,12 +399,6 @@ async fn main() { // read. A missing codebook is otherwise indistinguishable from a // working map on every other signal (200s, full tiles, correct // geometry) while drawing grey and untagged. - // Serve the hydrated bake from disk, same-origin. The browser cannot - // fetch the bucket itself: it is private (and shared with clinical - // bakes that must stay private), and a browser cannot sign SigV4 - // without being handed credentials. The bytes are already local — - // `body_bake::ensure_body_bake_local` put them there at boot. - .route("/api/bake", get(bake_asset_handler)) .route("/api/osm/health", get(osm_features::osm_health_handler)) .route( "/api/osm/regions", @@ -471,6 +467,21 @@ async fn main() { .fallback(get(static_handler)) .layer(CorsLayer::permissive()); + // `/api/bake/v4` — /helix2's bake, and nothing else's. Attached here rather + // than in the table above so this addition cannot perturb a single existing + // route, and served by `ServeFile` so a ~59 MB artifact streams from disk + // instead of being read into a `Vec` per request (Codex P2 on #152: + // concurrent cache misses would multiply that allocation). + // + // Only a path that PASSED its checksum is served: `verified_path()` is + // published by the hydrate, so a cached copy that failed verification and + // whose re-download also failed leaves nothing to serve (Codex P1 on #152 — + // the handler used to trust file existence alone). + let app = match body_bake_v4::verified_path() { + Some(p) => app.route_service("/api/bake/v4", ServeFile::new(p)), + None => app.route("/api/bake/v4", get(bake_v4_unavailable)), + }; + let port: u16 = std::env::var("PORT") .ok() .and_then(|p| p.parse().ok()) @@ -748,64 +759,16 @@ async fn garmin_contour_handler( // ── Static file handler with SPA fallback ──────────────────────────────────── /// Serves embedded Vite build files. Falls back to index.html for SPA routing. -/// `GET /api/bake` — serve the artifact this deploy hydrated. -/// -/// Exists so a NEW bake can reach a running deploy without an image rebuild: -/// the embedded `dist/` copy is fixed at build time, this is not. The embedded -/// copy remains the primary path for `/helix` and is untouched — a deploy that -/// hydrated nothing simply 503s here. +/// `GET /api/bake/v4` when no verified v4 bake exists on this deploy. /// -/// **It takes no coordinates on purpose.** The first version was -/// `/api/bake/:tag/:asset`, with the client reading the tag and filename from -/// `body.manifest.json` while the server read them from its own environment — -/// two places naming one artifact, either of which could be set without the -/// other, producing a 404 in which both halves looked right. The server already -/// knows what it fetched; asking the client to agree added a way to disagree -/// and nothing else. One name, one place: `BODY_BAKE_ASSET`. -/// -/// The filename travels in `Content-Disposition` so a caller that wants to -/// know WHICH bake it received can read it, rather than having to assert it. -async fn bake_asset_handler() -> impl axum::response::IntoResponse { - use axum::http::{StatusCode, header}; - - let path = body_bake::local_path(); - let Ok(bytes) = tokio::fs::read(&path).await else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - "no bake is hydrated on this deploy (the boot log names the missing \ - variable); /helix still serves the bake embedded in the image" - .to_string(), - ) - .into_response(); - }; - - let (tag, asset) = body_bake::coordinates(); - let ct = if asset.ends_with(".gz") { - "application/gzip" - } else { - "application/octet-stream" - }; +/// A deploy with no v4 bake is the normal state, not an error: `/helix` serves +/// the body embedded in the image and never consults this route. +async fn bake_v4_unavailable() -> impl axum::response::IntoResponse { ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, ct.to_string()), - // Named, not addressed: the caller can see which bake this is - // without a second variable that could name a different one. - ( - header::CONTENT_DISPOSITION, - format!("inline; filename=\"{asset}\""), - ), - (header::ETAG, format!("\"{tag}/{asset}\"")), - // The name changes when the bake changes, and the ETag carries it, - // so the bytes behind this URL are safe to cache hard. - ( - header::CACHE_CONTROL, - "public, max-age=31536000, immutable".to_string(), - ), - ], - bytes, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "no verified v4 bake on this deploy (the boot log names the missing \ + BODY_BAKE_V4_* variables); /helix is unaffected", ) - .into_response() } async fn static_handler(uri: axum::http::Uri) -> Response { diff --git a/crates/cockpit-server/src/osm_slab_hydrate.rs b/crates/cockpit-server/src/osm_slab_hydrate.rs index 254bade23..544b17a3c 100644 --- a/crates/cockpit-server/src/osm_slab_hydrate.rs +++ b/crates/cockpit-server/src/osm_slab_hydrate.rs @@ -72,11 +72,6 @@ use object_store::aws::AmazonS3Builder; use object_store::{ObjectStore, ObjectStoreExt}; use sha2::{Digest, Sha256}; -/// The label the in-module call sites pass to the reusable helpers below -/// ([`fetch_sums`], [`download_verified`], …), which `body_bake` also calls -/// with its own name. It is the log prefix, nothing more. -pub(crate) const SLAB: &str = "osm slab"; - /// The region baked by default. `OSM_BAKE_REGION` selects another one. /// /// The region is the ONLY thing that differs between bakes: the baker @@ -151,7 +146,7 @@ fn cache_dir(vol: &str) -> PathBuf { /// in), and that must fail the SAME way as the variable not existing at all — /// not attempt a real S3 call with an empty bucket name, which fails later, /// differently, and less legibly than "not configured". -pub(crate) fn env_var_nonempty(key: &str) -> Option { +fn env_var_nonempty(key: &str) -> Option { std::env::var(key).ok().filter(|v| !v.trim().is_empty()) } @@ -381,7 +376,7 @@ async fn hydrate_one_region( volume re-verifies in ~1s)" ); - let sums = fetch_sums(SLAB, store, &prefix).await?; + let sums = fetch_sums(store, &prefix).await?; for name in region_artifacts.iter().map(String::as_str) { let want = match sums.iter().find(|(k, _)| k == name).map(|(_, h)| h.clone()) { @@ -399,7 +394,7 @@ async fn hydrate_one_region( // Cache hit, but only if it still hashes correctly — see module docs. if dest.is_file() { - match resolve_cache_hit(SLAB, &dest, &want) { + match resolve_cache_hit(&dest, &want) { CacheDecision::TrustedViaMarker => { tracing::info!( region, @@ -427,7 +422,7 @@ async fn hydrate_one_region( } } - if !download_verified(SLAB, store, &prefix, name, &dest, &want).await { + if !download_verified(store, &prefix, name, &dest, &want).await { return None; } } @@ -437,22 +432,18 @@ async fn hydrate_one_region( /// Fetch and parse `SHA256SUMS` — ` ` per line, the `sha256sum` /// format the bucket already uses for the MedCare bakes. -pub(crate) async fn fetch_sums( - label: &str, - store: &impl ObjectStore, - prefix: &str, -) -> Option> { +async fn fetch_sums(store: &impl ObjectStore, prefix: &str) -> Option> { let path = object_store::path::Path::from(format!("{prefix}/SHA256SUMS")); let bytes = match store.get(&path).await { Ok(r) => match r.bytes().await { Ok(b) => b, Err(e) => { - tracing::error!(error = %e, "{label}: SHA256SUMS body read failed"); + tracing::error!(error = %e, "osm slab: SHA256SUMS body read failed"); return None; } }, Err(e) => { - tracing::error!(error = %e, %prefix, "{label}: SHA256SUMS not readable"); + tracing::error!(error = %e, %prefix, "osm slab: SHA256SUMS not readable"); return None; } }; @@ -461,7 +452,7 @@ pub(crate) async fn fetch_sums( /// Parse `sha256sum` output. Tolerates the `*name` binary marker and blank /// lines; ignores anything that is not ` `. -pub(crate) fn parse_sums(text: &str) -> Vec<(String, String)> { +fn parse_sums(text: &str) -> Vec<(String, String)> { text.lines() .filter_map(|line| { let mut it = line.split_whitespace(); @@ -478,8 +469,7 @@ pub(crate) fn parse_sums(text: &str) -> Vec<(String, String)> { /// Stream one object to `.part`, hash while writing, and rename into /// place only if it matches. A mismatch leaves no file behind. -pub(crate) async fn download_verified( - label: &str, +async fn download_verified( store: &impl ObjectStore, prefix: &str, name: &str, @@ -493,7 +483,7 @@ pub(crate) async fn download_verified( let result = match store.get(&path).await { Ok(r) => r, Err(e) => { - tracing::error!(artifact = name, error = %e, "{label}: download failed"); + tracing::error!(artifact = name, error = %e, "osm slab: download failed"); return false; } }; @@ -502,7 +492,7 @@ pub(crate) async fn download_verified( let mut file = match std::fs::File::create(&part) { Ok(f) => f, Err(e) => { - tracing::error!(artifact = name, error = %e, "{label}: cannot create .part"); + tracing::error!(artifact = name, error = %e, "osm slab: cannot create .part"); return false; } }; @@ -514,21 +504,21 @@ pub(crate) async fn download_verified( let chunk = match chunk { Ok(c) => c, Err(e) => { - tracing::error!(artifact = name, error = %e, "{label}: stream error"); + tracing::error!(artifact = name, error = %e, "osm slab: stream error"); let _ = std::fs::remove_file(&part); return false; } }; hasher.update(&chunk); if let Err(e) = file.write_all(&chunk) { - tracing::error!(artifact = name, error = %e, "{label}: write error"); + tracing::error!(artifact = name, error = %e, "osm slab: write error"); let _ = std::fs::remove_file(&part); return false; } written += chunk.len() as u64; } if let Err(e) = file.flush() { - tracing::error!(artifact = name, error = %e, "{label}: flush error"); + tracing::error!(artifact = name, error = %e, "osm slab: flush error"); let _ = std::fs::remove_file(&part); return false; } @@ -536,22 +526,22 @@ pub(crate) async fn download_verified( let got = hex::encode(hasher.finalize()); if got != want { - tracing::error!(artifact = name, %got, %want, "{label}: checksum mismatch; discarding"); + tracing::error!(artifact = name, %got, %want, "osm slab: checksum mismatch; discarding"); let _ = std::fs::remove_file(&part); return false; } if let Err(e) = std::fs::rename(&part, dest) { - tracing::error!(artifact = name, error = %e, "{label}: rename into place failed"); + tracing::error!(artifact = name, error = %e, "osm slab: rename into place failed"); let _ = std::fs::remove_file(&part); return false; } // A fresh download IS a real verification — record it so the NEXT boot's // cache hit can trust it via `resolve_cache_hit` without re-reading. - write_marker(label, dest, &got); + write_marker(dest, &got); tracing::info!( artifact = name, bytes = written, - "{label}: downloaded and verified" + "osm slab: downloaded and verified" ); true } @@ -636,7 +626,7 @@ fn marker_path(dest: &Path) -> PathBuf { /// changed at all: the marker's digest no longer equals the freshly /// fetched `want`, so this declines and `sha256_file` runs for real, /// which then correctly reports a mismatch and triggers a re-download. -pub(crate) fn trusted_via_marker(dest: &Path, want: &str) -> bool { +fn trusted_via_marker(dest: &Path, want: &str) -> bool { let Some(marker) = std::fs::read_to_string(marker_path(dest)) .ok() .and_then(|text| VerifiedMarker::parse(&text)) @@ -653,7 +643,7 @@ pub(crate) fn trusted_via_marker(dest: &Path, want: &str) -> bool { /// proven-correct identity a later boot's [`trusted_via_marker`] can trust. /// Failure to write is logged, never fatal: the next boot simply re-hashes, /// which is exactly today's behaviour without this whole mechanism. -pub(crate) fn write_marker(label: &str, dest: &Path, digest: &str) { +fn write_marker(dest: &Path, digest: &str) { let Some((mtime_nanos, len)) = stat_identity(dest) else { return; }; @@ -665,7 +655,7 @@ pub(crate) fn write_marker(label: &str, dest: &Path, digest: &str) { if let Err(e) = std::fs::write(marker_path(dest), marker.render()) { tracing::warn!( path = %dest.display(), error = %e, - "{label}: could not write verification marker (non-fatal; next boot re-hashes)" + "osm slab: could not write verification marker (non-fatal; next boot re-hashes)" ); } } @@ -674,7 +664,7 @@ pub(crate) fn write_marker(label: &str, dest: &Path, digest: &str) { /// [`ensure_slab_local`]'s loop needs to log and branch on. Split out from /// that loop so it is directly testable without env vars or an S3 stub — /// see `resolve_cache_hit_trusts_a_matching_marker_without_hashing` below. -pub(crate) enum CacheDecision { +enum CacheDecision { /// The marker proved identity without touching the file's bytes. TrustedViaMarker, /// A real `sha256_file` ran and matched `want` — the marker is now @@ -686,13 +676,13 @@ pub(crate) enum CacheDecision { Unreadable(std::io::Error), } -pub(crate) fn resolve_cache_hit(label: &str, dest: &Path, want: &str) -> CacheDecision { +fn resolve_cache_hit(dest: &Path, want: &str) -> CacheDecision { if trusted_via_marker(dest, want) { return CacheDecision::TrustedViaMarker; } match sha256_file(dest) { Ok(got) if got == want => { - write_marker(label, dest, &got); + write_marker(dest, &got); CacheDecision::Verified } Ok(got) => CacheDecision::Mismatch(got), @@ -920,7 +910,7 @@ not-a-hash junk.txt let p = write_temp_artifact(&dir, "artifact.bin", b"original content"); let (mtime_nanos, len) = stat_identity(&p).expect("stat"); let digest = sha256_file(&p).expect("hash"); - write_marker(SLAB, &p, &digest); + write_marker(&p, &digest); // Rewrite with DIFFERENT content — a real mtime bump, not a forced one. std::fs::write(&p, b"different content, different length").expect("rewrite"); @@ -946,7 +936,7 @@ not-a-hash junk.txt std::fs::create_dir_all(&dir).unwrap(); let p = write_temp_artifact(&dir, "artifact.bin", b"stable content"); let digest = sha256_file(&p).expect("hash"); - write_marker(SLAB, &p, &digest); + write_marker(&p, &digest); assert!( !trusted_via_marker(&p, &"f".repeat(64)), @@ -965,7 +955,7 @@ not-a-hash junk.txt std::fs::create_dir_all(&dir).unwrap(); let p = write_temp_artifact(&dir, "artifact.bin", b"unchanged content"); let digest = sha256_file(&p).expect("hash"); - write_marker(SLAB, &p, &digest); + write_marker(&p, &digest); assert!(trusted_via_marker(&p, &digest)); @@ -986,10 +976,10 @@ not-a-hash junk.txt std::fs::create_dir_all(&dir).unwrap(); let p = write_temp_artifact(&dir, "artifact.bin", b"trust me, i'm unchanged"); let digest = sha256_file(&p).expect("hash"); - write_marker(SLAB, &p, &digest); + write_marker(&p, &digest); let before = FADVISE_ATTEMPTED.load(std::sync::atomic::Ordering::Relaxed); - let decision = resolve_cache_hit(SLAB, &p, &digest); + let decision = resolve_cache_hit(&p, &digest); let after = FADVISE_ATTEMPTED.load(std::sync::atomic::Ordering::Relaxed); assert!(matches!(decision, CacheDecision::TrustedViaMarker)); @@ -1016,7 +1006,7 @@ not-a-hash junk.txt let digest = sha256_file(&p).expect("hash"); let before = FADVISE_ATTEMPTED.load(std::sync::atomic::Ordering::Relaxed); - let decision = resolve_cache_hit(SLAB, &p, &digest); + let decision = resolve_cache_hit(&p, &digest); let after = FADVISE_ATTEMPTED.load(std::sync::atomic::Ordering::Relaxed); assert!(matches!(decision, CacheDecision::Verified));