Skip to main content

yah_qed/
preflight.rs

1//! Cross-compile preflight (R407-T3, W143, W154).
2//!
3//! Walks a cargo workspace package's transitive dep tree and flags crates
4//! that link glibc-only system libraries — these can't build statically
5//! against musl and would fail at link time during the cross build. The
6//! gate runs *before* the build step so the pipeline can route to the
7//! container fallback (`runtime = "container"`) with a clear actionable
8//! error rather than dying mid-cross-build with a confusing linker error.
9//!
10//! This is the W154 musl-static gate: native-runtime deployment under
11//! Kamaji assumes a static musl binary, and any glibc-only dep makes
12//! that impossible. The gate is the producer-side guarantee that the
13//! workload classification in W154 (yubaba → native, codec-heavy yah CLI
14//! → container) actually reflects what's buildable.
15//!
16//! ## Surface
17//!
18//! - [`KNOWN_GLIBC_ONLY_CRATES`] — hand-maintained list of crate names that
19//!   are known not to musl-static cleanly. Grows as real cross-build
20//!   failures surface; entries carry a comment with the remediation.
21//! - [`check_dep_list`] — pure: takes a sequence of crate names and returns
22//!   the offending subset against the gate list. Used in tests and as the
23//!   core of the metadata-driven check.
24//! - [`check_musl_compatibility`] — shells `cargo metadata` for a workspace
25//!   package and runs [`check_dep_list`] over its transitive deps.
26//!
27//! ## Integration
28//!
29//! A `kind = "musl-static-preflight"` pipeline step runs this gate against
30//! `step.package` (a workspace member name) before the cross build that
31//! depends on it. The runner integration lives in
32//! [`crate::runner::PipelineRunner::execute_step_musl_static_preflight`].
33
34use cargo_metadata::MetadataCommand;
35use std::path::{Path, PathBuf};
36use thiserror::Error;
37
38/// Crates that link glibc-only system libraries / APIs and therefore can't
39/// be built statically against musl. Hand-maintained — grows as real
40/// failures surface during musl cross builds. Sorted alphabetically for
41/// easy diff review when entries land.
42pub const KNOWN_GLIBC_ONLY_CRATES: &[&str] = &[
43    // CUDA driver bindings — Linux nvidia driver isn't musl-friendly.
44    "cudarc",
45    // System libdbus.
46    "dbus",
47    // hyper-tls pulls openssl-sys; switch to hyper-rustls.
48    "hyper-tls",
49    // glibc-specific NSS plugin host APIs.
50    "libnss-mdns",
51    // Links system udev (glibc-specific build).
52    "libudev-sys",
53    // NSS plugin glue.
54    "nss-files",
55    // OpenSSL via system libssl is dynamic-only on musl distros; use
56    // rustls or set `vendored` feature on the `openssl` crate.
57    "openssl-sys",
58    // CUDA toolkit bindings.
59    "rust-cuda",
60];
61
62#[derive(Debug, Error, PartialEq, Eq)]
63pub enum MuslPreflightError {
64    #[error("`cargo metadata` failed for `{package}`: {reason}")]
65    CargoMetadata { package: String, reason: String },
66    #[error("package `{package}` not found in the workspace metadata at {manifest_path}")]
67    PackageNotFound {
68        package: String,
69        manifest_path: PathBuf,
70    },
71    #[error(
72        "package `{package}` cannot build musl-static — depends on glibc-only crate(s): {offenders:?}. \
73         Route this build to the container fallback (set `runtime = \"container\"` on the upstream build step, \
74         or use the `cross` toolchain) — or replace the offending dep with a musl-friendly alternative."
75    )]
76    NotMuslSafe {
77        package: String,
78        offenders: Vec<String>,
79    },
80}
81
82/// Pure dep-list gate. Used in tests directly and by
83/// [`check_musl_compatibility`] after harvesting names from `cargo metadata`.
84/// Returns `Ok(())` when no input matches the gate list.
85pub fn check_dep_list<I, S>(package: &str, dep_names: I) -> Result<(), MuslPreflightError>
86where
87    I: IntoIterator<Item = S>,
88    S: AsRef<str>,
89{
90    let mut offenders: Vec<String> = dep_names
91        .into_iter()
92        .filter(|d| KNOWN_GLIBC_ONLY_CRATES.contains(&d.as_ref()))
93        .map(|d| d.as_ref().to_string())
94        .collect();
95    offenders.sort();
96    offenders.dedup();
97    if offenders.is_empty() {
98        Ok(())
99    } else {
100        Err(MuslPreflightError::NotMuslSafe {
101            package: package.to_string(),
102            offenders,
103        })
104    }
105}
106
107/// Shell `cargo metadata` for `workspace_root/Cargo.toml`, locate the
108/// workspace member named `package`, and run [`check_dep_list`] against the
109/// names of every crate in its resolved transitive closure.
110///
111/// The check is intentionally coarse: any appearance of a gated crate
112/// anywhere in the resolved set fails the preflight, even if the crate is
113/// only pulled by an optional feature that the target build won't enable.
114/// False positives are recoverable (the operator can route to container
115/// fallback or refine the gate list); silently shipping a non-musl-static
116/// binary into the native runtime path is not.
117pub fn check_musl_compatibility(
118    workspace_root: &Path,
119    package: &str,
120) -> Result<(), MuslPreflightError> {
121    let manifest_path = workspace_root.join("Cargo.toml");
122    let metadata = MetadataCommand::new()
123        .manifest_path(&manifest_path)
124        .exec()
125        .map_err(|e| MuslPreflightError::CargoMetadata {
126            package: package.to_string(),
127            reason: e.to_string(),
128        })?;
129
130    // Anchor on the named workspace member so the gate is workload-aware:
131    // walking ALL workspace deps would over-trigger on crates that the
132    // target binary doesn't pull.
133    let root_id = metadata
134        .workspace_members
135        .iter()
136        .find(|id| metadata[id].name == package)
137        .cloned()
138        .ok_or_else(|| MuslPreflightError::PackageNotFound {
139            package: package.to_string(),
140            manifest_path: manifest_path.clone(),
141        })?;
142
143    // Walk the resolved graph from root, collecting every reachable
144    // crate name. `resolve.nodes` is a flat list keyed by PackageId; we
145    // do a small BFS to stay on the named package's closure.
146    let resolve = metadata
147        .resolve
148        .as_ref()
149        .ok_or_else(|| MuslPreflightError::CargoMetadata {
150            package: package.to_string(),
151            reason: "cargo metadata returned no resolved dep graph".into(),
152        })?;
153    let node_by_id: std::collections::HashMap<_, _> =
154        resolve.nodes.iter().map(|n| (n.id.clone(), n)).collect();
155
156    let mut seen = std::collections::HashSet::new();
157    let mut frontier = vec![root_id.clone()];
158    let mut reachable_names = Vec::new();
159    while let Some(id) = frontier.pop() {
160        if !seen.insert(id.clone()) {
161            continue;
162        }
163        if let Some(node) = node_by_id.get(&id) {
164            reachable_names.push(metadata[&id].name.clone());
165            for dep in &node.dependencies {
166                frontier.push(dep.clone());
167            }
168        }
169    }
170
171    check_dep_list(package, reachable_names.iter().map(String::as_str))
172}
173
174/// One row of a workspace musl-static audit (R407-T4): per workspace member,
175/// did the gate pass and (when it didn't) what crates blocked it.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct AuditRow {
178    pub package: String,
179    pub offenders: Vec<String>,
180}
181
182impl AuditRow {
183    pub fn is_clean(&self) -> bool {
184        self.offenders.is_empty()
185    }
186}
187
188/// Full workspace audit: one [`AuditRow`] per resolvable workspace member,
189/// ordered alphabetically for stable diffs.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct WorkspaceAudit {
192    pub rows: Vec<AuditRow>,
193}
194
195impl WorkspaceAudit {
196    pub fn clean(&self) -> impl Iterator<Item = &AuditRow> {
197        self.rows.iter().filter(|r| r.is_clean())
198    }
199    pub fn blocked(&self) -> impl Iterator<Item = &AuditRow> {
200        self.rows.iter().filter(|r| !r.is_clean())
201    }
202}
203
204/// Run [`check_musl_compatibility`] across every workspace member and return
205/// the per-package result. Used by `yah qed audit-musl` to give the
206/// W154 inventory answer ("which yah crates can musl-static today").
207///
208/// Errors from individual member checks become rows with `offenders.is_empty()
209/// == false` if they were `NotMuslSafe`, or are propagated up if they were
210/// metadata-shape errors (PackageNotFound is impossible by construction —
211/// we iterate `metadata.workspace_members`).
212pub fn audit_workspace(workspace_root: &Path) -> Result<WorkspaceAudit, MuslPreflightError> {
213    let manifest_path = workspace_root.join("Cargo.toml");
214    let metadata = MetadataCommand::new()
215        .manifest_path(&manifest_path)
216        .exec()
217        .map_err(|e| MuslPreflightError::CargoMetadata {
218            package: "<workspace>".into(),
219            reason: e.to_string(),
220        })?;
221
222    let resolve = metadata
223        .resolve
224        .as_ref()
225        .ok_or_else(|| MuslPreflightError::CargoMetadata {
226            package: "<workspace>".into(),
227            reason: "cargo metadata returned no resolved dep graph".into(),
228        })?;
229    let node_by_id: std::collections::HashMap<_, _> =
230        resolve.nodes.iter().map(|n| (n.id.clone(), n)).collect();
231
232    let mut rows: Vec<AuditRow> = Vec::with_capacity(metadata.workspace_members.len());
233    for member_id in &metadata.workspace_members {
234        let package = metadata[member_id].name.clone();
235        let mut seen = std::collections::HashSet::new();
236        let mut frontier = vec![member_id.clone()];
237        let mut reachable_names = Vec::new();
238        while let Some(id) = frontier.pop() {
239            if !seen.insert(id.clone()) {
240                continue;
241            }
242            if let Some(node) = node_by_id.get(&id) {
243                reachable_names.push(metadata[&id].name.clone());
244                for dep in &node.dependencies {
245                    frontier.push(dep.clone());
246                }
247            }
248        }
249        let offenders = match check_dep_list(&package, reachable_names.iter().map(String::as_str)) {
250            Ok(()) => Vec::new(),
251            Err(MuslPreflightError::NotMuslSafe { offenders, .. }) => offenders,
252            Err(other) => return Err(other),
253        };
254        rows.push(AuditRow { package, offenders });
255    }
256    rows.sort_by(|a, b| a.package.cmp(&b.package));
257    Ok(WorkspaceAudit { rows })
258}
259
260/// Render a [`WorkspaceAudit`] as a markdown report — a summary line plus a
261/// table grouped clean-first-then-blocked. Stable byte-for-byte across runs
262/// of the same metadata (no timestamps, no env-dependent strings).
263pub fn render_markdown(audit: &WorkspaceAudit) -> String {
264    let total = audit.rows.len();
265    let clean = audit.clean().count();
266    let blocked = total - clean;
267    let mut out = String::new();
268    out.push_str("# yah workspace musl-static audit\n\n");
269    out.push_str(&format!(
270        "**{clean}/{total} workspace members are musl-static clean** ({blocked} blocked).\n\n"
271    ));
272    out.push_str(
273        "Gated crate list: [`KNOWN_GLIBC_ONLY_CRATES`](../../crates/yah/qed/src/preflight.rs). \
274         Regenerate with `yah qed audit-musl`.\n\n",
275    );
276    out.push_str("| Package | Musl-static | Glibc-only deps |\n");
277    out.push_str("|---------|-------------|-----------------|\n");
278    for row in audit.clean() {
279        out.push_str(&format!("| `{}` | ✓ | — |\n", row.package));
280    }
281    for row in audit.blocked() {
282        let offenders = row
283            .offenders
284            .iter()
285            .map(|o| format!("`{o}`"))
286            .collect::<Vec<_>>()
287            .join(", ");
288        out.push_str(&format!("| `{}` | ✗ | {} |\n", row.package, offenders));
289    }
290    out
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn check_dep_list_passes_clean_tree() {
299        let deps = ["tokio", "serde", "thiserror", "uuid"];
300        check_dep_list("yubaba", deps).unwrap();
301    }
302
303    #[test]
304    fn check_dep_list_flags_openssl_sys() {
305        let deps = ["tokio", "openssl-sys", "serde"];
306        let err = check_dep_list("yubaba", deps).unwrap_err();
307        match err {
308            MuslPreflightError::NotMuslSafe { package, offenders } => {
309                assert_eq!(package, "yubaba");
310                assert_eq!(offenders, vec!["openssl-sys".to_string()]);
311            }
312            other => panic!("expected NotMuslSafe, got {other:?}"),
313        }
314    }
315
316    #[test]
317    fn check_dep_list_collects_and_dedups_multiple_offenders() {
318        // Each appears twice; output should dedupe + sort.
319        let deps = [
320            "tokio",
321            "openssl-sys",
322            "hyper-tls",
323            "openssl-sys",
324            "dbus",
325            "hyper-tls",
326        ];
327        let err = check_dep_list("yah", deps).unwrap_err();
328        match err {
329            MuslPreflightError::NotMuslSafe { offenders, .. } => {
330                assert_eq!(offenders, vec!["dbus", "hyper-tls", "openssl-sys"]);
331            }
332            other => panic!("expected NotMuslSafe, got {other:?}"),
333        }
334    }
335
336    #[test]
337    fn known_glibc_list_is_sorted_for_easy_diff_review() {
338        let mut sorted = KNOWN_GLIBC_ONLY_CRATES.to_vec();
339        sorted.sort();
340        assert_eq!(
341            sorted, KNOWN_GLIBC_ONLY_CRATES,
342            "KNOWN_GLIBC_ONLY_CRATES must stay sorted",
343        );
344    }
345
346    /// Smoke test against this very workspace: the qed crate is musl-clean
347    /// by design (no openssl-sys, no dbus, no cuda). If this test ever
348    /// fails, either the qed dep tree gained a glibc-only dep (regression
349    /// to fix) or [`KNOWN_GLIBC_ONLY_CRATES`] picked up a false positive
350    /// (refine the list).
351    #[test]
352    fn qed_workspace_passes_musl_gate() {
353        let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
354            .ancestors()
355            .find(|p| p.join("Cargo.lock").is_file())
356            .expect("workspace root has Cargo.lock")
357            .to_path_buf();
358        // The qed crate itself is the safest target: pure data + tokio +
359        // tar/flate2. If this errors with PackageNotFound the workspace
360        // member name has drifted.
361        check_musl_compatibility(&workspace_root, "qed").expect("qed crate is musl-safe");
362    }
363
364    // ── R407-T4 workspace audit ────────────────────────────────────────────
365
366    #[test]
367    fn audit_workspace_returns_one_row_per_member_and_qed_is_clean() {
368        let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
369            .ancestors()
370            .find(|p| p.join("Cargo.lock").is_file())
371            .expect("workspace root has Cargo.lock")
372            .to_path_buf();
373        let audit = audit_workspace(&workspace_root).expect("workspace audit succeeds");
374        assert!(!audit.rows.is_empty(), "workspace has members");
375        // qed must be in the audit and must be clean by design.
376        let qed = audit
377            .rows
378            .iter()
379            .find(|r| r.package == "qed")
380            .expect("qed appears in audit");
381        assert!(qed.is_clean(), "qed should be musl-static clean: {qed:?}");
382        // Rows are sorted alphabetically.
383        let mut sorted = audit.rows.clone();
384        sorted.sort_by(|a, b| a.package.cmp(&b.package));
385        assert_eq!(audit.rows, sorted, "audit rows are alphabetically sorted");
386    }
387
388    #[test]
389    fn render_markdown_groups_clean_first_then_blocked_with_summary() {
390        let audit = WorkspaceAudit {
391            rows: vec![
392                AuditRow {
393                    package: "yubaba".into(),
394                    offenders: vec![],
395                },
396                AuditRow {
397                    package: "yah".into(),
398                    offenders: vec!["openssl-sys".into()],
399                },
400                AuditRow {
401                    package: "qed".into(),
402                    offenders: vec![],
403                },
404                AuditRow {
405                    package: "desktop".into(),
406                    offenders: vec!["dbus".into(), "hyper-tls".into()],
407                },
408            ],
409        };
410        let md = render_markdown(&audit);
411        assert!(md.contains("**2/4 workspace members are musl-static clean** (2 blocked)."));
412        // Header table present.
413        assert!(md.contains("| Package | Musl-static | Glibc-only deps |"));
414        // Clean rows render with the check icon.
415        assert!(md.contains("| `yubaba` | ✓ | — |"));
416        assert!(md.contains("| `qed` | ✓ | — |"));
417        // Blocked rows list offenders inline, backtick-wrapped.
418        assert!(md.contains("| `yah` | ✗ | `openssl-sys` |"));
419        assert!(md.contains("| `desktop` | ✗ | `dbus`, `hyper-tls` |"));
420        // Clean group precedes blocked group.
421        let clean_at = md.find("| `yubaba` |").unwrap();
422        let blocked_at = md.find("| `yah` |").unwrap();
423        assert!(
424            clean_at < blocked_at,
425            "clean rows render before blocked rows for scanability",
426        );
427    }
428
429    #[test]
430    fn render_markdown_is_byte_stable_for_the_same_audit() {
431        let audit = WorkspaceAudit {
432            rows: vec![
433                AuditRow {
434                    package: "a".into(),
435                    offenders: vec![],
436                },
437                AuditRow {
438                    package: "b".into(),
439                    offenders: vec!["openssl-sys".into()],
440                },
441            ],
442        };
443        assert_eq!(render_markdown(&audit), render_markdown(&audit));
444    }
445
446    #[test]
447    fn workspace_audit_clean_and_blocked_iterators_partition_rows() {
448        let audit = WorkspaceAudit {
449            rows: vec![
450                AuditRow {
451                    package: "a".into(),
452                    offenders: vec![],
453                },
454                AuditRow {
455                    package: "b".into(),
456                    offenders: vec!["dbus".into()],
457                },
458                AuditRow {
459                    package: "c".into(),
460                    offenders: vec![],
461                },
462            ],
463        };
464        assert_eq!(audit.clean().count(), 2);
465        assert_eq!(audit.blocked().count(), 1);
466        assert_eq!(audit.blocked().next().unwrap().package, "b");
467    }
468
469    #[test]
470    fn package_not_found_surfaces_clearly() {
471        let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
472            .ancestors()
473            .find(|p| p.join("Cargo.lock").is_file())
474            .expect("workspace root has Cargo.lock")
475            .to_path_buf();
476        let err =
477            check_musl_compatibility(&workspace_root, "definitely-not-a-real-package").unwrap_err();
478        assert!(matches!(err, MuslPreflightError::PackageNotFound { .. }));
479    }
480}