1use cargo_metadata::MetadataCommand;
35use std::path::{Path, PathBuf};
36use thiserror::Error;
37
38pub const KNOWN_GLIBC_ONLY_CRATES: &[&str] = &[
43 "cudarc",
45 "dbus",
47 "hyper-tls",
49 "libnss-mdns",
51 "libudev-sys",
53 "nss-files",
55 "openssl-sys",
58 "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
82pub 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
107pub 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 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 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#[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#[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
204pub 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
260pub 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 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 #[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 check_musl_compatibility(&workspace_root, "qed").expect("qed crate is musl-safe");
362 }
363
364 #[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 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 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 assert!(md.contains("| Package | Musl-static | Glibc-only deps |"));
414 assert!(md.contains("| `yubaba` | ✓ | — |"));
416 assert!(md.contains("| `qed` | ✓ | — |"));
417 assert!(md.contains("| `yah` | ✗ | `openssl-sys` |"));
419 assert!(md.contains("| `desktop` | ✗ | `dbus`, `hyper-tls` |"));
420 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}