zlayer_builder/harvest.rs
1//! Cross-search: when the package resolver can't map a Linux package to a
2//! Homebrew/Chocolatey equivalent, fire a lightweight "unfulfilled request" to
3//! `ZPackageIndex` (`{base}/linux/request`). A server-side hourly scheduler
4//! drains the queue and resolves the mapping (Repology), so a FUTURE native
5//! sandbox/HCS build resolves the package without the VM fallback. The build
6//! itself does zero apt/buildah work — this is one cheap HMAC POST.
7//!
8//! There is exactly one HMAC signer in the workspace
9//! ([`zlayer_toolchain::package_index::sign`]) and the endpoint base lives in
10//! [`zlayer_types::package_index::PackageIndexConfig`]; this module reuses both
11//! rather than re-deriving either.
12
13use zlayer_toolchain::package_index::sign;
14use zlayer_types::package_index::PackageIndexConfig;
15
16/// Build-time reposync HMAC secret; `None` disables the POST. The single home
17/// for the *value* — the signing *algorithm* lives in `zlayer-toolchain`.
18const REPOSYNC_HMAC_SECRET: Option<&str> = option_env!("ZLAYER_REPOSYNC_HMAC_SECRET");
19
20/// Fire-and-forget: report a Linux package the resolver could not map, so the
21/// index can backfill it. `manager` is `"apt"`|`"apk"`; `distro` e.g.
22/// `"debian-12"`. No-op when the HMAC secret isn't baked in. Never
23/// blocks/fails the build.
24pub fn report_unfulfilled(distro: &str, manager: &str, name: &str) {
25 let Some(secret) = REPOSYNC_HMAC_SECRET.filter(|s| !s.is_empty()) else {
26 return;
27 };
28 let (distro, manager, name) = (distro.to_string(), manager.to_string(), name.to_string());
29 let endpoint = PackageIndexConfig::from_env().linux_request_url();
30 tokio::spawn(async move {
31 // JSON-escape via serde_json to be safe with package names.
32 let payload =
33 serde_json::json!({ "distro": distro, "manager": manager, "name": name }).to_string();
34 let signature = sign(secret, payload.as_bytes());
35 let _ = reqwest::Client::new()
36 .post(&endpoint)
37 .header("x-reposync-signature", signature)
38 .header("content-type", "application/json")
39 .body(payload)
40 .send()
41 .await;
42 });
43}
44
45#[cfg(test)]
46mod tests {
47 use super::*;
48
49 #[test]
50 fn shared_signer_matches_reposync_reference_vector() {
51 // Same reference vector the toolchain signer is tested against:
52 // crypto.createHmac('sha256','test-secret').update('{"foo":"bar"}').digest('hex')
53 // Proves the DRY consolidation onto zlayer_toolchain::package_index::sign
54 // preserves the exact signature the index expects.
55 let sig = sign("test-secret", br#"{"foo":"bar"}"#);
56 assert_eq!(
57 sig,
58 "sha256=9b1abf7d901bda91325d00f6b397fb0dc257937939b27d4dc67848ab9e08f6c0"
59 );
60 }
61
62 #[test]
63 fn payload_shape_is_stable() {
64 // The request body is a flat {distro, manager, name} object; serde_json
65 // escapes package names safely (no manual string interpolation).
66 let payload = serde_json::json!({
67 "distro": "debian-12",
68 "manager": "apt",
69 "name": "lib\"weird\"-dev",
70 })
71 .to_string();
72 let parsed: serde_json::Value = serde_json::from_str(&payload).expect("valid JSON");
73 assert_eq!(parsed["distro"], "debian-12");
74 assert_eq!(parsed["manager"], "apt");
75 assert_eq!(parsed["name"], "lib\"weird\"-dev");
76 }
77
78 #[test]
79 fn endpoint_derives_from_index_base() {
80 // The request endpoint is the PackageIndexConfig-derived URL, not a
81 // hardcoded const.
82 assert_eq!(
83 PackageIndexConfig::default().linux_request_url(),
84 "https://packages.zlayer.dev/linux/request"
85 );
86 }
87}