zlayer_toolchain/coverage.rs
1//! Toolchain build-coverage recording (writes) + advisory coverage-map reads.
2//!
3//! **Writes** go to the package index: `POST {base}/v1/coverage` (base =
4//! `ZLAYER_PACKAGE_INDEX_URL`, default `https://packages.zlayer.dev`), where the
5//! body is a single JSON [`CoverageRecord`] signed with the same reposync HMAC
6//! scheme as every other index write — `x-reposync-signature: sha256=<hex>` over
7//! the exact raw body bytes, via [`crate::package_index::sign`]. The server
8//! upserts keyed `(tool, platform, arch)` and batch-pushes per-arch maps to
9//! `RepoSources`.
10//!
11//! **Reads** never hit the worker: consumers GET the static Pages artifact
12//! `https://zachhandley.github.io/RepoSources/coverage/{platform}_{arch}.json`
13//! ([`fetch_coverage_map`]), which lags writes by a Pages build. Coverage is
14//! purely advisory — the toolchain always retains its build-it-yourself
15//! fallback, so a stale/missing map is never an error for provisioning itself.
16//!
17//! Recording ([`record`]) is **best-effort by construction**: it returns `()`,
18//! not a `Result`, so a caller physically cannot fail a resolve on it. A
19//! missing secret, transport error, or non-2xx response produces exactly one
20//! `warn!` and nothing else.
21//!
22//! Token conventions (kept consistent with the rest of the crate):
23//! - `platform` is the lockfile/manifest token `"macos"` / `"windows"` —
24//! `platform_token()` in `lib.rs` (`ToolPlatform::MacOS => "macos"`).
25//! - `arch` is the toolchain cache-key token `"arm64"` / `"x86_64"` —
26//! `arch_token()` in `lib.rs` and `windows_arch_token()` in `windows.rs`.
27//! NOT the vendor-download alias `"amd64"` (`vendor_arch()` is documented as
28//! a download-URL-only mapping for the prebuilt resolvers).
29
30use std::collections::HashMap;
31use std::time::Duration;
32
33use serde::{Deserialize, Serialize};
34use tracing::{debug, warn};
35
36use crate::error::{Result, ToolchainError};
37use zlayer_types::package_index::PackageIndexConfig;
38
39/// The default tool set the `seed` command builds coverage for: the C-toolchain
40/// substrate most Homebrew source builds bottom out on.
41///
42/// Note: `protoc` is `protobuf`'s binary — the seed command surfaces the alias
43/// (a user asking for `protoc` seeds/reads the `protobuf` coverage row).
44pub const SEED_TOOLS: &[&str] = &[
45 "protobuf",
46 "cmake",
47 "ninja",
48 "pkgconf",
49 "jq",
50 "git",
51 "autoconf",
52 "automake",
53 "libtool",
54 "gettext",
55 "pcre2",
56 "oniguruma",
57 "openssl@3",
58 "zstd",
59 "xz",
60];
61
62/// Runtime environment variable carrying the reposync HMAC secret. Takes
63/// precedence over the compile-time `option_env!` bake so the secret can be
64/// rotated without rebuilding the binary.
65pub const REPOSYNC_HMAC_SECRET_ENV: &str = "ZLAYER_REPOSYNC_HMAC_SECRET";
66
67/// Compile-time reposync HMAC secret bake (same bake `package_index.rs` uses;
68/// absent in most builds). The runtime env var wins when both are present.
69const REPOSYNC_HMAC_SECRET_BAKED: Option<&str> = option_env!("ZLAYER_REPOSYNC_HMAC_SECRET");
70
71/// The header carrying the reposync HMAC signature. Mirrors the (private)
72/// constant in `package_index.rs` — same header, same `sha256=<hex>` value
73/// shape, verified by the server's existing `hmacPost` wrapper.
74const REPOSYNC_SIGNATURE_HEADER: &str = "x-reposync-signature";
75
76/// Static GitHub Pages base serving the per-arch advisory coverage maps.
77const COVERAGE_PAGES_BASE: &str = "https://zachhandley.github.io/RepoSources/coverage";
78
79/// Outcome of a toolchain provisioning attempt, as recorded in coverage.
80///
81/// Serializes `snake_case` (`"built"` / `"failed"` / `"net_fallback"`) — the
82/// server contract and the token stored in the Pages maps.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum CoverageStatus {
86 /// The toolchain built (or restored from registry) successfully.
87 Built,
88 /// The build failed; `error_tail` carries the log tail for triage.
89 Failed,
90 /// Provisioning succeeded only via the loud full-network fallback
91 /// (`NetPolicy::AllowLoud`) — coverage exists but is not hermetic.
92 NetFallback,
93}
94
95/// One coverage row: how provisioning `tool` went on `(platform, arch)`.
96///
97/// This is the exact wire shape sent (POST) to `/v1/coverage` (single record; the
98/// server accepts single or batch — we send single).
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct CoverageRecord {
101 /// Formula/tool name (e.g. `"protobuf"` — `protoc` is its binary).
102 pub tool: String,
103 /// Platform token: `"macos"` | `"windows"` (matches `platform_token()` /
104 /// the lockfile `platform` key).
105 pub platform: String,
106 /// Arch token: `"arm64"` | `"x86_64"` (matches `arch_token()` /
107 /// `windows_arch_token()` cache-key tokens; never the vendor `"amd64"`).
108 pub arch: String,
109 /// Provisioning outcome.
110 pub status: CoverageStatus,
111 /// Resolved tool version (e.g. `"1.8.2"`).
112 pub version: String,
113 /// OCI reference the built toolchain was published under, when any.
114 #[serde(default)]
115 pub registry_ref: String,
116 /// Digest of the published toolchain artifact, when any.
117 #[serde(default)]
118 pub registry_digest: String,
119 /// Tail of the build log on failure (`ContainerBuildReport::log_tail`).
120 #[serde(default)]
121 pub error_tail: String,
122 /// ISO-8601 / RFC3339 UTC timestamp — generate with [`now_rfc3339`].
123 pub recorded_at: String,
124}
125
126/// A per-arch coverage map as served by the `RepoSources` Pages artifact
127/// (`coverage/{platform}_{arch}.json`): `{metadata: {...}, tools: {...}}`.
128///
129/// Permissive by design (advisory read): unknown fields are ignored, missing
130/// fields default, and `metadata` stays untyped JSON so a map-format evolution
131/// never breaks a reader.
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct CoverageMap {
134 /// Free-form map metadata (platform/arch/generated-at/…); shape is owned
135 /// by the `RepoSources` publisher, so it is deliberately untyped.
136 #[serde(default)]
137 pub metadata: serde_json::Value,
138 /// Per-tool coverage entries, keyed by formula/tool name.
139 #[serde(default)]
140 pub tools: HashMap<String, CoverageEntry>,
141}
142
143/// One tool's entry inside a [`CoverageMap`].
144///
145/// `status` is kept as a plain string (values match the [`CoverageStatus`]
146/// `snake_case` tokens) so an unknown future token degrades to data, not a
147/// parse error — the map is advisory.
148#[derive(Debug, Clone, Default, Serialize, Deserialize)]
149pub struct CoverageEntry {
150 /// Resolved tool version.
151 #[serde(default)]
152 pub version: String,
153 /// Coverage status token (`"built"` / `"failed"` / `"net_fallback"`).
154 #[serde(default)]
155 pub status: String,
156 /// Which provisioning path produced the coverage (publisher-owned token).
157 #[serde(default)]
158 pub source: String,
159 /// When the row was last upserted (RFC3339).
160 #[serde(default)]
161 pub updated_at: String,
162}
163
164/// Current UTC time as an RFC3339 string — the value for
165/// [`CoverageRecord::recorded_at`]. Uses the crate's existing `chrono` dep
166/// (the same idiom every manifest/lockfile timestamp in this crate uses).
167#[must_use]
168pub fn now_rfc3339() -> String {
169 chrono::Utc::now().to_rfc3339()
170}
171
172/// Best-effort coverage write: POST `rec` (single JSON record) to
173/// `{index}/v1/coverage`, HMAC-signed over the exact raw body bytes.
174///
175/// Returns `()` — deliberately not a `Result` — so it is UNMISTAKABLE that a
176/// coverage write can never fail the resolve that triggered it. Every failure
177/// path (no secret resolvable, serialization failure, transport error, non-2xx
178/// response) emits exactly one `warn!` and returns; a 2xx emits a `debug!`.
179pub async fn record(rec: &CoverageRecord) {
180 let Some(secret) = resolve_hmac_secret() else {
181 warn!(
182 tool = %rec.tool,
183 "no reposync HMAC secret (env or compile-time bake); skipping coverage record"
184 );
185 return;
186 };
187
188 // Serialize ONCE and sign those exact bytes — the signature must cover the
189 // bytes actually sent, byte-for-byte.
190 let body = match serde_json::to_vec(rec) {
191 Ok(b) => b,
192 Err(e) => {
193 warn!(tool = %rec.tool, error = %e, "coverage record failed to serialize; skipping");
194 return;
195 }
196 };
197 let signature = crate::package_index::sign(&secret, &body);
198 let url = coverage_post_url(&PackageIndexConfig::from_env().base_url);
199
200 match http_client()
201 .post(&url)
202 .header(REPOSYNC_SIGNATURE_HEADER, signature)
203 .header(reqwest::header::CONTENT_TYPE, "application/json")
204 .body(body)
205 .send()
206 .await
207 {
208 Ok(resp) if resp.status().is_success() => {
209 debug!(
210 tool = %rec.tool,
211 platform = %rec.platform,
212 arch = %rec.arch,
213 status = %resp.status(),
214 "recorded toolchain coverage"
215 );
216 }
217 Ok(resp) => {
218 let status = resp.status();
219 let snippet: String = resp
220 .text()
221 .await
222 .unwrap_or_default()
223 .chars()
224 .take(200)
225 .collect();
226 warn!(
227 tool = %rec.tool,
228 %status,
229 body = %snippet,
230 "coverage record rejected (non-fatal)"
231 );
232 }
233 Err(e) => {
234 warn!(tool = %rec.tool, error = %e, "coverage record failed to send (non-fatal)");
235 }
236 }
237}
238
239/// Fetch the advisory per-arch coverage map from the `RepoSources` Pages
240/// artifact (`{pages}/coverage/{platform}_{arch}.json`).
241///
242/// `platform`/`arch` take the crate's tokens (`"macos"`/`"windows"`,
243/// `"arm64"`/`"x86_64"`). The map lags writes by a Pages build; treat it as a
244/// hint, never a gate.
245///
246/// # Errors
247///
248/// Returns [`ToolchainError::RegistryError`] on a transport failure, a
249/// non-success HTTP status (including 404 — no map published yet for that
250/// platform/arch), or an unparseable body.
251pub async fn fetch_coverage_map(platform: &str, arch: &str) -> Result<CoverageMap> {
252 let url = pages_map_url(platform, arch);
253 let resp = http_client()
254 .get(&url)
255 .send()
256 .await
257 .map_err(|e| ToolchainError::RegistryError {
258 message: format!("failed to GET {url}: {e}"),
259 })?;
260 if !resp.status().is_success() {
261 return Err(ToolchainError::RegistryError {
262 message: format!("GET {url} returned status {}", resp.status()),
263 });
264 }
265 let bytes = resp
266 .bytes()
267 .await
268 .map_err(|e| ToolchainError::RegistryError {
269 message: format!("failed to read body from {url}: {e}"),
270 })?;
271 parse_coverage_map(&bytes).map_err(|e| ToolchainError::RegistryError {
272 message: format!("failed to parse coverage map from {url}: {e}"),
273 })
274}
275
276/// Resolve the reposync HMAC secret: the runtime env var
277/// [`REPOSYNC_HMAC_SECRET_ENV`] takes precedence over the compile-time
278/// `option_env!` bake (rotation without rebuild). Empty values (either source)
279/// count as absent; the env value is trimmed (env files love trailing
280/// newlines), the bake is used verbatim.
281fn resolve_hmac_secret() -> Option<String> {
282 resolve_hmac_secret_from(
283 std::env::var(REPOSYNC_HMAC_SECRET_ENV).ok(),
284 REPOSYNC_HMAC_SECRET_BAKED,
285 )
286}
287
288/// Pure core of [`resolve_hmac_secret`] — separated so precedence is testable
289/// without racing on process-global env state.
290fn resolve_hmac_secret_from(env_value: Option<String>, baked: Option<&str>) -> Option<String> {
291 env_value
292 .map(|s| s.trim().to_string())
293 .filter(|s| !s.is_empty())
294 .or_else(|| {
295 baked
296 .filter(|s| !s.is_empty())
297 .map(std::string::ToString::to_string)
298 })
299}
300
301/// `{base}/v1/coverage` with any trailing slash on `base` trimmed (mirrors
302/// `PackageIndexConfig::base()` defensiveness).
303fn coverage_post_url(base: &str) -> String {
304 format!("{}/v1/coverage", base.trim_end_matches('/'))
305}
306
307/// The Pages URL of the per-arch coverage map: `{pages}/{platform}_{arch}.json`.
308fn pages_map_url(platform: &str, arch: &str) -> String {
309 format!("{COVERAGE_PAGES_BASE}/{platform}_{arch}.json")
310}
311
312/// Parse a Pages coverage-map body. Bare JSON (no `{name, data}` envelope —
313/// Pages serves the artifact verbatim, unlike the index worker routes).
314fn parse_coverage_map(bytes: &[u8]) -> serde_json::Result<CoverageMap> {
315 serde_json::from_slice(bytes)
316}
317
318/// Short-timeout HTTP client mirroring `package_index.rs`'s construction
319/// (same user agent), plus the ~10s cap appropriate for best-effort /
320/// advisory calls. Infallible: a builder error falls back to the default
321/// client (losing only the timeout), the established idiom in this crate.
322fn http_client() -> reqwest::Client {
323 reqwest::Client::builder()
324 .user_agent("zlayer-toolchain")
325 .timeout(Duration::from_secs(10))
326 .build()
327 .unwrap_or_default()
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 fn sample_record() -> CoverageRecord {
335 CoverageRecord {
336 tool: "jq".to_string(),
337 platform: "macos".to_string(),
338 arch: "arm64".to_string(),
339 status: CoverageStatus::Built,
340 version: "1.8.2".to_string(),
341 registry_ref: "forge.blackleafdigital.com/zlayer/toolchains/jq:1.8.2-macos-arm64"
342 .to_string(),
343 registry_digest: "sha256:abc123".to_string(),
344 error_tail: String::new(),
345 recorded_at: "2026-07-06T00:00:00+00:00".to_string(),
346 }
347 }
348
349 // --- secret resolution ---------------------------------------------------
350
351 #[test]
352 fn secret_precedence_env_over_baked() {
353 // Env wins when both present.
354 assert_eq!(
355 resolve_hmac_secret_from(Some("from-env".into()), Some("from-bake")).as_deref(),
356 Some("from-env"),
357 );
358 // Env absent -> bake.
359 assert_eq!(
360 resolve_hmac_secret_from(None, Some("from-bake")).as_deref(),
361 Some("from-bake"),
362 );
363 // Empty/whitespace env counts as absent -> bake.
364 assert_eq!(
365 resolve_hmac_secret_from(Some(" \n".into()), Some("from-bake")).as_deref(),
366 Some("from-bake"),
367 );
368 // Empty bake counts as absent.
369 assert_eq!(resolve_hmac_secret_from(None, Some("")), None);
370 // Nothing anywhere.
371 assert_eq!(resolve_hmac_secret_from(None, None), None);
372 // Env value is trimmed.
373 assert_eq!(
374 resolve_hmac_secret_from(Some(" s3cret\n".into()), None).as_deref(),
375 Some("s3cret"),
376 );
377 }
378
379 /// Env-facing branch coverage. Kept as ONE test (set + unset asserted
380 /// sequentially in the same body) because tests in a binary run in
381 /// parallel threads and `REPOSYNC_HMAC_SECRET_ENV` is process-global —
382 /// no other test in this crate touches or reads it at runtime (the
383 /// `package_index` bake is compile-time), so this cannot race.
384 #[test]
385 fn resolve_hmac_secret_honors_runtime_env() {
386 std::env::set_var(REPOSYNC_HMAC_SECRET_ENV, "runtime-secret-probe");
387 // Env set -> env wins regardless of whether a bake exists in this build.
388 assert_eq!(
389 resolve_hmac_secret().as_deref(),
390 Some("runtime-secret-probe")
391 );
392
393 std::env::remove_var(REPOSYNC_HMAC_SECRET_ENV);
394 // Env absent -> exactly the compile-time bake (None in normal test
395 // builds, where the secret is not exported at compile time).
396 assert_eq!(
397 resolve_hmac_secret(),
398 REPOSYNC_HMAC_SECRET_BAKED
399 .filter(|s| !s.is_empty())
400 .map(str::to_string),
401 );
402 }
403
404 // --- wire contract ---------------------------------------------------------
405
406 #[test]
407 fn status_tokens_are_snake_case() {
408 assert_eq!(
409 serde_json::to_string(&CoverageStatus::Built).unwrap(),
410 "\"built\""
411 );
412 assert_eq!(
413 serde_json::to_string(&CoverageStatus::Failed).unwrap(),
414 "\"failed\""
415 );
416 assert_eq!(
417 serde_json::to_string(&CoverageStatus::NetFallback).unwrap(),
418 "\"net_fallback\""
419 );
420 assert_eq!(
421 serde_json::from_str::<CoverageStatus>("\"net_fallback\"").unwrap(),
422 CoverageStatus::NetFallback
423 );
424 }
425
426 #[test]
427 fn record_serializes_to_server_contract() {
428 let v = serde_json::to_value(sample_record()).unwrap();
429 assert_eq!(v["tool"], "jq");
430 assert_eq!(v["platform"], "macos");
431 assert_eq!(v["arch"], "arm64");
432 assert_eq!(v["status"], "built");
433 assert_eq!(v["version"], "1.8.2");
434 assert_eq!(
435 v["registry_ref"],
436 "forge.blackleafdigital.com/zlayer/toolchains/jq:1.8.2-macos-arm64"
437 );
438 assert_eq!(v["registry_digest"], "sha256:abc123");
439 assert_eq!(v["error_tail"], "");
440 assert_eq!(v["recorded_at"], "2026-07-06T00:00:00+00:00");
441 // Exactly the nine contract fields — nothing extra sneaks onto the wire.
442 assert_eq!(v.as_object().unwrap().len(), 9);
443 }
444
445 #[test]
446 fn record_round_trips_and_optionals_default() {
447 // The three #[serde(default)] fields may be omitted by other writers.
448 let rec: CoverageRecord = serde_json::from_str(
449 r#"{"tool":"cmake","platform":"windows","arch":"x86_64",
450 "status":"failed","version":"4.0.3",
451 "recorded_at":"2026-07-06T00:00:00+00:00"}"#,
452 )
453 .unwrap();
454 assert_eq!(rec.status, CoverageStatus::Failed);
455 assert_eq!(rec.registry_ref, "");
456 assert_eq!(rec.registry_digest, "");
457 assert_eq!(rec.error_tail, "");
458 }
459
460 // --- signing -----------------------------------------------------------------
461
462 /// [`record`] reuses [`crate::package_index::sign`] directly (single DRY
463 /// signer). Cross-check that signer against an independently computed
464 /// HMAC-SHA256 over the exact bytes `record` would send.
465 #[test]
466 fn signing_matches_package_index_sign_for_record_bytes() {
467 use hmac::{Hmac, Mac};
468 use sha2::Sha256;
469
470 let body = serde_json::to_vec(&sample_record()).unwrap();
471 let via_shared_signer = crate::package_index::sign("secret", &body);
472
473 let mut mac = Hmac::<Sha256>::new_from_slice(b"secret").unwrap();
474 mac.update(&body);
475 let independent = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
476
477 assert_eq!(via_shared_signer, independent);
478 }
479
480 // --- URLs --------------------------------------------------------------------
481
482 #[test]
483 fn coverage_post_url_joins_base() {
484 assert_eq!(
485 coverage_post_url("https://packages.zlayer.dev"),
486 "https://packages.zlayer.dev/v1/coverage"
487 );
488 assert_eq!(
489 coverage_post_url("https://packages.zlayer.dev/"),
490 "https://packages.zlayer.dev/v1/coverage"
491 );
492 }
493
494 #[test]
495 fn pages_map_url_shape() {
496 assert_eq!(
497 pages_map_url("macos", "arm64"),
498 "https://zachhandley.github.io/RepoSources/coverage/macos_arm64.json"
499 );
500 assert_eq!(
501 pages_map_url("windows", "x86_64"),
502 "https://zachhandley.github.io/RepoSources/coverage/windows_x86_64.json"
503 );
504 }
505
506 // --- Pages map parsing ---------------------------------------------------------
507
508 /// A literal Pages-shaped fixture: `{metadata:{...}, tools:{<tool>:
509 /// {version,status,source,updated_at}}}`.
510 const PAGES_FIXTURE: &str = r#"{
511 "metadata": {
512 "platform": "macos",
513 "arch": "arm64",
514 "generated_at": "2026-07-06T12:00:00Z",
515 "tool_count": 2
516 },
517 "tools": {
518 "jq": {
519 "version": "1.8.2",
520 "status": "built",
521 "source": "source_build",
522 "updated_at": "2026-07-06T11:58:00Z"
523 },
524 "cmake": {
525 "version": "4.0.3",
526 "status": "net_fallback",
527 "source": "brew_emulate",
528 "updated_at": "2026-07-05T09:00:00Z"
529 }
530 }
531 }"#;
532
533 #[test]
534 fn coverage_map_parses_pages_fixture() {
535 let map = parse_coverage_map(PAGES_FIXTURE.as_bytes()).unwrap();
536 assert_eq!(map.tools.len(), 2);
537
538 let jq = &map.tools["jq"];
539 assert_eq!(jq.version, "1.8.2");
540 assert_eq!(jq.status, "built");
541 assert_eq!(jq.source, "source_build");
542 assert_eq!(jq.updated_at, "2026-07-06T11:58:00Z");
543
544 let cmake = &map.tools["cmake"];
545 assert_eq!(cmake.status, "net_fallback");
546
547 assert_eq!(map.metadata["platform"], "macos");
548 assert_eq!(map.metadata["tool_count"], 2);
549 }
550
551 #[test]
552 fn coverage_map_is_permissive() {
553 // Unknown fields (top-level and per-entry) tolerated; missing entry
554 // fields default; unknown status tokens degrade to data.
555 let map = parse_coverage_map(
556 br#"{"tools":{"git":{"version":"2.55.0","status":"someday","extra":true}},"future_top_level":1}"#,
557 )
558 .unwrap();
559 assert_eq!(map.tools["git"].version, "2.55.0");
560 assert_eq!(map.tools["git"].status, "someday");
561 assert_eq!(map.tools["git"].source, "");
562 assert!(map.metadata.is_null());
563
564 // A bare empty object is a valid (empty) map.
565 let empty = parse_coverage_map(b"{}").unwrap();
566 assert!(empty.tools.is_empty());
567
568 // Garbage is a parse error, not a husk.
569 assert!(parse_coverage_map(b"not json").is_err());
570 }
571
572 // --- timestamps -------------------------------------------------------------------
573
574 #[test]
575 fn now_rfc3339_parses_back() {
576 let ts = now_rfc3339();
577 let parsed =
578 chrono::DateTime::parse_from_rfc3339(&ts).expect("recorded_at must be valid RFC3339");
579 // Sanity: it's UTC "now", not an epoch default.
580 assert!(parsed.timestamp() > 1_700_000_000);
581 }
582
583 // --- seed set ------------------------------------------------------------------------
584
585 #[test]
586 fn seed_tools_shape() {
587 assert_eq!(SEED_TOOLS.len(), 15);
588 assert!(SEED_TOOLS.contains(&"protobuf"), "protoc's formula");
589 assert!(SEED_TOOLS.contains(&"openssl@3"));
590 // No accidental duplicates.
591 let mut sorted: Vec<_> = SEED_TOOLS.to_vec();
592 sorted.sort_unstable();
593 sorted.dedup();
594 assert_eq!(sorted.len(), SEED_TOOLS.len());
595 }
596}