Skip to main content

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}