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 match image {
172 "archlinux:latest" => "Arch".to_string(),
173 "debian:stable" => "Debian".to_string(),
174 "fedora:latest" => "Fedora".to_string(),
175 "opensuse/tumbleweed:latest" => "openSUSE Tumbleweed".to_string(),
176 other => match other.split_once(':') {
177 Some(("ubuntu", version)) => format!("Ubuntu {version}"),
178 _ => helixforge_core::printable(other),
179 },
180 }
181}
182
183impl Evidence {
184 pub fn load(root: &Path) -> Self {
187 let mut evidence = Evidence::default();
188 let catalog = root.join("catalog");
189 let claims = catalog.join("validation.toml");
190 match std::fs::read_to_string(&claims) {
191 Ok(text) => match toml::from_str(&text) {
192 Ok(claims) => evidence.claims = claims,
193 Err(error) => evidence.problems.push(format!(
194 "{}: {}",
195 claims.display(),
196 helixforge_core::printable(error.message())
197 )),
198 },
199 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
200 Err(error) => evidence
201 .problems
202 .push(format!("{}: {error}", claims.display())),
203 }
204 let results: PathBuf = catalog.join("validation-results.json");
205 if let Ok(text) = std::fs::read_to_string(&results) {
206 match serde_json::from_str(&text) {
207 Ok(results) => evidence.results = Some(results),
208 Err(error) => evidence
209 .problems
210 .push(format!("{}: {error}", results.display())),
211 }
212 }
213 evidence
214 }
215
216 pub fn critical(&self, entry: &Entry) -> bool {
220 Risk::from_flags(&entry.flags) == Risk::High
221 || self.claims.critical.entries.contains(&entry.id)
222 }
223
224 pub fn assess(&self, entry: &Entry, history: &[history::Record]) -> Assessment {
226 let here = history
227 .iter()
228 .find(|record| record.success && record.entries.contains(&entry.id))
229 .map(|record| record.unix_timestamp);
230 let mut assessment = Assessment {
231 level: Level::NotYetValidated,
232 systems: Vec::new(),
233 verified: None,
234 evidence: Vec::new(),
235 here,
236 critical: self.critical(entry),
237 };
238 let better = |assessment: &mut Assessment, level: Level| {
239 if level < assessment.level {
240 assessment.level = level;
241 }
242 };
243 for (kind, reports, level) in [
244 (
245 "a real machine",
246 &self.claims.hardware,
247 Level::HardwareTested,
248 ),
249 ("a virtual machine", &self.claims.vm, Level::VmTested),
250 ] {
251 for report in reports.iter().filter(|report| report.entry == entry.id) {
252 better(&mut assessment, level);
253 let system = helixforge_core::printable(&report.system);
254 if !assessment.systems.contains(&system) {
255 assessment.systems.push(system.clone());
256 }
257 let date = helixforge_core::printable(&report.date);
258 if assessment
259 .verified
260 .as_ref()
261 .is_none_or(|known| *known < date)
262 {
263 assessment.verified = Some(date.clone());
264 }
265 let mut line = format!("Ran on {kind}: {system}, {date}");
266 if !report.version.is_empty() {
267 line.push_str(&format!(
268 ", HelixForge {}",
269 helixforge_core::printable(&report.version)
270 ));
271 }
272 if !report.note.is_empty() {
273 line.push_str(&format!(" ({})", helixforge_core::printable(&report.note)));
274 }
275 assessment.evidence.push(line);
276 }
277 }
278 if let Some(results) = &self.results {
279 let date = crate::date(results.finished);
280 let machines: Vec<String> = results
281 .vms
282 .iter()
283 .filter(|(_, found)| {
284 found
285 .entries
286 .get(&entry.id)
287 .is_some_and(|outcome| outcome == "verified")
288 })
289 .map(|(name, _)| vm_label(name))
290 .collect();
291 if !machines.is_empty() {
292 better(&mut assessment, Level::VmTested);
293 for machine in &machines {
294 if !assessment.systems.contains(machine) {
295 assessment.systems.push(machine.clone());
296 }
297 }
298 if assessment
299 .verified
300 .as_ref()
301 .is_none_or(|known| *known < date)
302 {
303 assessment.verified = Some(date.clone());
304 }
305 assessment.evidence.push(format!(
306 "In virtual machines, Distributions run {} on {date}: {}; run for real, rebooted into and checked",
307 results.run,
308 machines.join(", ")
309 ));
310 }
311 let mut shown: Vec<String> = Vec::new();
312 for (image, found) in &results.images {
313 if let Some(outcome) = found.entries.get(&entry.id) {
314 shown.push(format!(
315 "{} ({})",
316 image_label(image),
317 if outcome == "verified" {
318 "installed and verified"
319 } else {
320 "ran"
321 }
322 ));
323 let label = image_label(image);
324 if !assessment.systems.contains(&label) {
325 assessment.systems.push(label);
326 }
327 }
328 }
329 if !shown.is_empty() {
330 better(&mut assessment, Level::ContainerTested);
331 if assessment
332 .verified
333 .as_ref()
334 .is_none_or(|known| *known < date)
335 {
336 assessment.verified = Some(date.clone());
337 }
338 assessment.evidence.push(format!(
339 "In containers, Distributions run {} on {date}: {}",
340 results.run,
341 shown.join(", ")
342 ));
343 }
344 }
345 for stand_ins in self
346 .claims
347 .stand_ins
348 .iter()
349 .filter(|group| group.entries.contains(&entry.id))
350 {
351 better(&mut assessment, Level::StandInTested);
352 let mut line = format!(
353 "Installer tests with stand-ins ({}): {}",
354 stand_ins.tests.join(", "),
355 helixforge_core::printable(&stand_ins.covers)
356 );
357 if let Some(results) = &self.results {
358 let date = crate::date(results.finished);
359 line.push_str(&format!(
360 "; they ran in all {} containers on {date}",
361 results.images.len()
362 ));
363 if assessment.level == Level::StandInTested {
364 assessment.verified = Some(date);
365 for image in results.images.keys() {
366 let label = image_label(image);
367 if !assessment.systems.contains(&label) {
368 assessment.systems.push(label);
369 }
370 }
371 }
372 }
373 assessment.evidence.push(line);
374 }
375 if let Action::Package { .. } = entry.action {
376 assessment.evidence.push(
377 "Its commands are built by HelixForge from package names, and tested against harmless stand-ins on every change.".to_string(),
378 );
379 }
380 if let Some(when) = here {
381 assessment.evidence.push(format!(
382 "Ran successfully on this machine, {}",
383 crate::date(when)
384 ));
385 }
386 assessment
387 }
388
389 pub fn summary(&self) -> String {
391 match &self.results {
392 Some(results) => format!(
393 "Container record: Distributions run {} on commit {}, {}, {} distributions{}.",
394 results.run,
395 results.commit.chars().take(12).collect::<String>(),
396 crate::date(results.finished),
397 results.images.len(),
398 match results.vms.len() {
399 0 => String::new(),
400 1 => " and 1 virtual machine".to_string(),
401 count => format!(" and {count} virtual machines"),
402 }
403 ),
404 None => "No container record in this build; release builds carry one.".to_string(),
405 }
406 }
407}
408
409pub fn warnings<'a>(
412 ids: impl IntoIterator<Item = &'a str>,
413 lookup: &dyn Fn(&str) -> Option<&'a Entry>,
414 evidence: &Evidence,
415) -> Vec<String> {
416 let mut warnings = Vec::new();
417 for id in ids {
418 let Some(entry) = lookup(id) else { continue };
419 if !evidence.critical(entry) {
420 continue;
421 }
422 let assessment = evidence.assess(entry, &[]);
423 if assessment.level >= Level::ContainerTested {
424 warnings.push(format!(
425 "{} is {}: {}",
426 entry.name,
427 assessment.level.label(),
428 match assessment.level {
429 Level::NotYetValidated => "nothing has tested it yet",
430 Level::StandInTested => "only tested with stand-ins, never on a real system",
431 _ => "containers cannot test boot, login or drivers",
432 }
433 ));
434 }
435 }
436 warnings
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use helixforge_catalog::Catalog;
443
444 fn evidence(claims: &str, results: Option<&str>) -> Evidence {
445 Evidence {
446 claims: toml::from_str(claims).unwrap(),
447 results: results.map(|text| serde_json::from_str(text).unwrap()),
448 problems: Vec::new(),
449 }
450 }
451
452 const RESULTS: &str = r#"{"format": 1, "commit": "0123456789abcdef0123", "run": 42,
453 "finished": 1792172552,
454 "images": {"archlinux:latest": {"entries": {"htop": "verified"}},
455 "ubuntu:24.04": {"entries": {"htop": "verified", "system-report": "ran"}}},
456 "vms": {"arch": {"entries": {"sddm": "verified"}}}}"#;
457
458 #[test]
459 fn levels_come_from_the_record_never_beyond_it() {
460 let catalog = Catalog::builtin(false).unwrap();
461 let found = evidence(
462 r#"
463 [critical]
464 entries = ["sddm", "grub"]
465 [[stand-ins]]
466 entries = ["sddm"]
467 tests = ["test_sddm"]
468 covers = "the theme"
469 [[hardware]]
470 entry = "grub"
471 system = "Garuda Linux"
472 date = "2026-10-20"
473 "#,
474 Some(RESULTS),
475 );
476 let assess = |id: &str| found.assess(catalog.find(id).unwrap(), &[]);
477 let htop = assess("htop");
478 assert_eq!(htop.level, Level::ContainerTested);
479 assert_eq!(htop.systems, ["Arch", "Ubuntu 24.04"]);
480 assert_eq!(htop.verified.as_deref(), Some("2026-10-16"));
481 assert!(!htop.critical);
482 assert_eq!(assess("system-report").level, Level::ContainerTested);
483 let sddm = assess("sddm");
485 assert_eq!(sddm.level, Level::VmTested);
486 assert!(sddm.critical);
487 assert!(
488 sddm.systems.contains(&"Arch VM".to_string()),
489 "{:?}",
490 sddm.systems
491 );
492 assert!(
493 sddm.evidence[0].contains("virtual machines"),
494 "{:?}",
495 sddm.evidence
496 );
497 assert!(sddm.evidence.iter().any(|line| line.contains("test_sddm")));
498 assert!(
499 found.summary().contains(" and 1 virtual machine."),
500 "{}",
501 found.summary()
502 );
503 let grub = assess("grub");
504 assert_eq!(grub.level, Level::HardwareTested);
505 assert_eq!(grub.verified.as_deref(), Some("2026-10-20"));
506 assert_eq!(assess("ripgrep").level, Level::NotYetValidated);
507 let ran = [history::Record {
509 unix_timestamp: 1_700_000_000,
510 entries: vec!["ripgrep".into()],
511 names: Vec::new(),
512 success: true,
513 result: String::new(),
514 log: None,
515 }];
516 let ripgrep = found.assess(catalog.find("ripgrep").unwrap(), &ran);
517 assert_eq!(ripgrep.level, Level::NotYetValidated);
518 assert_eq!(ripgrep.here, Some(1_700_000_000));
519
520 let warned = warnings(
523 ["sddm", "grub", "htop", "arch-server-setup"],
524 &|id| catalog.find(id),
525 &found,
526 );
527 assert_eq!(warned.len(), 1, "{warned:?}");
528 assert!(warned[0].starts_with("Arch Server Setup"), "{warned:?}");
529 assert!(found.critical(catalog.find("arch-server-setup").unwrap()));
531 }
532
533 #[test]
534 fn a_build_without_a_record_says_so() {
535 let found = evidence("", None);
536 assert!(found.summary().contains("No container record"));
537 let catalog = Catalog::builtin(false).unwrap();
538 assert_eq!(
539 found.assess(catalog.find("htop").unwrap(), &[]).level,
540 Level::NotYetValidated
541 );
542 }
543
544 #[test]
545 fn the_catalogs_own_record_reads_and_names_real_entries() {
546 let catalog = Catalog::builtin(false).unwrap();
547 let found = Evidence::load(catalog.root());
548 assert!(found.problems.is_empty(), "{:?}", found.problems);
549 assert!(!found.claims.stand_ins.is_empty());
550 for id in found
551 .claims
552 .stand_ins
553 .iter()
554 .flat_map(|group| group.entries.iter())
555 .chain(found.claims.critical.entries.iter())
556 .chain(found.claims.hardware.iter().map(|report| &report.entry))
557 .chain(found.claims.vm.iter().map(|report| &report.entry))
558 {
559 assert!(catalog.find(id).is_some(), "validation.toml names {id}");
560 }
561 }
562}