1use crate::history;
8use helixforge_core::{Action, Entry, Risk};
9use serde::Deserialize;
10use std::{
11 collections::BTreeMap,
12 path::{Path, PathBuf},
13};
14
15#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum Level {
19 HardwareTested,
21 VmTested,
23 ContainerTested,
26 StandInTested,
28 NotYetValidated,
29}
30
31impl Level {
32 pub fn label(self) -> &'static str {
33 match self {
34 Level::HardwareTested => "Hardware Tested",
35 Level::VmTested => "VM Tested",
36 Level::ContainerTested => "Container Tested",
37 Level::StandInTested => "Stand-in Tested",
38 Level::NotYetValidated => "Not Yet Validated",
39 }
40 }
41
42 pub fn explain(self) -> &'static str {
43 match self {
44 Level::HardwareTested => "A tester ran it on a real machine and reported the result.",
45 Level::VmTested => {
46 "It ran for real in a virtual machine: the Distributions workflow boots a distribution's cloud image or live ISO, runs the entry, reboots into the result and checks it (the configuration, the kernel command line, the boot images, the services, a graphical boot menu or login screen). Real graphics hardware and firmware are not covered."
47 }
48 Level::ContainerTested => {
49 "The Distributions workflow ran it in distribution containers and HelixForge verified the result there. Containers have no boot loader, login screen or desktop, so those parts are not covered."
50 }
51 Level::StandInTested => {
52 "Installer tests run its script, or the helpers it calls, against stand-in commands and files, on every change and in each distribution's container. They show what it would change, without changing a system."
53 }
54 Level::NotYetValidated => {
55 "No automated test runs it and nobody has reported running it. Its plan is built and checked, like every entry's."
56 }
57 }
58 }
59}
60
61#[derive(Clone, Debug, Default, Deserialize)]
63pub struct Claims {
64 #[serde(default)]
65 pub critical: Critical,
66 #[serde(default, rename = "stand-ins")]
67 pub stand_ins: Vec<StandIns>,
68 #[serde(default)]
69 pub hardware: Vec<Report>,
70 #[serde(default)]
71 pub vm: Vec<Report>,
72}
73
74#[derive(Clone, Debug, Default, Deserialize)]
75pub struct Critical {
76 #[serde(default)]
77 pub entries: Vec<String>,
78}
79
80#[derive(Clone, Debug, Default, Deserialize)]
82pub struct StandIns {
83 pub entries: Vec<String>,
84 pub tests: Vec<String>,
85 #[serde(default)]
86 pub covers: String,
87}
88
89#[derive(Clone, Debug, Default, Deserialize)]
91pub struct Report {
92 pub entry: String,
93 pub system: String,
94 #[serde(default)]
95 pub version: String,
96 pub date: String,
97 #[serde(default)]
98 pub note: String,
99}
100
101#[derive(Clone, Debug, Default, Deserialize)]
104pub struct Results {
105 #[serde(default)]
106 pub commit: String,
107 #[serde(default)]
108 pub run: u64,
109 #[serde(default)]
110 pub url: String,
111 #[serde(default)]
113 pub finished: u64,
114 #[serde(default)]
117 pub images: BTreeMap<String, Image>,
118 #[serde(default)]
121 pub vms: BTreeMap<String, Image>,
122}
123
124#[derive(Clone, Debug, Default, Deserialize)]
125pub struct Image {
126 #[serde(default)]
127 pub entries: BTreeMap<String, String>,
128}
129
130#[derive(Clone, Debug, Default)]
132pub struct Evidence {
133 pub claims: Claims,
134 pub results: Option<Results>,
135 pub problems: Vec<String>,
137}
138
139#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
141pub struct Assessment {
142 pub level: Level,
143 pub systems: Vec<String>,
145 pub verified: Option<String>,
147 pub evidence: Vec<String>,
149 pub here: Option<u64>,
151 pub critical: bool,
153}
154
155pub fn vm_label(name: &str) -> String {
157 match name {
158 "arch" | "arch-themes" => "Arch VM".to_string(),
159 "debian" => "Debian VM".to_string(),
160 "fedora" => "Fedora VM".to_string(),
161 "opensuse" => "openSUSE VM".to_string(),
162 "arch-uefi" => "Arch UEFI VM".to_string(),
163 "arch-iso" => "Arch live ISO VM".to_string(),
164 "steamdeck" => "Steam Deck stand-in VM".to_string(),
165 other => format!("{} VM", helixforge_core::printable(other)),
166 }
167}
168
169pub fn image_label(image: &str) -> String {
171 if let Some(image) = image.strip_suffix(" on aarch64") {
173 return format!("{} on ARM64", image_label(image));
174 }
175 match image {
176 "archlinux:latest" => "Arch".to_string(),
177 "archlinuxarm" => "Arch Linux ARM".to_string(),
178 "debian:stable" => "Debian".to_string(),
179 "fedora:latest" => "Fedora".to_string(),
180 "opensuse/tumbleweed:latest" => "openSUSE Tumbleweed".to_string(),
181 other => match other.split_once(':') {
182 Some(("ubuntu", version)) => format!("Ubuntu {version}"),
183 _ => helixforge_core::printable(other),
184 },
185 }
186}
187
188impl Evidence {
189 pub fn load(root: &Path) -> Self {
192 let mut evidence = Evidence::default();
193 let catalog = root.join("catalog");
194 let claims = catalog.join("validation.toml");
195 match std::fs::read_to_string(&claims) {
196 Ok(text) => match toml::from_str(&text) {
197 Ok(claims) => evidence.claims = claims,
198 Err(error) => evidence.problems.push(format!(
199 "{}: {}",
200 claims.display(),
201 helixforge_core::printable(error.message())
202 )),
203 },
204 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
205 Err(error) => evidence
206 .problems
207 .push(format!("{}: {error}", claims.display())),
208 }
209 let results: PathBuf = catalog.join("validation-results.json");
210 if let Ok(text) = std::fs::read_to_string(&results) {
211 match serde_json::from_str(&text) {
212 Ok(results) => evidence.results = Some(results),
213 Err(error) => evidence
214 .problems
215 .push(format!("{}: {error}", results.display())),
216 }
217 }
218 evidence
219 }
220
221 pub fn critical(&self, entry: &Entry) -> bool {
225 Risk::from_flags(&entry.flags) == Risk::High
226 || self.claims.critical.entries.contains(&entry.id)
227 }
228
229 pub fn assess(&self, entry: &Entry, history: &[history::Record]) -> Assessment {
231 let here = history
232 .iter()
233 .find(|record| record.success && record.entries.contains(&entry.id))
234 .map(|record| record.unix_timestamp);
235 let mut assessment = Assessment {
236 level: Level::NotYetValidated,
237 systems: Vec::new(),
238 verified: None,
239 evidence: Vec::new(),
240 here,
241 critical: self.critical(entry),
242 };
243 let better = |assessment: &mut Assessment, level: Level| {
244 if level < assessment.level {
245 assessment.level = level;
246 }
247 };
248 for (kind, reports, level) in [
249 (
250 "a real machine",
251 &self.claims.hardware,
252 Level::HardwareTested,
253 ),
254 ("a virtual machine", &self.claims.vm, Level::VmTested),
255 ] {
256 for report in reports.iter().filter(|report| report.entry == entry.id) {
257 better(&mut assessment, level);
258 let system = helixforge_core::printable(&report.system);
259 if !assessment.systems.contains(&system) {
260 assessment.systems.push(system.clone());
261 }
262 let date = helixforge_core::printable(&report.date);
263 if assessment
264 .verified
265 .as_ref()
266 .is_none_or(|known| *known < date)
267 {
268 assessment.verified = Some(date.clone());
269 }
270 let mut line = format!("Ran on {kind}: {system}, {date}");
271 if !report.version.is_empty() {
272 line.push_str(&format!(
273 ", HelixForge {}",
274 helixforge_core::printable(&report.version)
275 ));
276 }
277 if !report.note.is_empty() {
278 line.push_str(&format!(" ({})", helixforge_core::printable(&report.note)));
279 }
280 assessment.evidence.push(line);
281 }
282 }
283 if let Some(results) = &self.results {
284 let date = crate::date(results.finished);
285 let machines: Vec<String> = results
286 .vms
287 .iter()
288 .filter(|(_, found)| {
289 found
290 .entries
291 .get(&entry.id)
292 .is_some_and(|outcome| outcome == "verified")
293 })
294 .map(|(name, _)| vm_label(name))
295 .collect();
296 if !machines.is_empty() {
297 better(&mut assessment, Level::VmTested);
298 for machine in &machines {
299 if !assessment.systems.contains(machine) {
300 assessment.systems.push(machine.clone());
301 }
302 }
303 if assessment
304 .verified
305 .as_ref()
306 .is_none_or(|known| *known < date)
307 {
308 assessment.verified = Some(date.clone());
309 }
310 assessment.evidence.push(format!(
311 "In virtual machines, Distributions run {} on {date}: {}; run for real, rebooted into and checked",
312 results.run,
313 machines.join(", ")
314 ));
315 }
316 let mut shown: Vec<String> = Vec::new();
317 for (image, found) in &results.images {
318 if let Some(outcome) = found.entries.get(&entry.id) {
319 shown.push(format!(
320 "{} ({})",
321 image_label(image),
322 if outcome == "verified" {
323 "installed and verified"
324 } else {
325 "ran"
326 }
327 ));
328 let label = image_label(image);
329 if !assessment.systems.contains(&label) {
330 assessment.systems.push(label);
331 }
332 }
333 }
334 if !shown.is_empty() {
335 better(&mut assessment, Level::ContainerTested);
336 if assessment
337 .verified
338 .as_ref()
339 .is_none_or(|known| *known < date)
340 {
341 assessment.verified = Some(date.clone());
342 }
343 assessment.evidence.push(format!(
344 "In containers, Distributions run {} on {date}: {}",
345 results.run,
346 shown.join(", ")
347 ));
348 }
349 }
350 for stand_ins in self
351 .claims
352 .stand_ins
353 .iter()
354 .filter(|group| group.entries.contains(&entry.id))
355 {
356 better(&mut assessment, Level::StandInTested);
357 let mut line = format!(
358 "Installer tests with stand-ins ({}): {}",
359 stand_ins.tests.join(", "),
360 helixforge_core::printable(&stand_ins.covers)
361 );
362 if let Some(results) = &self.results {
363 let date = crate::date(results.finished);
364 line.push_str(&format!(
365 "; they ran in all {} containers on {date}",
366 results.images.len()
367 ));
368 if assessment.level == Level::StandInTested {
369 assessment.verified = Some(date);
370 for image in results.images.keys() {
371 let label = image_label(image);
372 if !assessment.systems.contains(&label) {
373 assessment.systems.push(label);
374 }
375 }
376 }
377 }
378 assessment.evidence.push(line);
379 }
380 if let Action::Package { .. } = entry.action {
381 assessment.evidence.push(
382 "Its commands are built by HelixForge from package names, and tested against harmless stand-ins on every change.".to_string(),
383 );
384 }
385 if let Some(when) = here {
386 assessment.evidence.push(format!(
387 "Ran successfully on this machine, {}",
388 crate::date(when)
389 ));
390 }
391 assessment
392 }
393
394 pub fn summary(&self) -> String {
396 match &self.results {
397 Some(results) => format!(
398 "Container record: Distributions run {} on commit {}, {}, {} distributions{}.",
399 results.run,
400 results.commit.chars().take(12).collect::<String>(),
401 crate::date(results.finished),
402 results.images.len(),
403 match results.vms.len() {
404 0 => String::new(),
405 1 => " and 1 virtual machine".to_string(),
406 count => format!(" and {count} virtual machines"),
407 }
408 ),
409 None => "No container record in this build; release builds carry one.".to_string(),
410 }
411 }
412}
413
414pub fn warnings<'a>(
417 ids: impl IntoIterator<Item = &'a str>,
418 lookup: &dyn Fn(&str) -> Option<&'a Entry>,
419 evidence: &Evidence,
420) -> Vec<String> {
421 let mut warnings = Vec::new();
422 for id in ids {
423 let Some(entry) = lookup(id) else { continue };
424 if !evidence.critical(entry) {
425 continue;
426 }
427 let assessment = evidence.assess(entry, &[]);
428 if assessment.level >= Level::ContainerTested {
429 warnings.push(format!(
430 "{} is {}: {}",
431 entry.name,
432 assessment.level.label(),
433 match assessment.level {
434 Level::NotYetValidated => "nothing has tested it yet",
435 Level::StandInTested => "only tested with stand-ins, never on a real system",
436 _ => "containers cannot test boot, login or drivers",
437 }
438 ));
439 }
440 }
441 warnings
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use helixforge_catalog::Catalog;
448
449 fn evidence(claims: &str, results: Option<&str>) -> Evidence {
450 Evidence {
451 claims: toml::from_str(claims).unwrap(),
452 results: results.map(|text| serde_json::from_str(text).unwrap()),
453 problems: Vec::new(),
454 }
455 }
456
457 const RESULTS: &str = r#"{"format": 1, "commit": "0123456789abcdef0123", "run": 42,
458 "finished": 1792172552,
459 "images": {"archlinux:latest": {"entries": {"htop": "verified"}},
460 "ubuntu:24.04": {"entries": {"htop": "verified", "system-report": "ran"}}},
461 "vms": {"arch": {"entries": {"sddm": "verified"}}}}"#;
462
463 #[test]
464 fn container_images_are_named_as_systems() {
465 assert_eq!(image_label("debian:stable"), "Debian");
466 assert_eq!(image_label("ubuntu:22.04"), "Ubuntu 22.04");
467 assert_eq!(
468 image_label("opensuse/tumbleweed:latest on aarch64"),
469 "openSUSE Tumbleweed on ARM64"
470 );
471 assert_eq!(
472 image_label("archlinuxarm on aarch64"),
473 "Arch Linux ARM on ARM64"
474 );
475 }
476
477 #[test]
478 fn levels_come_from_the_record_never_beyond_it() {
479 let catalog = Catalog::builtin(false).unwrap();
480 let found = evidence(
481 r#"
482 [critical]
483 entries = ["sddm", "grub"]
484 [[stand-ins]]
485 entries = ["sddm"]
486 tests = ["test_sddm"]
487 covers = "the theme"
488 [[hardware]]
489 entry = "grub"
490 system = "Garuda Linux"
491 date = "2026-10-20"
492 "#,
493 Some(RESULTS),
494 );
495 let assess = |id: &str| found.assess(catalog.find(id).unwrap(), &[]);
496 let htop = assess("htop");
497 assert_eq!(htop.level, Level::ContainerTested);
498 assert_eq!(htop.systems, ["Arch", "Ubuntu 24.04"]);
499 assert_eq!(htop.verified.as_deref(), Some("2026-10-16"));
500 assert!(!htop.critical);
501 assert_eq!(assess("system-report").level, Level::ContainerTested);
502 let sddm = assess("sddm");
504 assert_eq!(sddm.level, Level::VmTested);
505 assert!(sddm.critical);
506 assert!(
507 sddm.systems.contains(&"Arch VM".to_string()),
508 "{:?}",
509 sddm.systems
510 );
511 assert!(
512 sddm.evidence[0].contains("virtual machines"),
513 "{:?}",
514 sddm.evidence
515 );
516 assert!(sddm.evidence.iter().any(|line| line.contains("test_sddm")));
517 assert!(
518 found.summary().contains(" and 1 virtual machine."),
519 "{}",
520 found.summary()
521 );
522 let grub = assess("grub");
523 assert_eq!(grub.level, Level::HardwareTested);
524 assert_eq!(grub.verified.as_deref(), Some("2026-10-20"));
525 assert_eq!(assess("ripgrep").level, Level::NotYetValidated);
526 let ran = [history::Record {
528 unix_timestamp: 1_700_000_000,
529 entries: vec!["ripgrep".into()],
530 names: Vec::new(),
531 success: true,
532 result: String::new(),
533 log: None,
534 }];
535 let ripgrep = found.assess(catalog.find("ripgrep").unwrap(), &ran);
536 assert_eq!(ripgrep.level, Level::NotYetValidated);
537 assert_eq!(ripgrep.here, Some(1_700_000_000));
538
539 let warned = warnings(
542 ["sddm", "grub", "htop", "arch-server-setup"],
543 &|id| catalog.find(id),
544 &found,
545 );
546 assert_eq!(warned.len(), 1, "{warned:?}");
547 assert!(warned[0].starts_with("Arch Server Setup"), "{warned:?}");
548 assert!(found.critical(catalog.find("arch-server-setup").unwrap()));
550 }
551
552 #[test]
553 fn a_build_without_a_record_says_so() {
554 let found = evidence("", None);
555 assert!(found.summary().contains("No container record"));
556 let catalog = Catalog::builtin(false).unwrap();
557 assert_eq!(
558 found.assess(catalog.find("htop").unwrap(), &[]).level,
559 Level::NotYetValidated
560 );
561 }
562
563 #[test]
564 fn the_catalogs_own_record_reads_and_names_real_entries() {
565 let catalog = Catalog::builtin(false).unwrap();
566 let found = Evidence::load(catalog.root());
567 assert!(found.problems.is_empty(), "{:?}", found.problems);
568 assert!(!found.claims.stand_ins.is_empty());
569 for id in found
570 .claims
571 .stand_ins
572 .iter()
573 .flat_map(|group| group.entries.iter())
574 .chain(found.claims.critical.entries.iter())
575 .chain(found.claims.hardware.iter().map(|report| &report.entry))
576 .chain(found.claims.vm.iter().map(|report| &report.entry))
577 {
578 assert!(catalog.find(id).is_some(), "validation.toml names {id}");
579 }
580 }
581}