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