USB-AIO für automatische Geräteprüfung
Some checks failed
Tests / test (push) Has been cancelled

This commit is contained in:
2026-10-14 20:14:36 +02:00
commit 81f06cf6b4
40 changed files with 9956 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
name: Tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Systempakete installieren
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libx11-dev libxkbcommon-dev libwayland-dev libgl1-mesa-dev
- name: Rust installieren
uses: dtolnay/rust-toolchain@stable
- name: Rust testen
run: |
cargo test --locked --manifest-path scanner/Cargo.toml
cargo test --locked --manifest-path controller/Cargo.toml
cargo test --locked --manifest-path gui/Cargo.toml
- name: Python und Rauchtests
run: |
python3 -m unittest discover -s tests -p 'test_*.py'
bash tests/smoke.sh

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
/build/
/**/target/
__pycache__/
*.py[cod]
*.log
*.iso
.idea/
.vscode/
.DS_Store
# Lokale Zugangsdaten und persistente Werkstattdaten
/config/
/database/
/reports/
/inspections/
/final-reports/
/outbox/
inventory-api-key
inventory-server.crt
workshop-wifi.json

104
README.md Normal file
View File

@@ -0,0 +1,104 @@
# CS-R USB-AIO
Der USB-Stick unterstützt die Annahme und technische Prüfung gespendeter Computer.
Er startet als eigenständiges Arch-Linux-Livesystem, erkennt das aktuelle Gerät über
seine Hardware-ID und verbindet es mit der zentralen Inventarverwaltung.
## Ablauf in der Werkstatt
1. Vom USB-Stick starten.
2. Beim ersten Einsatz unter **Einstellungen** das Werkstatt-WLAN verbinden und
**WLAN speichern** aktiviert lassen.
3. Das Gerät wird über DMI-UUID oder Hersteller und Seriennummer erkannt.
4. Die Inventarverwaltung liefert die vorhandene Gerätenummer oder vergibt eine neue.
5. Geräte im Status **Eingang** erhalten automatisch einen Kurzscan mit Modell, CPU,
RAM und Datenträgern.
6. Danach kann der Techniker das Gerät ausschalten oder die Inspektion starten.
7. Prüfstände und Ergebnisse werden als **Inspektion** synchronisiert. Offline
gespeicherte Ergebnisse bleiben in der Warteschlange und werden später erneut gesendet.
Die Oberfläche konzentriert sich auf **Gerät**, **Inspektion** und **Einstellungen**.
Reparaturen, Berichte und die Synchronisationswarteschlange sind über das Menü erreichbar.
## Funktionen
- automatische Hardware-ID und serverseitig vergebene Gerätenummer
- Schutz vor doppelten Datensätzen
- gespeichertes Werkstatt-WLAN mit geschütztem Passwort
- geräteabhängige Tests für Akku, Touchpad, Webcam und mehrere Laufwerke
- getrennte Ergebnisse für Hardwarefehler, nicht verfügbaren Test,
Einrichtungsproblem, nicht geprüft und nicht vorhanden
- Wiederaufnahme nach Neustart oder Stromunterbrechung
- Reparaturnotizen und erneute Prüfung betroffener Komponenten
- druckbarer Abschlussbericht mit Messwerten und Freigabe
- dauerhafte Offline-Warteschlange mit sicheren Wiederholungen
- zentrale Sprache, Testdauer und Grenzwerte
- verifizierbarer Build mit Versions- und Prüfsummenmanifest
## Projektaufbau
| Pfad | Inhalt |
| --- | --- |
| `gui/` | Bedienoberfläche und geführte Tests |
| `scanner/` | Hardwareerkennung und Messwerte |
| `controller/` | Geräteklassifizierung und Auswertung |
| `scripts/` | Build, Synchronisation und lokale Datenwerkzeuge |
| `live/` | Start und Vorbereitung des Livesystems |
| `tests/` | Python- und Rauchtests |
| `config.example/` | Beispiel für die Inventarverbindung |
Der Inventory-Server wird in einem eigenen Repository gepflegt. Dieses Repository
enthält ausschließlich den USB-Client.
## Entwicklung und Prüfung
Voraussetzungen sind Rust, Cargo und Python 3. Auf Arch Linux werden für den ISO-Bau
zusätzlich `archiso`, GRUB, Dateisystemwerkzeuge und `libarchive` benötigt.
```bash
bash scripts/build-release.sh
```
Der Befehl führt die Rust-, Python- und Rauchtests aus, baut alle drei Programme im
Release-Modus und erzeugt `build/release.json` mit Build-ID und SHA-256-Prüfsummen.
## ISO erstellen
Das Zielverzeichnis muss neu und leer sein:
```bash
bash scripts/build-iso.sh "$PWD/build/usb-aio-2.6.0"
```
Der Build prüft Profil, Binärdateien, Kernelpfade und die FAT32-Größengrenze. Ein
erfolgreicher Softwaretest ersetzt keinen Starttest auf echter UEFI- und BIOS-Hardware.
## USB-Stick installieren
Dieser Befehl löscht den ausgewählten Datenträger vollständig:
```bash
lsblk -o NAME,PATH,TYPE,SIZE,TRAN,MODEL,SERIAL,MOUNTPOINTS
sudo bash scripts/install-usb.sh /dev/sdX /absoluter/pfad/zur.iso
```
Das Installationsskript akzeptiert nur ein vollständiges USB-Blockgerät, lehnt den
Systemdatenträger ab und verlangt die exakte Bestätigung `ERASE /dev/sdX`.
## Konfiguration auf `INTAKE_DATA`
Die dauerhaften Dateien liegen auf der Datenpartition des Sticks:
```text
config/inventory.json
config/inventory-api-key
config/inventory-server.crt
config/workshop-wifi.json
config/workshop.json
```
API-Schlüssel und WLAN-Passwort gehören nie in Git. Ein privates CA-Zertifikat darf
nur als öffentliches Zertifikat gespeichert werden. Die Datenpartition ist nicht
verschlüsselt und muss wie ein Werkstatt-Zugang behandelt werden.
Weitere Einzelheiten stehen in [WERKSTATT-ABLAUF.md](WERKSTATT-ABLAUF.md).

92
WERKSTATT-ABLAUF.md Normal file
View File

@@ -0,0 +1,92 @@
# Werkstattablauf
## Geräteerkennung
Beim Start liest das Livesystem geschützte DMI-Felder einmal als `root` und stellt
sie der unprivilegierten Anwendung nur lesbar bereit. Bevorzugt wird eine gültige
System-UUID. Fehlt sie, wird eine gehashte Kombination aus Hersteller und
Seriennummer verwendet. Platzhalter wie `To be filled by O.E.M.` sind ungültig.
Die Hardware-ID wird an `POST /api/v1/devices/resolve/` gesendet. Der Server ordnet
sie einem vorhandenen Gerät zu oder vergibt atomar eine neue Gerätenummer. UUID und
Seriennummer können als Aliase dasselbe Gerät bezeichnen. Widersprüche werden zur
manuellen Prüfung abgelehnt.
## Automatische Erfassung
Für neue Geräte und Geräte in **Eingang** startet ein Kurzscan. Erfasst werden
Hersteller, Modell, CPU, installierter RAM sowie Größe und Art aller Laufwerke. Die
Daten werden revisionsgesichert über den Intake-Endpunkt geschrieben. Ein Gerät in
einem späteren Prozessschritt wird durch den automatischen Scan nicht überschrieben.
## Inspektion
Jeder Prüfschritt besitzt eine Aktion, eine verständliche Anleitung, ein sichtbares
Ergebnis und eine Wiederholungsmöglichkeit. Verfügbare Schritte umfassen Display,
Tastatur, Touchpad, WLAN, Lautsprecher, Mikrofon, Kamera, USB-Anschlüsse, Laden,
Temperatur, Stabilität und SMART-Daten jedes internen Laufwerks.
Ergebnisse unterscheiden:
- bestanden
- Hardwarefehler
- Test nicht verfügbar
- Einrichtungsproblem
- nicht geprüft
- nicht vorhanden
- Bestätigung erforderlich
Ein fehlendes Werkzeug oder eine fehlende Berechtigung wird niemals als
Hardwaredefekt gewertet. Optionale Hardware muss ausdrücklich als nicht vorhanden
bestätigt werden.
Beim Start einer Prüfung wird der Zustand **Inspektion** an den Server gesendet.
Zwischenstände und der Abschluss verwenden unveränderliche Anforderungs-IDs, damit
ein Verbindungsabbruch keine doppelten Einträge erzeugt. Ein Abschluss benötigt
einen Technikernamen und vollständig bewertete Prüfschritte.
## Unterbrechung und Offlinebetrieb
Der lokale Prüfstand wird atomar gespeichert. Nach Neustart öffnet die Anwendung
die letzte unvollständige Prüfung mit Messwerten und Notizen. Noch nicht bestätigte
Uploads liegen in `outbox/inspections/`; bestätigte Uploads werden nach
`outbox/sent/` verschoben. Beim nächsten Start werden auch gespeicherte Prüfungen
des zuvor bearbeiteten Computers wieder in die Warteschlange aufgenommen.
## Reparaturen und Bericht
Eine Reparatur enthält Bauteil, Tätigkeit, Ersatzteil, Techniker und Zeitpunkt. Der
vorherige Befund bleibt erhalten und der betroffene Test wird erneut geöffnet. Der
HTML-Abschlussbericht enthält Spezifikationen, Laufwerks- und Akkuzustand,
Messwerte, Mängel, Reparaturverlauf und technische Freigabe.
## WLAN und Inventarverbindung
Das Werkstatt-WLAN wird erst nach erfolgreicher Verbindung gespeichert. Die Datei
`config/workshop-wifi.json` ist nur für den Besitzer lesbar und kann in den
Einstellungen ersetzt oder gelöscht werden. Das Passwort wird über die
Standardeingabe an NetworkManager übergeben und erscheint nicht in Prozessargumenten.
Die Inventarverbindung benötigt:
```json
{"url": "https://inventory.example.org"}
```
Der API-Schlüssel liegt separat in `config/inventory-api-key`. Für einen privaten
HTTPS-Server kann `config/inventory-server.crt` die öffentliche CA enthalten.
Zertifikats- und Hostnamenprüfung bleiben aktiv.
## Abnahme eines neuen Builds
Vor dem Werkstatteinsatz sind mindestens diese Punkte auf echter Hardware zu prüfen:
1. UEFI- und BIOS-Start
2. persistente Daten nach Neustart
3. automatische Wiederverbindung mit dem WLAN
4. richtige Gerätezuordnung und Dublettenwarnung
5. Intake-Synchronisation ohne Überschreiben späterer Zustände
6. Ein- und Ausgabegeräte sowie jedes Laufwerk
7. Offline-Warteschlange und Wiederholung nach Verbindungsabbruch
8. druckbarer Bericht
9. kontrolliertes Herunterfahren

View File

@@ -0,0 +1,5 @@
{
"url": "https://inventory.example.org",
"device_type_id": 1,
"status_id": 1
}

7
controller/Cargo.lock generated Normal file
View File

@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "intake-controller"
version = "1.7.0"

5
controller/Cargo.toml Normal file
View File

@@ -0,0 +1,5 @@
[package]
name = "intake-controller"
version = "1.7.0"
edition = "2021"
[dependencies]

194
controller/src/extended.rs Normal file
View File

@@ -0,0 +1,194 @@
//! Optional technician workstations; no write access to internal devices.
use std::{fs, io::{self, Write}, path::Path, process::Command, time::{SystemTime, UNIX_EPOCH}};
fn ask(prompt: &str) -> String {
print!("{}", prompt);
let _ = io::stdout().flush();
let mut answer = String::new();
if io::stdin().read_line(&mut answer).is_err() { return String::new(); }
answer.trim().to_string()
}
fn read_trim(path: &str) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
fn output(exe: &str, args: &[&str]) -> Option<String> {
let r = Command::new(exe).args(args).output().ok()?;
if r.status.success() { Some(String::from_utf8_lossy(&r.stdout).trim().to_owned()) } else { None }
}
fn escape_json(s: &str) -> String {
let mut result = String::from("\"");
for c in s.chars() { match c {
'"' => result.push_str("\\\""), '\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"), '\r' => result.push_str("\\r"), '\t' => result.push_str("\\t"),
c if c.is_control() => result.push_str(&format!("\\u{:04x}", c as u32)),
c => result.push(c),
}}
result.push('"'); result
}
fn valid_asset(id: &str) -> bool {
!id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
fn normalize_hostname(model: &str, asset: &str) -> String {
let source = format!("{}-{}", model, asset).to_ascii_lowercase();
let mut result = String::new();
let mut last_dash = false;
for c in source.chars() {
if c.is_ascii_alphanumeric() { result.push(c); last_dash = false; }
else if !last_dash && !result.is_empty() { result.push('-'); last_dash = true; }
}
result.trim_matches('-').chars().take(63).collect::<String>().trim_end_matches('-').to_owned()
}
fn net_interfaces() -> Vec<(String, String, String, String)> {
let mut list = Vec::new();
if let Ok(entries) = fs::read_dir("/sys/class/net") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name == "lo" { continue; }
let base = format!("/sys/class/net/{name}");
let kind = if Path::new(&format!("{base}/wireless")).exists() { "Wi-Fi" } else { "LAN/other" };
let carrier = read_trim(&format!("{base}/carrier")).unwrap_or_else(|| "unknown".into());
let mac = read_trim(&format!("{base}/address")).unwrap_or_else(|| "unknown".into());
list.push((name, kind.to_string(), carrier, mac));
}
}
list.sort(); list
}
fn ethernet_connected() -> bool { net_interfaces().iter().any(|(_,kind,carrier,_)| kind == "LAN/other" && carrier == "1") }
fn memory_dmi() -> (String, String) {
let text = match output("dmidecode", &["-t", "memory"]) {
Some(s) => s, None => return ("UNKNOWN (dmidecode unavailable or permissions missing)".into(), "UNKNOWN".into()),
};
let mut slots = 0usize;
let mut occupied = 0usize;
let mut max_cap = String::from("UNKNOWN");
let mut in_device = false;
for line in text.lines() {
let t = line.trim();
if t == "Memory Device" { slots += 1; in_device = true; continue; }
if t == "Physical Memory Array" { in_device = false; continue; }
if t.starts_with("Maximum Capacity:") && max_cap == "UNKNOWN" { max_cap = t.trim_start_matches("Maximum Capacity:").trim().into(); }
if in_device && t.starts_with("Size:") && !t.contains("No Module Installed") && !t.ends_with("Unknown") { occupied += 1; in_device = false; }
}
let summary = if slots == 0 { "UNKNOWN".into() } else { format!("{occupied}/{slots} occupied; {} free", slots.saturating_sub(occupied)) };
(summary, max_cap)
}
fn display_info() -> String {
let mut displays = Vec::new();
if let Ok(entries) = fs::read_dir("/sys/class/drm") {
for entry in entries.flatten() {
let path = entry.path();
if !path.join("status").is_file() { continue; }
let status = fs::read_to_string(path.join("status")).unwrap_or_default();
if status.trim() != "connected" { continue; }
let name = entry.file_name().to_string_lossy().to_string();
let modes = fs::read_to_string(path.join("modes")).unwrap_or_default();
let mode = modes.lines().next().unwrap_or("resolution unknown");
let size = fs::read(path.join("edid")).ok().and_then(|e| {
if e.len() >= 23 && e[21] > 0 && e[22] > 0 {
let diag = ((e[21] as f64).powi(2) + (e[22] as f64).powi(2)).sqrt() / 2.54;
Some(format!("{diag:.1} inch (EDID estimate)"))
} else { None }
}).unwrap_or_else(|| "size UNKNOWN".into());
displays.push(format!("{name}: {mode}, {size}"));
}
}
if displays.is_empty() { "UNKNOWN (no active DRM connector)".into() } else { displays.join("; ") }
}
fn save(data: &Path, asset: &str, stem: &str, content: &str) -> Result<(),String> {
let folder = data.join("reports").join(asset).join("supplemental");
fs::create_dir_all(&folder).map_err(|e| e.to_string())?;
let path = folder.join(stem);
fs::write(&path, content).map_err(|e|e.to_string())?;
println!("Saved to {}", path.display());
Ok(())
}
pub fn device_details(data: &Path) {
let asset = ask("Asset ID / Geraetenummer: ");
if !valid_asset(&asset) { println!("Invalid asset ID"); return; }
let manufacturer = read_trim("/sys/class/dmi/id/sys_vendor").unwrap_or_else(|| "UNKNOWN".into());
let model = read_trim("/sys/class/dmi/id/product_name").unwrap_or_else(|| "UNKNOWN".into());
let uuid = read_trim("/sys/class/dmi/id/product_uuid").unwrap_or_else(|| "UNKNOWN".into());
let cpu = read_trim("/proc/cpuinfo").and_then(|s|s.lines().find_map(|line|line.strip_prefix("model name").and_then(|v|v.split_once(':').map(|(_,v)|v.trim().to_owned())))).unwrap_or_else(|| "UNKNOWN".into());
let (slots, max_ram) = memory_dmi();
let display = display_info();
let host = normalize_hostname(&model, &asset);
let connections = net_interfaces();
println!("\nManufacturer: {manufacturer}\nModel: {model}\nCPU: {cpu}\nUUID: {uuid}\nRAM slots: {slots}\nMax RAM (BIOS estimate): {max_ram}\nDisplay: {display}\nProposed hostname: {host}");
for (name,kind,carrier,mac) in &connections { println!("Interface {name}: {kind}, carrier={carrier}, MAC={mac}"); }
println!("Storage sizes and device type are collected by the main scanner; don't include the boot USB.");
let mut json = format!("{{\"asset_id\":{},\"manufacturer\":{},\"model\":{},\"cpu\":{},\"uuid\":{},\"ram_slots\":{},\"ram_max\":{},\"display\":{},\"proposed_hostname\":{},\"network\":[",escape_json(&asset),escape_json(&manufacturer),escape_json(&model),escape_json(&cpu),escape_json(&uuid),escape_json(&slots),escape_json(&max_ram),escape_json(&display),escape_json(&host));
for (i,(name,kind,carrier,mac)) in connections.iter().enumerate() {
if i>0 { json.push(','); }
json.push_str(&format!("{{\"interface\":{},\"type\":{},\"carrier\":{},\"mac\":{}}}",escape_json(name),escape_json(kind),escape_json(carrier),escape_json(mac)));
}
json.push_str("]}\n");
if let Err(e)=save(data,&asset,"hardware-details.json",&json){ eprintln!("Save failed: {e}"); }
}
fn check_prompt(label: &str) -> &'static str {
println!("{label}");
loop { match ask("Result [p]ass / [f]ail / [u]ntested / [n]ot applicable: ").to_lowercase().as_str() {
"p" => return "PASS", "f" => return "FAIL", "u" | "" => return "NOT_TESTED", "n" => return "NOT_APPLICABLE", _ => println!("Use p/f/u/n"),
}}
}
pub fn guided_tests(data: &Path) {
let asset=ask("Asset ID: "); if !valid_asset(&asset) { println!("Invalid asset ID"); return; }
println!("\nNETWORK PRECHECK: Before Wi-Fi test, unplug Ethernet if one is connected.");
if ethernet_connected() {
println!("WARNING: Wired network link detected. Unplug LAN cable before Wi-Fi test.");
let _=ask("After unplugging LAN press Enter to recheck...");
if ethernet_connected() { println!("LAN link remains active: Wi-Fi connectivity cannot be confirmed until disconnected."); }
} else { println!("No active wired link detected. Wireless testing may proceed."); }
let wifi = if ethernet_connected() { "NOT_TESTED" } else {
println!("Wi-Fi interfaces: {:?}",net_interfaces().iter().filter(|x|x.1=="Wi-Fi").map(|x|&x.0).collect::<Vec<_>>());
println!("Connect using your network manager and verify actual traffic over Wi-Fi. Interface detection alone is not enough.");
check_prompt("Wi-Fi connection and data transfer verified?")
};
println!("\nAUDIO: We'll attempt a stereo test sound if speaker-test exists. Confirm left/right audibly.");
if ask("Run short speaker test? [y/N] ").eq_ignore_ascii_case("y") {
let result=Command::new("speaker-test").args(["-c","2","-t","wav","-l","1"]).status();
println!("speaker-test outcome: {:?} (not proof that sound was audible)",result);
}
let audio=check_prompt("Speakers: both channels audible, without distortion?");
println!("\nMICROPHONE: make a short recording then play it back, confirming sound clarity.");
let mic=check_prompt("Microphone recorded and replayed clearly?");
println!("\nCAMERA: look for /dev/video*, and preview live picture if a desktop is running.");
if let Ok(devices)=fs::read_dir("/dev") {
for dev in devices.flatten() { let n=dev.file_name().to_string_lossy().to_string(); if n.starts_with("video") {println!("Found /dev/{n}");} }
}
let webcam=check_prompt("Webcam picture inspected and working?");
let mut json=format!("{{\"asset_id\":{},\"timestamp\":{},\"wifi\":{},\"audio\":{},\"microphone\":{},\"webcam\":{}}}\n",escape_json(&asset),SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),escape_json(wifi),escape_json(audio),escape_json(mic),escape_json(webcam));
if let Err(e)=save(data,&asset,"guided-tests.json",&json){eprintln!("Save failed: {e}");}
json.clear();
}
pub fn maintenance_guidance() {
println!("\nMAINTENANCE OF INSTALLED OPERATING SYSTEM");
println!("The live USB does NOT automatically run updates on an internal disk. Only run maintenance after booting the installed system and confirming its Linux distribution.");
println!("For Debian/Ubuntu/Mint installed systems:");
println!(" sudo apt update && sudo apt upgrade && sudo apt autoremove && sudo apt clean");
println!("For Arch Linux installed systems:");
println!(" sudo pacman -Syu");
println!("Do not chroot or rename another machine's OS without selecting and validating its target disk. Proposed hostname is stored in hardware-details.json for controlled provisioning.");
let _=ask("Press Enter to return to menu...");
}
#[cfg(test)] mod tests {
use super::*;
#[test] fn valid_asset_ids(){assert!(valid_asset("PC-0042")); assert!(!valid_asset("../evil"));}
#[test] fn hostnames(){assert_eq!(normalize_hostname("EliteBook 745 G6","PC-0001"),"elitebook-745-g6-pc-0001");}
#[test] fn escape(){assert_eq!(escape_json("a\"b"),"\"a\\\"b\"");}
}
/// Read-only CPU stress plus allocated userspace memory checks. Not a full RAM test.
pub fn stress_test(data: &Path) {
let asset=ask("Asset ID: "); if !valid_asset(&asset) { println!("Invalid asset ID"); return; }
println!("\nThis uses stress-ng (120 seconds) and memtester on up to 50% of MemAvailable, capped at 8 GiB.");
println!("Do not use on a machine with unstable cooling. This cannot test all physical RAM or prove absence of faults.");
if !ask("Run stress tests now? Type YES: ").eq("YES") {println!("Cancelled");return;}
let cpu=Command::new("stress-ng").args(["--cpu","0","--timeout","120s","--metrics-brief"]).status();
println!("CPU stress result: {cpu:?}");
let mem_kib=read_trim("/proc/meminfo").and_then(|t|t.lines().find(|l|l.starts_with("MemAvailable:")).and_then(|line| line.split_whitespace().nth(1)).and_then(|n|n.parse::<u64>().ok())).unwrap_or(0);
let mem_mib=std::cmp::min(mem_kib/2048,8192);
let ram=if mem_mib>=128 { Command::new("memtester").args([&format!("{mem_mib}M"),"1"]).status() } else { println!("Not enough free RAM for safe test");return; };
println!("RAM stress result: {ram:?}");
let status=|r: &io::Result<std::process::ExitStatus>|match r {Ok(s) if s.success()=>"PASS",Ok(_)=>"FAIL",Err(_)=>"UNAVAILABLE"};
let report=format!("{{\"asset_id\":{},\"cpu_stress\":{},\"ram_stress\":{},\"ram_test_mib\":{},\"coverage\":\"PARTIAL_USERSPACE\"}}\n",escape_json(&asset),escape_json(status(&cpu)),escape_json(status(&ram)),mem_mib);
if let Err(e)=save(data,&asset,"stress-test.json",&report){eprintln!("Save failed: {e}");}
}

28
controller/src/main.rs Normal file
View File

@@ -0,0 +1,28 @@
mod extended;
use std::{env, fs, io::{self, Write}, path::{Path, PathBuf}, process::Command, time::{SystemTime, UNIX_EPOCH}};
const DATA: &str = "/mnt/intake-data";
fn prompt(q: &str) -> String { print!("{}",q); let _=io::stdout().flush(); let mut s=String::new(); let _=io::stdin().read_line(&mut s); s.trim().to_string() }
fn run(program:&str,args:&[&str])->Result<(),String>{let st=Command::new(program).args(args).status().map_err(|e|format!("{}: {}",program,e))?;if st.success(){Ok(())}else{Err(format!("{} exited with {}",program,st))}}
fn capture(program:&str,args:&[&str])->Result<String,String>{let o=Command::new(program).args(args).output().map_err(|e|e.to_string())?;if !o.status.success(){return Err(String::from_utf8_lossy(&o.stderr).to_string())}Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())}
fn sqlquote(s:&str)->String{format!("'{}'",s.replace('\'',"''"))}
fn db_path(data:&Path)->PathBuf{data.join("database/intake.sqlite3")}
fn sql(data:&Path,statement:&str)->Result<String,String>{capture("sqlite3",&[db_path(data).to_str().ok_or("Invalid DB path")?,statement])}
fn init(data:&Path)->Result<(),String>{for part in ["database","reports","checklists","config"]{fs::create_dir_all(data.join(part)).map_err(|e|e.to_string())?;}let probe=data.join(".write-test");fs::write(&probe,b"ok").map_err(|e|format!("Persistent partition not writable: {}",e))?;let _=fs::remove_file(probe);sql(data,"PRAGMA journal_mode=DELETE; CREATE TABLE IF NOT EXISTS scans (id INTEGER PRIMARY KEY AUTOINCREMENT, asset_id TEXT NOT NULL, timestamp INTEGER NOT NULL, report_path TEXT NOT NULL UNIQUE, inspection_path TEXT, inspection_status TEXT NOT NULL DEFAULT 'NOT_TESTED', sync_status TEXT NOT NULL DEFAULT 'PENDING', synced_inventory_id TEXT);")?;Ok(())}
fn valid_id(s:&str)->bool{!s.is_empty()&&s.len()<=64&&s.bytes().all(|b|b.is_ascii_alphanumeric()||b==b'-'||b==b'_')}
fn latest(data:&Path,id:&str)->Option<PathBuf>{let root=data.join("reports").join(id);let mut matches=Vec::new();for d in fs::read_dir(root).ok()?.flatten(){let p=d.path().join("report.json");if p.is_file(){let mt=p.metadata().ok()?.modified().ok()?;matches.push((mt,p));}}matches.sort_by_key(|(m,_)|*m);matches.pop().map(|(_,p)|p)}
fn get_time()->u64{SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()}
fn scan(data:&Path,quick:bool){let id=prompt("Asset ID: ");if !valid_id(&id){println!("Invalid ID (letters, digits, underscore and hyphen only)");return;}let out=data.join("reports");let outstr=out.to_string_lossy().to_string();let mut cmd=Command::new("/usr/local/bin/pc-intake");cmd.args(["--asset-id",&id,"--output",&outstr]);if quick{cmd.arg("--quick");}else{cmd.arg("--compile");}let checklist=data.join("checklists").join(format!("{}.txt",id));if checklist.exists(){cmd.args(["--checklist",&checklist.to_string_lossy()]);}println!("Running {} scan...",if quick{"quick"}else{"full"});match cmd.status(){Ok(status) if status.success()=>{if let Some(report)=latest(data,&id){let result=sql(data,&format!("INSERT OR IGNORE INTO scans(asset_id,timestamp,report_path) VALUES({},{},{});",sqlquote(&id),get_time(),sqlquote(&report.to_string_lossy())));match result{Ok(_)=>{println!("Saved: {}",report.display());println!("Inventory sync: PENDING (not uploaded)");},Err(e)=>println!("Database write failed: {}. JSON report remains saved.",e)}}else{println!("Scanner exited successfully, but no report found");}},Ok(status)=>println!("Scanner failed: {}",status),Err(e)=>println!("Could not launch scanner: {}",e)}}
const CHECKS:&[(&str,&str)]=&[("display","Show colors/brightness and inspect for dead pixels"),("keyboard","Check every key, including modifiers and function keys"),("touchpad","Verify movement, taps, buttons and scrolling; N/A for desktop"),("wifi","Connect to a network and open a local or remote test service"),("audio","Play left/right sound and check for distortion"),("microphone","Record voice and play it back"),("webcam","Preview image and verify focus and quality; N/A if absent"),("usb_ports","Insert a known-good test device in each physical port"),("charging","Verify AC adapter, charging status, battery runtime; N/A for desktop"),("thermals","Verify fans, heat and sustained load"),("stability","Check for crashes, unexpected resets or kernel errors")];
fn technician(data:&Path){let id=prompt("Asset ID for inspection: ");if !valid_id(&id){println!("Invalid ID");return;}let mut rows=Vec::new();for (key,desc) in CHECKS{println!("\n{}: {}",key,desc);if *key=="audio"{println!("Optional speaker test (if installed): speaker-test -c 2 -t wav");}if *key=="webcam"{println!("Optional camera detection: ls /dev/video*; preview requires graphical session");}loop{let ans=prompt("[p]ass / [f]ail / [u]ntested / [n]ot applicable: ").to_lowercase();if let Some(st)=match ans.as_str(){"p"=>Some("PASS"),"f"=>Some("FAIL"),"u"|""=>Some("NOT_TESTED"),"n"=>Some("NOT_APPLICABLE"),_=>None}{rows.push((key.to_string(),st.to_string()));break;}println!("Enter p, f, u or n");}}
let target=data.join("checklists").join(format!("{}.txt",id));let mut raw=String::new();for (k,v) in &rows{raw.push_str(&format!("{}={}\n",k,v));}if let Err(e)=fs::write(&target,raw){println!("Could not save checklist: {}",e);return;}let fail=rows.iter().any(|(_,s)|s=="FAIL");let unknown=rows.iter().any(|(_,s)|s=="NOT_TESTED");let verdict=if fail{"FAIL"}else if unknown{"INCOMPLETE"}else{"COMPLETED"};let _=sql(data,&format!("UPDATE scans SET inspection_path={},inspection_status={} WHERE id=(SELECT MAX(id) FROM scans WHERE asset_id={});",sqlquote(&target.to_string_lossy()),sqlquote(verdict),sqlquote(&id)));println!("Inspection {}. Saved {}; re-scan to embed checklist in hardware report.",verdict,target.display());}
fn recent(data: &Path) {
let query = "SELECT id || ' | ' || asset_id || ' | ' || inspection_status || ' | ' || sync_status || ' | ' || report_path FROM scans ORDER BY id DESC LIMIT 20;";
match sql(data, query) {
Ok(rows) if rows.is_empty() => println!("No scans yet."),
Ok(rows) => println!("{}", rows),
Err(err) => println!("Database error: {}", err),
}
}
fn help(){println!("PC Intake AIO v1.7.0 — offline-first technician controller");println!("Usage: intake-controller [--data PATH] [--self-check]");}
fn main(){let args:Vec<String>=env::args().collect();if args.iter().any(|x|x=="--help"){help();return;}if args.iter().any(|x|x=="--self-check"){for tool in ["sqlite3","smartctl","ffmpeg","mpv","glmark2","stress-ng","memtester","speaker-test"]{println!("{}: {}",tool,if Command::new("sh").args(["-c",&format!("command -v {} >/dev/null",tool)]).status().map(|s|s.success()).unwrap_or(false){"FOUND"}else{"MISSING"});}return;}let data=if let Some(p)=args.iter().position(|x|x=="--data"){PathBuf::from(args.get(p+1).unwrap_or(&DATA.to_owned()))}else{PathBuf::from(DATA)};if let Err(e)=init(&data){eprintln!("FATAL: {} — refusing to scan without persistent storage",e);std::process::exit(1)}loop{println!("\n==== PC INTAKE AIO v1.7.0 ====\nStorage: {}\n1 Full scan and local queue\n2 Quick inventory\n3 Guided technician checklist\n4 Recent scans and sync states\n5 Check diagnostic tools\n6 Safely power off\n7 Hardware details (UUID / MAC / RAM slots / screen)\n8 Guided audio / webcam / Wi-Fi test\n9 Installed-system maintenance instructions\n10 CPU and RAM stress test (opt-in)\n",data.display());match prompt("Select: ").as_str(){"1"=>scan(&data,false),"2"=>scan(&data,true),"3"=>technician(&data),"4"=>recent(&data),"5"=>{let _=Command::new(env::current_exe().unwrap_or_else(|_|PathBuf::from("intake-controller"))).arg("--self-check").status();},"6"=>{let _=run("sync",&[]);let _=run("systemctl",&["poweroff"]);break;},"7"=>extended::device_details(&data),"8"=>extended::guided_tests(&data),"9"=>extended::maintenance_guidance(),"10"=>extended::stress_test(&data),_=>println!("Unknown selection")}}}
#[cfg(test)]mod tests{use super::*;#[test]fn id_validation(){assert!(valid_id("PC-0123"));assert!(!valid_id("../../evil"));}#[test]fn sql_escape(){assert_eq!(sqlquote("O'Reilly"),"'O''Reilly'");}}

3267
gui/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

9
gui/Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "intake-gui"
version = "2.6.0"
edition = "2021"
[dependencies]
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

39
gui/build.rs Normal file
View File

@@ -0,0 +1,39 @@
use std::{env, fs, path::Path};
fn main() {
println!("cargo:rerun-if-env-changed=CSR_BUILD_ID");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=../scripts/inventory-sync.py");
println!("cargo:rerun-if-changed=Cargo.lock");
fn hash_dir(path: &Path, hash: &mut u64) {
let mut files: Vec<_> = fs::read_dir(path)
.unwrap()
.flatten()
.map(|e| e.path())
.collect();
files.sort();
for p in files {
if p.is_dir() {
hash_dir(&p, hash);
} else {
for b in fs::read(p).unwrap() {
*hash = (*hash ^ b as u64).wrapping_mul(1099511628211);
}
}
}
}
let mut hash = 14695981039346656037u64;
hash_dir(Path::new("src"), &mut hash);
for file in [
"../scripts/inventory-sync.py",
"Cargo.toml",
"Cargo.lock",
"build.rs",
] {
for b in fs::read(file).unwrap() {
hash = (hash ^ b as u64).wrapping_mul(1099511628211);
}
}
let id = env::var("CSR_BUILD_ID")
.unwrap_or_else(|_| format!("{}-{hash:016x}", env::var("CARGO_PKG_VERSION").unwrap()));
println!("cargo:rustc-env=CSR_BUILD_ID={id}");
}

483
gui/src/diagnostics.rs Normal file
View File

@@ -0,0 +1,483 @@
//! Read-only or temporary hardware diagnostics. Never mark a technician check PASS automatically.
use std::{
fs,
path::Path,
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
fn available(tool: &str) -> bool {
Command::new("sh")
.args(["-c", "command -v \"$1\" >/dev/null 2>&1", "sh", tool])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn exec(tool: &str, args: &[&str]) -> Result<String, String> {
let out = Command::new("timeout")
.args(["--kill-after=3s", "150s", tool])
.args(args)
.output()
.map_err(|e| format!("{tool}: {e}"))?;
let log = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
if out.status.success() {
Ok(log)
} else {
Err(format!("{tool} ({})\n{log}", out.status))
}
}
fn temps() -> Vec<(String, f64)> {
let mut values = Vec::new();
let Ok(chips) = fs::read_dir("/sys/class/hwmon") else {
return values;
};
for entry in chips.flatten() {
let base = entry.path();
let chip = fs::read_to_string(base.join("name"))
.unwrap_or_default()
.trim()
.to_owned();
let Ok(files) = fs::read_dir(&base) else {
continue;
};
for file in files.flatten() {
let name = file.file_name().to_string_lossy().to_string();
if !name.starts_with("temp") || !name.ends_with("_input") {
continue;
}
let Ok(raw) = fs::read_to_string(file.path()) else {
continue;
};
let Ok(milli) = raw.trim().parse::<f64>() else {
continue;
};
let c = milli / 1000.0;
if !(-20.0..=150.0).contains(&c) {
continue;
}
values.push((format!("{chip}:{name}"), c));
}
}
values
}
fn thermal(settings: &crate::workflow::Settings) -> (bool, String) {
if !available("stress-ng") {
return (false, "UNAVAILABLE: stress-ng fehlt im Live-System".into());
}
let baseline = temps();
let start = Instant::now();
let mut child = match Command::new("stress-ng")
.args([
"--cpu",
"0",
"--timeout",
&format!("{}s", settings.stress_seconds),
"--metrics-brief",
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => return (false, format!("stress-ng konnte nicht starten: {e}")),
};
let mut samples = 0usize;
let mut peak: Option<f64> = None;
loop {
for (_, t) in temps() {
peak = Some(peak.map_or(t, |p| p.max(t)));
samples += 1;
}
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {}
Err(e) => return (false, format!("Prozessprüfung fehlgeschlagen: {e}")),
}
if start.elapsed() > Duration::from_secs(settings.stress_seconds + 15) {
let _ = child.kill();
break;
}
thread::sleep(Duration::from_secs(2));
}
let output = match child.wait_with_output() {
Ok(o) => o,
Err(e) => return (false, format!("Ausgabe fehlt: {e}")),
};
let mut log = format!(
"Dauer: {:.0}s\nSensorwerte zu Beginn: {:?}\nMesspunkte: {}\nMaximal beobachtet: {}\n{}",
start.elapsed().as_secs_f64(),
baseline,
samples,
peak.map(|p| format!("{p:.1} °C"))
.unwrap_or("UNAVAILABLE".into()),
String::from_utf8_lossy(&output.stderr)
);
if let Some(p) = peak {
log.push_str(&format!("\nPEAK_TEMPERATURE_C={p}"));
}
if let Some(p) = peak {
if p >= settings.max_temperature_c {
log.push_str(
"\nWARNUNG: Werkstatt-Temperaturgrenze überschritten; manuelle Prüfung nötig.",
);
}
}
if samples == 0 {
log.push_str("\nWARNUNG: Kein Temperatursensor auslesbar.");
}
(
output.status.success()
&& samples > 0
&& peak
.map(|p| p < settings.max_temperature_c)
.unwrap_or(false)
&& start.elapsed() >= Duration::from_secs(settings.stress_seconds.saturating_sub(2)),
log,
)
}
fn stability(settings: &crate::workflow::Settings) -> (bool, String) {
if !available("stress-ng") {
return (false, "UNAVAILABLE: stress-ng fehlt".into());
}
let mem_kib = fs::read_to_string("/proc/meminfo")
.ok()
.and_then(|s| {
s.lines()
.find(|l| l.starts_with("MemAvailable:"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|v| v.parse::<u64>().ok())
})
.unwrap_or(0);
// Conservatively cap test allocation; never allocate all free RAM in a live USB GUI.
if mem_kib < 256 * 1024 {
return (
false,
"Insufficient available RAM for a bounded stress test / Zu wenig freier RAM".into(),
);
}
let ram_mib = ((mem_kib / 1024) / 4).clamp(128, 2048);
let mut child = match Command::new("stress-ng")
.args([
"--cpu",
"0",
"--vm",
"1",
"--vm-bytes",
&format!("{ram_mib}M"),
"--verify",
"--timeout",
&format!("{}s", settings.stress_seconds),
"--metrics-brief",
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => return (false, format!("Start fehlgeschlagen: {e}")),
};
let start = Instant::now();
let mut peak: Option<f64> = None;
loop {
for (_, v) in temps() {
peak = Some(peak.map_or(v, |p| p.max(v)));
}
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {}
Err(e) => return (false, format!("Prozessfehler: {e}")),
}
if start.elapsed() > Duration::from_secs(settings.stress_seconds + 15) {
let _ = child.kill();
break;
}
thread::sleep(Duration::from_secs(2));
}
match child.wait_with_output(){Ok(o)=>(o.status.success() && peak.map(|p|p<settings.max_temperature_c).unwrap_or(true) && start.elapsed()>=Duration::from_secs(settings.stress_seconds.saturating_sub(2)),format!("CPU-/RAM-Test {:.0}s, RAM-Verbrauch ca. {} MiB, Temp-Spitze {}\nPEAK_TEMPERATURE_C={}\n{}",start.elapsed().as_secs_f64(),ram_mib,peak.map(|p|format!("{p:.1}°C")).unwrap_or("UNKNOWN".into()),peak.map(|p|p.to_string()).unwrap_or_default(),String::from_utf8_lossy(&o.stderr))),Err(e)=>(false,format!("Ausgabe fehlgeschlagen: {e}"))}
}
fn microphone(settings: &crate::workflow::Settings) -> (bool, String) {
if !available("arecord") || !available("aplay") {
return (
false,
"UNAVAILABLE: alsa-utils / arecord / aplay fehlen".into(),
);
}
let path = format!(
"/tmp/csr-audio-{}-{}.wav",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let record = exec(
"arecord",
&[
"-q",
"-d",
&settings.recording_seconds.to_string(),
"-f",
"S16_LE",
"-r",
"16000",
"-c",
"1",
&path,
],
);
let result = match record {
Err(e) => (false, format!("Mikrofonaufnahme fehlgeschlagen:\n{e}")),
Ok(_) => match exec("aplay", &["-q", &path]) {
Ok(_) => (
true,
format!(
"{} Sekunden aufgenommen und abgespielt. Bitte Tonqualität bestätigen.",
settings.recording_seconds
),
),
Err(e) => (false, format!("Wiedergabe fehlgeschlagen: {e}")),
},
};
if Path::new(&path).exists() {
let _ = fs::remove_file(&path);
}
result
}
fn camera(settings: &crate::workflow::Settings) -> (bool, String) {
if !Path::new("/dev/video0").exists() {
return (false, "UNAVAILABLE: keine Kamera unter /dev/video0".into());
}
if !available("ffplay") {
return (false, "UNAVAILABLE: ffplay fehlt".into());
}
let out = exec(
"timeout",
&[
&format!("{}s", settings.camera_seconds),
"ffplay",
"-hide_banner",
"-loglevel",
"error",
"-f",
"video4linux2",
"-i",
"/dev/video0",
],
);
match out {
Ok(s) => (
true,
format!("Vorschau beendet. Sichtprüfung erforderlich.\n{s}"),
),
Err(s) => {
// timeout exits 124 after a normal 25s bounded preview.
if s.contains("124") {
(
true,
format!(
"{} Sekunden Vorschau beendet. Sichtprüfung erforderlich.",
settings.camera_seconds
),
)
} else {
(false, s)
}
}
}
}
fn run_legacy(tool: &str, settings: &crate::workflow::Settings) -> (bool, String) {
match tool {
"audio" => match exec("speaker-test", &["-c", "2", "-t", "wav", "-l", "1"]) {
Ok(s) => (
true,
format!("Linker/rechter Testkanal ausgegeben. Bitte Hörtest bestätigen.\n{s}"),
),
Err(s) => (false, s),
},
"microphone" => microphone(settings),
"camera" => camera(settings),
"thermals" => thermal(settings),
"stability" => stability(settings),
"wifi" => {
let service = exec("nmcli", &["general", "status"]);
match service {
Err(s) => (false, format!("NetworkManager nicht bereit: {s}")),
Ok(status) => {
let dev = exec("nmcli", &["device", "status"]).unwrap_or_else(|e| e);
let scan = exec(
"nmcli",
&["-f", "IN-USE,SSID,SIGNAL", "device", "wifi", "list"],
)
.unwrap_or_else(|e| e);
(true,format!("Status:\n{status}\nGeräte:\n{dev}\nNetze:\n{scan}\nLAN trennen und WLAN-Verbindung aktiv testen; Sichtbarkeit ist noch kein PASS."))
}
}
}
"video" => (
true,
"Kameratest bitte über Kameravorschau durchführen.".into(),
),
_ => (false, "Unbekannter Diagnosetest".into()),
}
}
#[derive(Clone, Debug)]
pub struct Diagnostic {
pub outcome: crate::workflow::Outcome,
pub text: String,
}
pub fn run(tool: &str, settings: &crate::workflow::Settings) -> Diagnostic {
use crate::workflow::Outcome;
let required: &[&str] = match tool {
"audio" => &["speaker-test"],
"microphone" => &["arecord", "aplay"],
"webcam" => &["ffplay", "timeout"],
"thermals" | "stability" => &["stress-ng"],
"usb_ports" => &["lsusb"],
_ => &[],
};
if !available("timeout") {
return Diagnostic {
outcome: Outcome::Unavailable,
text: "timeout tool missing / fehlt".into(),
};
}
let missing: Vec<_> = required.iter().filter(|p| !available(p)).copied().collect();
if !missing.is_empty() {
return Diagnostic {
outcome: Outcome::Unavailable,
text: format!("Missing tools / Fehlende Werkzeuge: {}", missing.join(", ")),
};
}
if let Some(disk) = tool.strip_prefix("disk:") {
if !disk
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Diagnostic {
outcome: Outcome::SetupProblem,
text: "Invalid disk name".into(),
};
}
if !available("smartctl") {
return Diagnostic {
outcome: Outcome::Unavailable,
text: "smartctl missing / fehlt".into(),
};
}
return match Command::new("timeout")
.args(["--kill-after=3s", "30s", "smartctl"])
.args(["-j", "-H", "-A", &format!("/dev/{disk}")])
.output()
{
Ok(o) => {
let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap_or_default();
let outcome = match v
.pointer("/smart_status/passed")
.and_then(serde_json::Value::as_bool)
{
Some(false) => Outcome::HardwareFailure,
Some(true) => Outcome::NeedsReview,
None => Outcome::SetupProblem,
};
Diagnostic {
outcome,
text: format!(
"{}\n{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
),
}
}
Err(e) => Diagnostic {
outcome: Outcome::SetupProblem,
text: e.to_string(),
},
};
}
if tool == "charging" {
let mut lines = Vec::new();
if let Ok(entries) = fs::read_dir("/sys/class/power_supply") {
for e in entries.flatten() {
let p = e.path();
let read = |k: &str| {
fs::read_to_string(p.join(k))
.unwrap_or_default()
.trim()
.to_owned()
};
lines.push(format!("{}: type={} status={} charge={}% online={} energy_full={} energy_full_design={} charge_full={} charge_full_design={}",e.file_name().to_string_lossy(),read("type"),read("status"),read("capacity"),read("online"),read("energy_full"),read("energy_full_design"),read("charge_full"),read("charge_full_design")));
}
}
return Diagnostic {
outcome: if lines.is_empty() {
Outcome::Unavailable
} else {
Outcome::NeedsReview
},
text: if lines.is_empty() {
"No power supply data / Keine Akkudaten".into()
} else {
lines.join("\n")
},
};
}
if tool == "usb_ports" {
return match exec("lsusb", &[]) {
Ok(text) => Diagnostic {
outcome: Outcome::NeedsReview,
text,
},
Err(text) => Diagnostic {
outcome: Outcome::SetupProblem,
text,
},
};
}
let (ok, text) = run_legacy(if tool == "webcam" { "camera" } else { tool }, settings);
// Command failures are setup problems, never proof of a broken component.
Diagnostic {
outcome: if text.starts_with("UNAVAILABLE")
|| (tool == "thermals" && text.contains("Messpunkte: 0"))
{
Outcome::Unavailable
} else if peak_temperature(&text)
.map(|v| v >= settings.max_temperature_c)
.unwrap_or(false)
{
Outcome::NeedsReview
} else if ok {
Outcome::NeedsReview
} else {
Outcome::SetupProblem
},
text,
}
}
pub fn peak_temperature(text: &str) -> Option<f64> {
text.lines().find_map(|l| {
l.strip_prefix("PEAK_TEMPERATURE_C=")
.and_then(|s| s.parse::<f64>().ok())
.filter(|v| v.is_finite())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn peak_is_machine_readable_and_finite() {
assert_eq!(
peak_temperature("log\nPEAK_TEMPERATURE_C=94.5\nend"),
Some(94.5)
);
assert_eq!(peak_temperature("PEAK_TEMPERATURE_C=NaN"), None);
}
}

72
gui/src/inventory.rs Normal file
View File

@@ -0,0 +1,72 @@
use serde::Deserialize;
use std::{path::Path, process::Command};
const BRIDGE: &str = include_str!("../../scripts/inventory-sync.py");
#[derive(Clone, Debug, Deserialize)]
pub struct QueueEntry {
pub asset_id: String,
pub inventory_number: Option<u64>,
pub status: String,
pub attempts: u64,
pub next_attempt: u64,
pub error: String,
pub report_path: String,
}
fn call(data: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new("python3")
.args(["-c", BRIDGE, "--data"])
.arg(data)
.args(args)
.output()
.map_err(|e| e.to_string())?;
let log = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
if out.status.success() {
Ok(log)
} else {
Err(log)
}
}
pub fn queue(data: &Path) -> Result<Vec<QueueEntry>, String> {
serde_json::from_str(&call(data, &["--list"])?).map_err(|e| e.to_string())
}
pub fn sync(data: &Path, retry: bool) -> Result<String, String> {
call(data, if retry { &["--retry"] } else { &[] })
}
pub fn check(data: &Path) -> Result<String, String> {
call(data, &["--check"])
}
#[derive(Deserialize)]
pub struct HardwareIdentity {
pub hwid: String,
pub local_id: String,
}
pub fn identify(identity: &crate::workflow::Identity) -> Result<HardwareIdentity, String> {
let report = serde_json::json!({"system_uuid": identity.uuid, "serial_number": identity.serial,
"manufacturer": identity.manufacturer});
let output = Command::new("python3")
.args(["-c", BRIDGE, "--identity-json", &report.to_string()])
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_owned());
}
serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())
}
pub fn station(
data: &Path,
action: &str,
report: Option<&Path>,
) -> Result<serde_json::Value, String> {
let mut args = vec!["--station", action];
let path = report.map(|p| p.to_string_lossy().into_owned());
if let Some(p) = &path {
args.extend(["--report", p]);
}
serde_json::from_str(&call(data, &args)?).map_err(|e| e.to_string())
}

1936
gui/src/main.rs Normal file

File diff suppressed because it is too large Load Diff

187
gui/src/report.rs Normal file
View File

@@ -0,0 +1,187 @@
use crate::workflow::{self, Inspection};
use serde_json::Value;
use std::path::{Path, PathBuf};
fn escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn value(v: &Value, k: &str) -> String {
escape(&match &v[k] {
Value::String(s) => s.clone(),
Value::Null => "Unknown / Unbekannt".into(),
v => v.to_string(),
})
}
pub fn html(s: &Inspection) -> String {
let en = s.settings.language == "en";
let title = if en {
"Inspection report"
} else {
"Prüfbericht"
};
let mut h=format!("<!doctype html><html lang='{}'><head><meta charset='utf-8'><title>{} {}</title><style>body{{font:14px system-ui;margin:32px;color:#182330}}h1,h2{{color:#153f46}}table{{border-collapse:collapse;width:100%;margin-bottom:20px}}th,td{{text-align:left;vertical-align:top;border-bottom:1px solid #ccd;padding:8px;overflow-wrap:anywhere}}pre{{white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px}}.banner{{padding:12px;background:#eff5f5}}@media print{{body{{margin:0}}button{{display:none}}thead{{display:table-header-group}}tr{{break-inside:avoid}}h2{{break-after:avoid}}}}@page{{size:A4;margin:16mm}}</style></head><body><button onclick='window.print()'>Print / Drucken</button><h1>{title}: {}</h1><p>Build: {} · Revision: {} · UTC Unix: {}</p>",if en{"en"}else{"de"},title,escape(&s.asset),escape(&s.asset),escape(&s.build),s.revision,s.updated);
h.push_str(&format!(
"<p>Report generator / Berichterstellung: {}</p>",
escape(env!("CSR_BUILD_ID"))
));
let approved = s
.approval
.as_ref()
.filter(|a| a.revision == s.revision && s.blockers().is_empty());
h.push_str(&format!(
"<div class='banner'><strong>{}</strong></div>",
if approved.is_some() {
"APPROVED / FREIGEGEBEN"
} else {
"NOT APPROVED / NICHT FREIGEGEBEN"
}
));
if let Some(a) = approved {
h.push_str(&format!(
"<p>Technician / Techniker: {} · UTC Unix: {}</p>",
escape(&a.technician),
a.timestamp
));
}
h.push_str(&format!(
"<h2>Device / Gerät</h2><p>{} {}<br>Serial: {}<br>UUID: {}</p>",
escape(&s.identity.manufacturer),
escape(&s.identity.model),
escape(&s.identity.serial),
escape(&s.identity.uuid)
));
if let Some(r) = &s.report {
h.push_str("<table><tr><th>Specification / Spezifikation</th><th>Value / Wert</th></tr>");
for k in [
"cpu",
"logical_threads",
"installed_ram_gib",
"ram_gib",
"gpus",
"screen_resolution",
"screen_inches",
"tool_version",
] {
h.push_str(&format!("<tr><td>{k}</td><td>{}</td></tr>", value(r, k)));
}
h.push_str("</table><h2>Storage health / Laufwerkszustand</h2><table><tr><th>Disk</th><th>Model / GiB</th><th>SMART</th></tr>");
for d in r["disks"].as_array().into_iter().flatten() {
h.push_str(&format!(
"<tr><td>{}</td><td>{} / {}</td><td>{}<pre>{}</pre></td></tr>",
value(d, "name"),
value(d, "model"),
value(d, "size_gib"),
value(d, "smart_health"),
value(d, "smart_details")
));
}
h.push_str("</table><h2>Battery health / Akkuzustand</h2><table><tr><th>Battery</th><th>Charge %</th><th>Health %</th><th>Status</th></tr>");
for b in r["batteries"].as_array().into_iter().flatten() {
h.push_str(&format!(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
value(b, "name"),
value(b, "charge_percent"),
value(b, "health_percent"),
value(b, "status")
));
}
h.push_str("</table><h2>Measurements / Messwerte</h2>");
for k in [
"temperatures_c",
"benchmarks",
"memory_benchmark",
"compilation_benchmark",
"concerns",
] {
if !r[k].is_null() {
h.push_str(&format!(
"<h3>{k}</h3><pre>{}</pre>",
escape(&serde_json::to_string_pretty(&r[k]).unwrap_or_default())
));
}
}
}
h.push_str("<h2>Inspection results / Prüfergebnisse</h2><table><thead><tr><th>Test</th><th>Result / Ergebnis</th><th>Notes / Notizen</th></tr></thead><tbody>");
for k in &s.order {
let c = &s.checks[k];
h.push_str(&format!(
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
escape(k),
c.outcome.label(en),
escape(&c.notes)
));
}
h.push_str("</tbody></table>");
for k in &s.order {
let c = &s.checks[k];
h.push_str(&format!(
"<h2>{}</h2><pre>{}</pre>",
escape(k),
escape(&c.evidence)
));
for e in &c.history {
h.push_str(&format!(
"<p>{} · {} · {}</p><pre>{}</pre>",
e.timestamp,
escape(&e.kind),
e.outcome.label(en),
escape(&e.text)
));
}
}
h.push_str(&format!("<h2>Notes / Notizen</h2><pre>{}</pre><h2>Acceptance settings / Werkstattgrenzen</h2><pre>{}</pre>",escape(&s.notes),escape(&serde_json::to_string_pretty(&s.settings).unwrap_or_default())));
for b in s.blockers() {
h.push_str(&format!("<p>{}</p>", escape(&b)));
}
if !s.approvals.is_empty() {
h.push_str("<h2>Approval history / Freigabehistorie</h2>");
for a in &s.approvals {
h.push_str(&format!(
"<p>Revision {} · {} · UTC Unix {}</p>",
a.revision,
escape(&a.technician),
a.timestamp
));
}
}
h.push_str("</body></html>");
h
}
pub fn export(data: &Path, s: &Inspection) -> Result<PathBuf, String> {
if !workflow::valid_id(&s.asset) {
return Err("Invalid asset ID".into());
}
let path = data.join("final-reports").join(&s.asset).join(format!(
"inspection-{}-{}.html",
s.revision,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
workflow::atomic_write(&path, html(s).as_bytes())?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workflow::*;
#[test]
fn escapes_notes_and_does_not_print_stale_approval() {
let mut s = Inspection::new("PC-1", Identity::default(), Settings::default());
s.notes = "<script>alert('x')</script>".into();
s.approval = Some(Approval {
technician: "T".into(),
timestamp: 0,
revision: 0,
});
let h = html(&s);
assert!(!h.contains("<script>"));
assert!(h.contains("&lt;script&gt;"));
assert!(h.contains("NOT APPROVED"));
assert!(h.contains("@media print"));
}
}

825
gui/src/wifi.rs Normal file
View File

@@ -0,0 +1,825 @@
//! Wi-Fi workflow shared by the walkthrough and inventory page.
use eframe::egui;
use std::{
fs::{self, OpenOptions},
io::Write,
os::unix::fs::OpenOptionsExt,
path::{Path, PathBuf},
process::{Command, Stdio},
sync::mpsc::{self, Receiver},
thread,
};
#[derive(Clone, Debug, PartialEq)]
struct Network {
ssid: String,
bssid: String,
device: String,
security: String,
signal: u8,
active: bool,
}
impl Network {
fn enterprise(&self) -> bool {
self.security.contains("802.1X") || self.security.contains("EAP")
}
fn secured(&self) -> bool {
!self.security.is_empty() && self.security != "--"
}
}
fn fields(line: &str) -> Vec<String> {
let mut result = vec![String::new()];
let mut escaped = false;
for c in line.chars() {
if escaped {
result.last_mut().unwrap().push(c);
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == ':' {
result.push(String::new());
} else {
result.last_mut().unwrap().push(c);
}
}
if escaped {
result.last_mut().unwrap().push('\\');
}
result
}
fn parse_networks(raw: &str) -> Vec<Network> {
let mut networks = Vec::new();
for line in raw.lines() {
let f = fields(line);
if f.len() != 6 || f[1].is_empty() {
continue;
}
let Ok(signal) = f[3].parse() else {
continue;
};
networks.push(Network {
active: f[0] == "*",
ssid: f[1].clone(),
bssid: f[2].clone(),
signal,
security: f[4].clone(),
device: f[5].clone(),
});
}
networks.sort_by_key(|n| std::cmp::Reverse(n.signal));
networks.dedup_by(|a, b| a.bssid == b.bssid && a.device == b.device);
networks
}
fn nmcli(args: &[&str]) -> Command {
let mut c = Command::new("nmcli");
c.env("LC_ALL", "C").args(args).stdin(Stdio::null());
c
}
fn output(mut command: Command) -> Result<String, String> {
let out = command
.output()
.map_err(|e| format!("Werkzeug konnte nicht starten: {e}"))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_owned())
}
}
fn scan(rescan: &str) -> Result<Vec<Network>, String> {
output(nmcli(&[
"--wait",
"20",
"-t",
"--escape",
"yes",
"-f",
"IN-USE,SSID,BSSID,SIGNAL,SECURITY,DEVICE",
"device",
"wifi",
"list",
"--rescan",
rescan,
]))
.map(|s| parse_networks(&s))
}
// Use a user-owned, memory-only profile: the live account has no admin password
// or desktop authentication agent. Passwords travel through stdin, never argv.
fn connect_with(
n: &Network,
password: &str,
user: &str,
uuid: &str,
mut run: impl FnMut(&[&str], Option<&str>) -> Result<String, String>,
) -> Result<(), String> {
if password.contains(['\n', '\r', '\0']) {
return Err("Das Passwort darf keinen Zeilenumbruch oder NUL enthalten.".into());
}
if n.enterprise() {
return Err("Enterprise Wi-Fi needs a configured NetworkManager profile / Unternehmens-WLAN benötigt ein konfiguriertes Profil.".into());
}
// Reuse existing credentials without an interactive prompt if the field is blank.
if n.secured() && !n.security.contains("OWE") && password.is_empty() {
return run(&["--wait", "25", "device", "wifi", "connect", &n.bssid, "ifname", &n.device], None)
.map(|_|()).map_err(|e|format!("Kein gespeichertes Passwort verwendbar. WLAN-Passwort eingeben und erneut versuchen.\n{e}"));
}
let name = format!("CS-R Wi-Fi {uuid}");
let owner = format!("user:{user}");
let mut args = vec![
"--wait",
"10",
"connection",
"add",
"save",
"no",
"type",
"wifi",
"ifname",
&n.device,
"con-name",
&name,
"ssid",
&n.ssid,
"connection.uuid",
uuid,
"connection.permissions",
&owner,
"connection.autoconnect",
"no",
];
let secret_property = if n.security.contains("WEP") {
args.extend([
"wifi-sec.key-mgmt",
"none",
"wifi-sec.wep-key-type",
"key",
"wifi-sec.wep-key-flags",
"2",
]);
Some("802-11-wireless-security.wep-key0")
} else if n.security.contains("OWE") {
args.extend(["wifi-sec.key-mgmt", "owe"]);
None
} else if n.secured() {
let key_mgmt = if n.security.contains("WPA3") && !n.security.contains("WPA2") {
"sae"
} else {
"wpa-psk"
};
args.extend(["wifi-sec.key-mgmt", key_mgmt, "wifi-sec.psk-flags", "2"]);
Some("802-11-wireless-security.psk")
} else {
None
};
run(&args, None)?;
let payload = secret_property.map(|property| format!("{property}:{password}\n"));
let mut up = vec![
"--wait",
"25",
"connection",
"up",
"uuid",
uuid,
"ifname",
&n.device,
"ap",
&n.bssid,
];
if payload.is_some() {
up.extend(["passwd-file", "/dev/stdin"]);
}
match run(&up, payload.as_deref()) {
Ok(_) => Ok(()),
Err(error) => {
// Delete only the temporary profile created by this attempt.
let _ = run(
&["--wait", "10", "connection", "delete", "uuid", uuid],
None,
);
Err(error)
}
}
}
fn run_connect_command(
mut command: Command,
input: Option<&str>,
password: &str,
) -> Result<String, String> {
let mut child = command
.stdin(if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("NetworkManager konnte nicht starten: {e}"))?;
let write_error = if let Some(input) = input {
child
.stdin
.take()
.ok_or_else(|| "Password input unavailable".to_owned())
.and_then(|mut pipe| pipe.write_all(input.as_bytes()).map_err(|e| e.to_string()))
.err()
} else {
None
};
let out = child
.wait_with_output()
.map_err(|e| format!("Verbindung fehlgeschlagen: {e}"))?;
let redact = |text: &str| {
if password.is_empty() {
text.to_owned()
} else {
text.replace(password, "[hidden]")
}
};
if !out.status.success() {
let detail = redact(String::from_utf8_lossy(&out.stderr).trim());
return Err(format!(
"NetworkManager ({}): {}",
out.status,
if detail.is_empty() {
"Connection failed / Verbindung fehlgeschlagen"
} else {
&detail
}
));
}
if let Some(error) = write_error {
return Err(format!(
"Password delivery failed / Passwortübergabe fehlgeschlagen: {error}"
));
}
Ok(redact(&String::from_utf8_lossy(&out.stdout)))
}
fn connect(n: &Network, password: &str) -> Result<(), String> {
let mut id = Command::new("id");
id.arg("-un");
let user = output(id)?;
let uuid =
std::fs::read_to_string("/proc/sys/kernel/random/uuid").map_err(|e| e.to_string())?;
connect_with(n, password, user.trim(), uuid.trim(), |args, input| {
let mut command = Command::new("timeout");
command
.env("LC_ALL", "C")
.args(["--kill-after=3s", "40s", "nmcli"])
.args(args);
run_connect_command(command, input, password)
})
}
fn test_connection(n: &Network, url: &str) -> Result<String, String> {
if !(url.starts_with("https://") || url.starts_with("http://")) {
return Err("Bitte eine HTTP- oder HTTPS-Testadresse eingeben.".into());
}
let active = scan("no")?;
if !active
.iter()
.any(|a| a.active && a.ssid == n.ssid && a.device == n.device)
{
return Err(
"Das ausgewählte WLAN ist nicht verbunden. Zuerst verbinden, dann erneut testen."
.into(),
);
}
let ip = output(nmcli(&[
"-g",
"IP4.ADDRESS,IP6.ADDRESS",
"device",
"show",
&n.device,
]))?;
if ip.trim().is_empty() {
return Err("Verbunden, aber noch keine IP-Adresse erhalten. Bitte erneut testen.".into());
}
let mut curl = Command::new("curl");
curl.args([
"--disable",
"--noproxy",
"*",
"--interface",
&format!("if!{}", n.device),
"--connect-timeout",
"8",
"--max-time",
"20",
"--silent",
"--show-error",
"--output",
"/dev/null",
"--write-out",
"%{http_code}",
"--proto",
"=http,https",
"--url",
url,
]);
let code =
output(curl).map_err(|e| format!("WLAN verbunden; Datentransfer fehlgeschlagen: {e}"))?;
if !code.starts_with('2') {
return Err(format!("WLAN verbunden; Testziel meldet HTTP {code}. Adresse oder Anmeldeseite des WLANs prüfen."));
}
Ok(format!("Datentransfer erfolgreich · {} über {} · HTTP {}\nIP: {}\nTestziel: {}\nJetzt das Prüfergebnis bestätigen.", n.ssid, n.device, code, ip.trim(), url))
}
fn save_network(path: &Path, ssid: &str, password: &str) -> Result<(), String> {
let tmp = path.with_extension(format!(
"{}.{}.new",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)
.map_err(|e| e.to_string())?;
let result = (|| {
serde_json::to_writer(
&mut file,
&serde_json::json!({"ssid":ssid,"password":password}),
)
.map_err(|e| e.to_string())?;
file.sync_all().map_err(|e| e.to_string())?;
fs::rename(&tmp, path).map_err(|e| e.to_string())
})();
if result.is_err() {
let _ = fs::remove_file(&tmp);
}
result
}
enum Event {
Scanned(Result<Vec<Network>, String>),
Connected(Result<(), String>),
Tested(Result<String, String>),
}
pub struct Wifi {
en: bool,
saved_path: Option<PathBuf>,
remember: bool,
networks: Vec<Network>,
selected: Option<Network>,
password: String,
show_password: bool,
filter: String,
url: String,
message: String,
completed_test: Option<String>,
receiver: Option<Receiver<Event>>,
}
impl Default for Wifi {
fn default() -> Self {
Self {
en: false,
saved_path: None,
remember: true,
networks: Vec::new(),
selected: None,
password: String::new(),
show_password: false,
filter: String::new(),
url: "https://example.com".into(),
message: "Starte die Suche nach WLAN-Netzen.".into(),
completed_test: None,
receiver: None,
}
}
}
impl Wifi {
pub fn configure_saved(&mut self, data: &Path) {
self.saved_path = Some(data.join("config/workshop-wifi.json"));
}
pub fn reconnect_saved(&mut self) {
let Some(path) = self.saved_path.clone().filter(|p| p.exists()) else {
return;
};
if self.busy() {
return;
}
let (tx, rx) = mpsc::channel();
self.receiver = Some(rx);
self.message = "Connecting to saved workshop Wi-Fi / Verbinde Werkstatt-WLAN …".into();
thread::spawn(move || {
let result = (|| {
let saved: serde_json::Value =
serde_json::from_slice(&fs::read(path).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let ssid = saved["ssid"].as_str().ok_or("Saved SSID missing")?;
let password = saved["password"].as_str().unwrap_or("");
for attempt in 0..3 {
if let Ok(networks) = scan("yes") {
if let Some(network) = networks.iter().find(|n| n.ssid == ssid) {
if network.active {
return Ok(());
}
return connect(network, password);
}
}
if attempt < 2 {
thread::sleep(std::time::Duration::from_secs(3));
}
}
Err("Saved Wi-Fi unavailable. Check network in Settings / Werkstatt-WLAN nicht gefunden.".into())
})();
let _ = tx.send(Event::Connected(result));
});
}
pub fn set_language(&mut self, en: bool) {
self.en = en;
}
pub fn set_test_url(&mut self, url: &str) {
self.url = url.to_owned();
}
pub fn take_test_log(&mut self) -> Option<String> {
self.completed_test.take()
}
pub fn busy(&self) -> bool {
self.receiver.is_some()
}
pub fn poll(&mut self) {
let event = self.receiver.as_ref().map(|r| r.try_recv());
match event {
Some(Ok(event)) => {
self.receiver = None;
match event {
Event::Scanned(Ok(networks)) => {
self.message = if networks.is_empty() {
"Keine WLAN-Netze gefunden. WLAN-Adapter und Funkschalter prüfen, dann erneut suchen.".into()
} else {
format!(
"{} WLAN-Netze gefunden. Wähle ein Netzwerk aus.",
networks.len()
)
};
self.networks = networks;
self.selected = None;
self.password.clear();
}
Event::Connected(Ok(())) => {
self.password.clear();
if let Some(n) = &mut self.selected {
n.active = true;
}
self.message = "WLAN verbunden. Starte jetzt den Verbindungstest.".into()
}
Event::Tested(result) => {
let log = match result {
Ok(message) => {
format!("TEST: wifi\nSTATUS: DATENTRANSFER ERFOLGREICH\n{message}")
}
Err(e) => format!("TEST: wifi\nSTATUS: FEHLER\n{e}"),
};
self.message = log.clone();
self.completed_test = Some(log);
}
Event::Scanned(Err(e)) => self.message = format!("Fehler: {e}"),
Event::Connected(Err(e)) => {
self.message = format!(
"Fehler: {e}\nPasswort bleibt für einen erneuten Versuch erhalten."
)
}
}
}
Some(Err(mpsc::TryRecvError::Disconnected)) => {
self.receiver = None;
self.message = "WLAN-Aktion unerwartet beendet. Bitte erneut versuchen.".into();
}
_ => {}
}
}
pub fn ui(&mut self, ui: &mut egui::Ui, other_busy: bool) {
let en = self.en;
if let Some(path) = self.saved_path.clone().filter(|p| p.exists()) {
ui.horizontal(|ui| {
ui.label(crate::tr(
en,
"Werkstatt-WLAN gespeichert",
"Workshop Wi-Fi saved",
));
if ui
.add_enabled(
!self.busy(),
egui::Button::new(crate::tr(en, "Vergessen", "Forget")),
)
.clicked()
{
match fs::remove_file(path) {
Ok(()) => {
self.message =
"Saved Wi-Fi removed / Gespeichertes WLAN gelöscht".into()
}
Err(e) => self.message = e.to_string(),
}
}
});
}
ui.add_enabled_ui(!self.busy() && !other_busy, |ui| {
ui.label(egui::RichText::new(crate::tr(en, "1. WLAN suchen und auswählen", "1. Find and select Wi-Fi")).strong());
if ui.button(crate::tr(en, "WLAN-Netze suchen / aktualisieren", "Search / refresh Wi-Fi networks")).clicked() {
self.networks.clear(); self.selected = None; self.password.clear();
let (tx, rx) = mpsc::channel(); self.receiver = Some(rx);
self.message = crate::tr(en, "Suche läuft …", "Scanning …").into();
thread::spawn(move || { let _ = tx.send(Event::Scanned(scan("yes"))); });
}
ui.add(egui::TextEdit::singleline(&mut self.filter).hint_text(crate::tr(en, "Netzwerkname suchen …", "Filter network name …")));
egui::ScrollArea::vertical().id_salt("wifi-networks").max_height(135.0).show(ui, |ui| {
for network in &self.networks {
if !network.ssid.to_lowercase().contains(&self.filter.to_lowercase()) { continue; }
let selected = self.selected.as_ref() == Some(network);
let label = format!("{} · {}% · {} · {}{}", network.ssid, network.signal, if network.secured() { &network.security } else { crate::tr(en, "Offen", "Open") }, network.device, if network.active { crate::tr(en, " · Verbunden", " · Connected") } else { "" });
if ui.selectable_label(selected, label).clicked() {
self.selected = Some(network.clone()); self.password.clear(); self.show_password = false;
self.message = format!("{}: {}", crate::tr(en,"Ausgewählt","Selected"), network.ssid);
}
}
});
if !self.networks.is_empty() && !self.networks.iter().any(|n| n.ssid.to_lowercase().contains(&self.filter.to_lowercase())) { ui.label(crate::tr(en, "Kein Netzwerk passt zur Suche.", "No network matches the filter.")); }
ui.separator();
ui.label(egui::RichText::new(crate::tr(en, "2. Passwort eingeben und verbinden", "2. Enter password and connect")).strong());
if let Some(n) = self.selected.clone() {
ui.label(format!("{}: {}", crate::tr(en,"Ausgewähltes WLAN","Selected Wi-Fi"), n.ssid));
if n.enterprise() { ui.label(crate::tr(en, "Dieses WLAN benötigt eine Unternehmensanmeldung. Bitte über NetworkManager konfigurieren.", "This network needs enterprise authentication. Configure it through NetworkManager.")); }
else {
if n.secured() && !n.security.contains("OWE") {
ui.add(egui::TextEdit::singleline(&mut self.password).password(!self.show_password).hint_text(crate::tr(en, "WLAN-Passwort", "Wi-Fi password")));
ui.checkbox(&mut self.show_password, crate::tr(en, "Passwort anzeigen", "Show password"));
ui.small(crate::tr(en, "Leer lassen, wenn das Passwort bereits in NetworkManager gespeichert ist.", "Leave blank to use a password already saved in NetworkManager."));
} else { ui.label(crate::tr(en, "Offenes Netzwerk – kein Passwort nötig.", "Open network — no password needed.")); }
if self.saved_path.is_some() {
ui.checkbox(&mut self.remember, crate::tr(en, "Werkstatt-WLAN auf USB merken", "Remember workshop Wi-Fi on USB"));
}
if ui.button(crate::tr(en, "Mit WLAN verbinden", "Connect to Wi-Fi")).clicked() {
let password = self.password.clone(); self.show_password = false;
let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); self.message = crate::tr(en, "Verbindung wird hergestellt …", "Connecting …").into();
let save = if self.remember { self.saved_path.clone() } else { None };
thread::spawn(move || {
let result = connect(&n, &password).and_then(|_| {
if let Some(path) = save {
if n.secured() && !n.security.contains("OWE") && password.is_empty() { return Err("Connected, but enter password once to save it on USB / Verbunden; Passwort zum Speichern einmal eingeben.".into()); }
save_network(&path, &n.ssid, &password)?;
}
Ok(())
});
let _ = tx.send(Event::Connected(result));
});
}
}
} else { ui.label(crate::tr(en, "Wähle zuerst ein Netzwerk aus der Liste.", "Select a network from the list first.")); }
ui.label(if en { english_message(&self.message) } else { self.message.clone() });
if self.saved_path.is_none() {
ui.separator();
ui.label(egui::RichText::new(crate::tr(en, "3. Verbindung testen", "3. Test connection")).strong());
ui.label(crate::tr(en, "Testadresse (Internet oder lokaler Prüfserver)", "Test URL (internet or local test server)"));
ui.add(egui::TextEdit::singleline(&mut self.url).desired_width(ui.available_width()));
if ui.add_enabled(self.selected.is_some() && !self.busy(), egui::Button::new(crate::tr(en, "Verbindung testen", "Test connection"))).clicked() {
let n = self.selected.clone().unwrap(); let url = self.url.trim().to_owned();
let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); self.message = crate::tr(en, "Prüfe WLAN, IP-Adresse und Datentransfer …", "Checking Wi-Fi, IP address and data transfer …").into();
thread::spawn(move || { let _ = tx.send(Event::Tested(test_connection(&n, &url))); });
}
}
});
if self.busy() {
ui.spinner();
}
ui.label(if en {
english_message(&self.message)
} else {
self.message.clone()
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_escaped_networks_and_sorts_signal() {
let n = parse_networks("*:Office\\: West\\\\Lab:AA\\:BB\\:CC\\:DD\\:EE\\:FF:82:WPA2:wlan0\n:Cafe:11\\:22:40:--:wlan1\n::33\\:44:90:WPA2:wlan0\nmalformed");
assert_eq!(n.len(), 2);
assert_eq!(n[0].ssid, "Office: West\\Lab");
assert_eq!(n[0].bssid, "AA:BB:CC:DD:EE:FF");
assert!(n[0].active && n[0].secured());
assert!(!n[1].secured());
}
#[test]
fn connection_is_user_owned_and_password_is_only_in_stdin() {
let n = parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0);
let mut calls = Vec::new();
connect_with(
&n,
" secret: with spaces ",
"intake",
"test-uuid",
|args, input| {
calls.push((
args.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
input.map(str::to_owned),
));
Ok(String::new())
},
)
.unwrap();
let add = &calls[0].0;
assert!(add.windows(2).any(|w| w == ["save", "no"]));
assert!(add
.windows(2)
.any(|w| w == ["connection.permissions", "user:intake"]));
assert!(add.windows(2).any(|w| w == ["wifi-sec.psk-flags", "2"]));
let up = &calls[1].0;
assert!(up.windows(2).any(|w| w == ["ifname", "wlan7"]));
assert!(up.windows(2).any(|w| w == ["ap", "AA:BB"]));
assert!(up.windows(2).any(|w| w == ["passwd-file", "/dev/stdin"]));
assert_eq!(
calls[1].1.as_deref(),
Some("802-11-wireless-security.psk: secret: with spaces \n")
);
assert!(calls
.iter()
.all(|(args, _)| args.iter().all(|a| !a.contains("secret:") && a != "--ask")));
}
#[test]
fn failed_activation_cleans_only_its_temporary_profile() {
let n = parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0);
let mut calls = Vec::new();
let error = connect_with(&n, "password", "intake", "unique-uuid", |args, _| {
calls.push(args.join(" "));
if args.contains(&"up") {
Err("Error: Secrets were required, but not provided.".into())
} else {
Ok(String::new())
}
})
.unwrap_err();
assert!(error.contains("Secrets were required"));
assert_eq!(
calls.last().unwrap(),
"--wait 10 connection delete uuid unique-uuid"
);
assert!(
connect_with(&n, "bad\npassword", "intake", "id", |_, _| panic!(
"must not run"
))
.is_err()
);
assert!(test_connection(&n, "file:///etc/passwd").is_err());
}
#[test]
fn process_errors_are_visible_but_secrets_are_redacted() {
let mut command = Command::new("sh");
command.args(["-c", "cat >&2; printf 'Authentication failed' >&2; exit 4"]);
let error = run_connect_command(command, Some("wifi-sec.psk:secret123\n"), "secret123")
.unwrap_err();
assert!(error.contains("Authentication failed"));
assert!(!error.contains("secret123"));
assert!(error.contains("[hidden]"));
}
#[test]
fn open_wpa3_and_saved_connections_use_correct_modes() {
for (security, password, expected) in [
("--", "", None),
("WPA3", "password", Some("sae")),
("OWE", "", Some("owe")),
] {
let mut n = parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0);
n.security = security.into();
let mut calls = Vec::new();
connect_with(&n, password, "intake", "uuid", |args, input| {
calls.push((args.join(" "), input.map(str::to_owned)));
Ok(String::new())
})
.unwrap();
if let Some(key) = expected {
assert!(calls[0].0.contains(&format!("wifi-sec.key-mgmt {key}")));
}
if password.is_empty() {
assert!(calls.iter().all(|(_, input)| input.is_none()));
}
}
let n = parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0);
connect_with(&n, "", "intake", "uuid", |args, input| {
assert!(args.contains(&"connect"));
assert!(!args.contains(&"--ask"));
assert!(input.is_none());
Ok(String::new())
})
.unwrap();
}
}
#[cfg(test)]
mod workflow_tests {
use super::*;
#[test]
fn renders_network_password_and_result_states() {
let mut wifi = Wifi::default();
wifi.networks =
parse_networks(":Workshop:AA\\:BB:80:WPA2:wlan0\n*:Guest:CC\\:DD:30:--:wlan0");
let ctx = egui::Context::default();
for i in 0..4 {
wifi.selected = wifi.networks.get(i % 2).cloned();
wifi.message = if i > 1 {
"Fehler: Falsches Passwort. Erneut versuchen.".into()
} else {
"WLAN verbunden.".into()
};
let _ = ctx.run(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(580.0, 600.0),
)),
..Default::default()
},
|ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
wifi.ui(ui, false);
assert!(ui.min_rect().is_finite());
});
},
);
}
}
#[test]
fn completion_and_worker_failure_release_busy_state() {
let mut wifi = Wifi::default();
wifi.password = "keep-for-retry".into();
wifi.selected = Some(parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0));
let (tx, rx) = mpsc::channel();
wifi.receiver = Some(rx);
tx.send(Event::Connected(Err("Passwort prüfen".into())))
.unwrap();
wifi.poll();
assert!(!wifi.busy());
assert!(wifi.message.contains("Passwort prüfen"));
assert_eq!(wifi.password, "keep-for-retry");
assert_eq!(wifi.selected.as_ref().unwrap().ssid, "Office");
let (tx, rx) = mpsc::channel();
wifi.receiver = Some(rx);
tx.send(Event::Connected(Ok(()))).unwrap();
wifi.poll();
assert!(wifi.password.is_empty());
assert!(wifi.selected.as_ref().unwrap().active);
let (tx, rx) = mpsc::channel();
wifi.receiver = Some(rx);
tx.send(Event::Tested(Ok("HTTP 200".into()))).unwrap();
wifi.poll();
assert!(wifi.take_test_log().unwrap().contains("HTTP 200"));
let (tx, rx) = mpsc::channel();
wifi.receiver = Some(rx);
drop(tx);
wifi.poll();
assert!(!wifi.busy());
assert!(wifi.message.contains("erneut"));
}
}
fn english_message(message: &str) -> String {
let mut s = message.to_owned();
for (de,en) in [
("Starte die Suche nach WLAN-Netzen.","Start searching for Wi-Fi networks."),
("Keine WLAN-Netze gefunden. WLAN-Adapter und Funkschalter prüfen, dann erneut suchen.","No Wi-Fi networks found. Check the adapter and radio switch, then retry."),
("WLAN-Netze gefunden. Wähle ein Netzwerk aus.","Wi-Fi networks found. Select a network."),
("WLAN verbunden. Starte jetzt den Verbindungstest.","Wi-Fi connected. Run the connection test now."),
("WLAN-Aktion unerwartet beendet. Bitte erneut versuchen.","Wi-Fi action interrupted. Please retry."),
("Verbindung fehlgeschlagen. Passwort, WLAN-Funkschalter und NetworkManager-Berechtigungen prüfen; danach erneut verbinden.","Connection failed. Check the password, radio switch and NetworkManager permissions, then retry."),
("Das ausgewählte WLAN ist nicht verbunden. Zuerst verbinden, dann erneut testen.","The selected Wi-Fi network is not connected. Connect first, then retry."),
("Verbunden, aber noch keine IP-Adresse erhalten. Bitte erneut testen.","Connected, but no IP address received yet. Please retry."),
("Bitte eine HTTP- oder HTTPS-Testadresse eingeben.","Enter an HTTP or HTTPS test URL."),
("WLAN verbunden; Datentransfer fehlgeschlagen:","Wi-Fi connected; data transfer failed:"),
("WLAN verbunden; Testziel meldet HTTP", "Wi-Fi connected; test server returned HTTP"),
("Adresse oder Anmeldeseite des WLANs prüfen.","Check the test URL or captive portal."),
("Datentransfer erfolgreich", "Data transfer successful"),
("über", "via"), ("Testziel:", "Test URL:"),
("Jetzt das Prüfergebnis bestätigen.", "Confirm the inspection result now."),
("DATENTRANSFER ERFOLGREICH", "DATA TRANSFER SUCCEEDED"), ("FEHLER", "ERROR"), ("Fehler:", "Error:"),
("Passwort bleibt für einen erneuten Versuch erhalten.","Password kept for retry."),
("Kein gespeichertes Passwort verwendbar. WLAN-Passwort eingeben und erneut versuchen.","No usable saved password. Enter the Wi-Fi password and retry."),
] {s=s.replace(de,en);}
s
}
#[cfg(test)]
mod saved_network_tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn saved_wifi_replaces_one_network_and_restricts_credentials() {
let root = std::env::temp_dir().join(format!("csr-wifi-save-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let path = root.join("workshop-wifi.json");
save_network(&path, "Workshop", " secret:pass ").unwrap();
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
let data: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(data["password"], " secret:pass ");
save_network(&path, "New workshop", "new pass").unwrap();
let data: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(data["ssid"], "New workshop");
assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
fs::remove_dir_all(root).unwrap();
}
}

893
gui/src/workflow.rs Normal file
View File

@@ -0,0 +1,893 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::BTreeMap,
fs::{self, File, OpenOptions},
io::Write,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
pub fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
let parent = path.parent().ok_or("Missing parent directory")?;
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
let temp = parent.join(format!(
".write-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let result = (|| {
let mut opts = OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&temp).map_err(|e| e.to_string())?;
f.write_all(bytes)
.and_then(|_| f.sync_all())
.map_err(|e| e.to_string())?;
fs::rename(&temp, path).map_err(|e| e.to_string())?;
File::open(parent)
.and_then(|f| f.sync_all())
.map_err(|e| e.to_string())
})();
if result.is_err() {
let _ = fs::remove_file(temp);
}
result
}
pub fn save_json<T: Serialize>(path: &Path, data: &T) -> Result<(), String> {
atomic_write(
path,
&serde_json::to_vec_pretty(data).map_err(|e| e.to_string())?,
)
}
fn sqlquote(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
pub fn sql(data: &Path, query: &str) -> Result<(), String> {
fs::create_dir_all(data.join("database")).map_err(|e| e.to_string())?;
let out = std::process::Command::new("sqlite3")
.args(["-cmd", ".timeout 10000"])
.arg(data.join("database/intake.sqlite3"))
.arg(query)
.output()
.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).into_owned())
}
}
pub fn init_index(data: &Path) -> Result<(), String> {
sql(data,"CREATE TABLE IF NOT EXISTS scans (id INTEGER PRIMARY KEY AUTOINCREMENT, asset_id TEXT NOT NULL, timestamp INTEGER NOT NULL, report_path TEXT NOT NULL UNIQUE, inspection_path TEXT, inspection_status TEXT NOT NULL DEFAULT 'NOT_TESTED', sync_status TEXT NOT NULL DEFAULT 'PENDING', synced_inventory_id TEXT);")
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
#[serde(default)]
pub struct Settings {
pub language: String,
pub test_url: String,
pub stress_seconds: u64,
pub recording_seconds: u64,
pub camera_seconds: u64,
pub max_temperature_c: f64,
pub min_battery_health_pct: u64,
pub retry_seconds: u64,
}
impl Default for Settings {
fn default() -> Self {
Self {
language: "de".into(),
test_url: "https://example.com".into(),
stress_seconds: 120,
recording_seconds: 5,
camera_seconds: 25,
max_temperature_c: 90.0,
min_battery_health_pct: 70,
retry_seconds: 60,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), String> {
if !["de", "en"].contains(&self.language.as_str()) {
return Err("Language must be de or en".into());
}
if !(self.test_url.starts_with("https://") || self.test_url.starts_with("http://"))
|| self.test_url.contains(['\n', '\r'])
{
return Err("Test URL must use HTTP(S)".into());
}
if !(10..=600).contains(&self.stress_seconds)
|| !(1..=30).contains(&self.recording_seconds)
|| !(5..=120).contains(&self.camera_seconds)
{
return Err("Test durations out of range".into());
}
if !self.max_temperature_c.is_finite()
|| !(40.0..=110.0).contains(&self.max_temperature_c)
|| self.min_battery_health_pct > 100
|| !(10..=3600).contains(&self.retry_seconds)
{
return Err("Threshold or retry interval out of range".into());
}
Ok(())
}
pub fn load(data: &Path) -> Result<Self, String> {
let path = data.join("config/workshop.json");
let s: Self = if path.exists() {
serde_json::from_slice(&fs::read(path).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?
} else {
Self::default()
};
s.validate()?;
Ok(s)
}
}
#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Identity {
pub manufacturer: String,
pub model: String,
pub serial: String,
pub uuid: String,
}
fn identity_value(s: &str) -> String {
let s = s.trim();
let l = s.to_lowercase();
if s.is_empty()
|| [
"unknown",
"none",
"default string",
"to be filled by o.e.m.",
"not specified",
"system serial number",
]
.contains(&l.as_str())
|| s.chars().all(|c| matches!(c, '0' | 'f' | 'F' | '-'))
{
String::new()
} else {
s.into()
}
}
impl Identity {
pub fn detect() -> Self {
let read = |key: &str| {
identity_value(
&fs::read_to_string(format!("/sys/class/dmi/id/{key}"))
.or_else(|_| fs::read_to_string(format!("/run/csr-intake/{key}")))
.unwrap_or_default(),
)
};
Self {
manufacturer: read("sys_vendor"),
model: read("product_name"),
serial: read("product_serial"),
uuid: read("product_uuid"),
}
}
pub fn from_report(v: &Value) -> Self {
let text = |k| identity_value(v.get(k).and_then(Value::as_str).unwrap_or(""));
Self {
manufacturer: text("manufacturer"),
model: text("model"),
serial: text("serial_number"),
uuid: text("system_uuid"),
}
}
pub fn matches(&self, other: &Self) -> bool {
(!self.uuid.is_empty() && self.uuid.eq_ignore_ascii_case(&other.uuid))
|| (!self.serial.is_empty()
&& self.serial.eq_ignore_ascii_case(&other.serial)
&& self.manufacturer.eq_ignore_ascii_case(&other.manufacturer))
}
pub fn conflicts(&self, other: &Self) -> bool {
(!self.uuid.is_empty()
&& !other.uuid.is_empty()
&& !self.uuid.eq_ignore_ascii_case(&other.uuid))
|| (!self.serial.is_empty()
&& !other.serial.is_empty()
&& !self.serial.eq_ignore_ascii_case(&other.serial))
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Outcome {
NotTested,
Pass,
HardwareFailure,
NotApplicable,
Unavailable,
SetupProblem,
NeedsReview,
}
impl Outcome {
pub const ALL: [Self; 7] = [
Self::NotTested,
Self::Pass,
Self::HardwareFailure,
Self::NotApplicable,
Self::Unavailable,
Self::SetupProblem,
Self::NeedsReview,
];
pub fn label(self, en: bool) -> &'static str {
match (self, en) {
(Self::NotTested, true) => "Not tested",
(Self::NotTested, false) => "Nicht getestet",
(Self::Pass, true) => "Passed",
(Self::Pass, false) => "Bestanden",
(Self::HardwareFailure, true) => "Hardware failure",
(Self::HardwareFailure, false) => "Hardwaredefekt",
(Self::NotApplicable, true) => "Not present / N/A",
(Self::NotApplicable, false) => "Nicht vorhanden / N/A",
(Self::Unavailable, true) => "Test unavailable",
(Self::Unavailable, false) => "Test nicht verfügbar",
(Self::SetupProblem, true) => "Setup problem",
(Self::SetupProblem, false) => "Einrichtungsproblem",
(Self::NeedsReview, true) => "Needs confirmation",
(Self::NeedsReview, false) => "Bestätigung nötig",
}
}
pub fn complete(self) -> bool {
matches!(self, Self::Pass | Self::NotApplicable)
}
pub fn legacy(self) -> &'static str {
match self {
Self::Pass => "PASS",
Self::HardwareFailure => "FAIL",
Self::NotApplicable => "NOT_APPLICABLE",
Self::Unavailable => "UNAVAILABLE",
Self::SetupProblem => "SETUP_PROBLEM",
_ => "NOT_TESTED",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Event {
pub timestamp: u64,
pub kind: String,
pub outcome: Outcome,
pub text: String,
pub settings: Settings,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Check {
pub outcome: Outcome,
pub notes: String,
pub evidence: String,
pub history: Vec<Event>,
pub suggestion: String,
}
impl Default for Check {
fn default() -> Self {
Self {
outcome: Outcome::NotTested,
notes: String::new(),
evidence: String::new(),
history: Vec::new(),
suggestion: String::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Approval {
pub technician: String,
pub timestamp: u64,
pub revision: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Inspection {
pub schema_version: u32,
pub asset: String,
#[serde(default)]
pub sync_phase: String,
pub identity: Identity,
pub step: usize,
pub updated: u64,
pub revision: u64,
pub checks: BTreeMap<String, Check>,
pub order: Vec<String>,
pub report: Option<Value>,
pub report_path: Option<PathBuf>,
pub notes: String,
pub technician: String,
pub approval: Option<Approval>,
#[serde(default)]
pub approvals: Vec<Approval>,
pub settings: Settings,
pub build: String,
}
pub const BASE: &[&str] = &[
"display",
"keyboard",
"touchpad",
"wifi",
"audio",
"microphone",
"webcam",
"usb_ports",
"charging",
"thermals",
"stability",
];
impl Inspection {
pub fn new(asset: &str, identity: Identity, settings: Settings) -> Self {
Self {
schema_version: 1,
asset: asset.into(),
sync_phase: String::new(),
identity,
step: 0,
updated: now(),
revision: 0,
checks: BASE
.iter()
.map(|k| (k.to_string(), Check::default()))
.collect(),
order: BASE.iter().map(|k| k.to_string()).collect(),
report: None,
report_path: None,
notes: String::new(),
technician: String::new(),
approval: None,
approvals: Vec::new(),
settings,
build: env!("CSR_BUILD_ID").into(),
}
}
pub fn changed(&mut self) {
self.revision += 1;
self.updated = now();
self.approval = None;
}
pub fn record(&mut self, key: &str, kind: &str, outcome: Outcome, text: String) {
if !self.sync_phase.is_empty() {
self.sync_phase = "IN_PROGRESS".into();
}
self.changed();
let c = self.checks.entry(key.into()).or_default();
c.history.push(Event {
timestamp: now(),
kind: kind.into(),
outcome,
text: text.clone(),
settings: self.settings.clone(),
});
c.outcome = outcome;
if kind == "diagnostic" {
c.evidence = text;
}
}
pub fn set_report(&mut self, path: PathBuf, report: Value) {
self.changed();
self.identity = Identity::from_report(&report);
for key in ["charging", "touchpad", "webcam"] {
let detected = match key {
"charging" => report
.get("batteries")
.and_then(Value::as_array)
.map(|a| !a.is_empty()),
_ => report
.get("capabilities")
.and_then(|c| c.get(key))
.and_then(Value::as_bool),
};
self.checks.entry(key.into()).or_default().suggestion = match detected {
Some(false) => {
"Not detected / Nicht erkannt — confirm absence before choosing N/A".into()
}
Some(true) => "Detected / Erkannt".into(),
None => "Detection unavailable / Erkennung nicht verfügbar".into(),
};
}
// Detected components first; optional, undetected hardware stays explicit until confirmed absent.
if self.report.is_none() {
self.order.sort_by_key(|key| {
self.checks
.get(key)
.map(|c| c.suggestion.starts_with("Not detected"))
.unwrap_or(false)
});
}
// Keep previous disks in the audit trail, but require explicit disposition when removed.
let disk_keys: Vec<String> = report
.get("disks")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|d| d.get("removable") == Some(&Value::Bool(false)))
.filter_map(|d| d.get("name").and_then(Value::as_str))
.map(|n| format!("disk:{n}"))
.collect();
for key in &disk_keys {
self.checks.entry(key.clone()).or_default();
if !self.order.contains(key) {
self.order.push(key.clone());
}
}
for key in self
.order
.clone()
.into_iter()
.filter(|k| k.starts_with("disk:") && !disk_keys.contains(k))
{
self.record(
&key,
"hardware_change",
Outcome::NeedsReview,
"Drive no longer detected; confirm replacement/removal.".into(),
);
}
self.report = Some(report);
self.report_path = Some(path);
}
pub fn blockers(&self) -> Vec<String> {
let mut out = Vec::new();
if self.report.is_none() {
out.push("No hardware scan / Kein Hardware-Scan".into());
}
for k in &self.order {
if !self
.checks
.get(k)
.map(|c| c.outcome.complete())
.unwrap_or(false)
{
out.push(format!("{k}: incomplete / offen"));
}
}
for k in ["thermals", "stability"] {
if let Some(c) = self.checks.get(k) {
if crate::diagnostics::peak_temperature(&c.evidence)
.map(|v| v >= self.settings.max_temperature_c)
.unwrap_or(false)
{
out.push(format!("{k}: measured peak exceeds workshop threshold / gemessene Spitze überschreitet Grenzwert"));
}
}
}
if let Some(r) = &self.report {
if !r
.get("disks")
.and_then(Value::as_array)
.map(|ds| {
ds.iter()
.any(|d| d.get("removable") == Some(&Value::Bool(false)))
})
.unwrap_or(false)
{
out.push("No internal storage verified / Kein internes Laufwerk geprüft".into());
}
for d in r
.get("disks")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|d| d.get("removable") == Some(&Value::Bool(false)))
{
if d.get("smart_health").and_then(Value::as_str) != Some("PASS") {
out.push(format!("{}: SMART requires review / prüfen", d["name"]));
}
}
for b in r
.get("batteries")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if b.get("health_percent")
.and_then(Value::as_u64)
.map(|v| v < self.settings.min_battery_health_pct)
.unwrap_or(true)
{
out.push(format!(
"{}: battery health below threshold or unknown / Akkuzustand prüfen",
b["name"]
));
}
}
for t in r
.get("temperatures_c")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if t.get("celsius")
.and_then(Value::as_f64)
.map(|v| v >= self.settings.max_temperature_c)
.unwrap_or(false)
{
out.push("Temperature above workshop threshold / Temperatur zu hoch".into());
}
}
}
out
}
pub fn approve(&mut self) -> Result<(), String> {
if self.technician.trim().is_empty() {
return Err("Technician name required / Technikername erforderlich".into());
}
let reasons = self.blockers();
if !reasons.is_empty() {
return Err(reasons.join("\n"));
}
self.approval = Some(Approval {
technician: self.technician.trim().into(),
timestamp: now(),
revision: self.revision,
});
self.approvals.push(self.approval.clone().unwrap());
Ok(())
}
pub fn save(&self, data: &Path) -> Result<(), String> {
if !valid_id(&self.asset) {
return Err("Invalid asset ID".into());
}
save_json(
&data
.join("inspections")
.join(format!("{}.json", self.asset)),
self,
)?;
let legacy = BASE
.iter()
.map(|k| {
format!(
"{}={}\n",
k,
self.checks
.get(*k)
.map(|c| c.outcome)
.unwrap_or(Outcome::NotTested)
.legacy()
)
})
.collect::<String>();
atomic_write(
&data.join("checklists").join(format!("{}.txt", self.asset)),
legacy.as_bytes(),
)?;
if let Some(path) = &self.report_path {
init_index(data)?;
let state = if self.approval.is_some() {
"APPROVED"
} else if self
.checks
.values()
.any(|c| c.outcome == Outcome::HardwareFailure)
{
"FAIL"
} else if self.checks.values().all(|c| c.outcome.complete()) {
"COMPLETED"
} else {
"INCOMPLETE"
};
let timestamp = self
.report
.as_ref()
.and_then(|r| r.get("timestamp_unix"))
.and_then(Value::as_u64)
.unwrap_or(self.updated);
sql(data,&format!("BEGIN; INSERT OR IGNORE INTO scans(asset_id,timestamp,report_path) VALUES({},{},{}); UPDATE scans SET inspection_path={},inspection_status={} WHERE report_path={}; COMMIT;",sqlquote(&self.asset),timestamp,sqlquote(&path.to_string_lossy()),sqlquote(&data.join("inspections").join(format!("{}.json",self.asset)).to_string_lossy()),sqlquote(state),sqlquote(&path.to_string_lossy())))?;
}
atomic_write(&data.join("active-inspection.txt"), self.asset.as_bytes())
}
pub fn load(data: &Path, asset: &str) -> Result<Self, String> {
if !valid_id(asset) {
return Err("Invalid asset ID".into());
}
let mut s: Self = serde_json::from_slice(
&fs::read(data.join("inspections").join(format!("{asset}.json")))
.map_err(|e| e.to_string())?,
)
.map_err(|e| format!("Inspection unreadable; original file preserved: {e}"))?;
if s.schema_version != 1
|| s.asset != asset
|| s.order.iter().any(|k| !s.checks.contains_key(k))
|| BASE
.iter()
.any(|k| !s.order.iter().any(|present| present == k))
|| s.order
.iter()
.collect::<std::collections::BTreeSet<_>>()
.len()
!= s.order.len()
|| s.settings.validate().is_err()
{
return Err("Unsupported or inconsistent inspection; file preserved".into());
}
s.step = s.step.min(s.order.len());
if s.approval
.as_ref()
.map(|a| a.revision != s.revision)
.unwrap_or(false)
|| !s.blockers().is_empty()
{
s.approval = None;
}
if s.checks
.values()
.any(|c| c.history.last().map(|e| e.kind.as_str()) == Some("started"))
{
s.approval = None;
}
for c in s.checks.values_mut() {
if c.history.last().map(|e| e.kind.as_str()) == Some("started") {
c.outcome = Outcome::NotTested;
c.evidence = "Interrupted test — rerun / Unterbrochen — erneut testen".into();
}
}
Ok(s)
}
}
pub fn latest_report(data: &Path, asset: &str) -> Option<(PathBuf, Value)> {
if !valid_id(asset) {
return None;
}
let mut files: Vec<_> = fs::read_dir(data.join("reports").join(asset))
.ok()?
.flatten()
.map(|e| e.path().join("report.json"))
.filter_map(|p| {
p.metadata()
.ok()
.and_then(|m| m.modified().ok())
.map(|t| (t, p))
})
.collect();
files.sort_by_key(|x| x.0);
files.into_iter().rev().find_map(|(_, p)| {
fs::read(&p)
.ok()
.and_then(|v| serde_json::from_slice::<Value>(&v).ok())
.filter(|v| v.get("asset_id").and_then(Value::as_str) == Some(asset))
.map(|v| (p, v))
})
}
pub fn known_assets(data: &Path) -> Vec<String> {
let mut ids = Vec::new();
for folder in ["inspections", "reports"] {
if let Ok(entries) = fs::read_dir(data.join(folder)) {
for e in entries.flatten() {
let name = if folder == "inspections" {
e.path()
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
} else {
e.file_name().to_string_lossy().into_owned()
};
if valid_id(&name) {
ids.push(name);
}
}
}
}
ids.sort();
ids.dedup();
ids
}
pub fn duplicates(data: &Path, asset: &str, identity: &Identity) -> Vec<String> {
known_assets(data)
.into_iter()
.filter(|id| {
id == asset
|| Inspection::load(data, id)
.map(|s| s.identity.matches(identity))
.unwrap_or_else(|_| {
latest_report(data, id)
.map(|(_, v)| Identity::from_report(&v).matches(identity))
.unwrap_or(false)
})
})
.collect()
}
pub fn migrate(data: &Path, asset: &str, settings: Settings) -> Inspection {
let mut s = Inspection::new(asset, Identity::default(), settings);
if let Some((p, v)) = latest_report(data, asset) {
s.set_report(p, v);
}
if let Ok(raw) = fs::read_to_string(data.join("checklists").join(format!("{asset}.txt"))) {
for line in raw.lines() {
if let Some((key, value)) = line.split_once('=') {
if s.checks.contains_key(key) {
let outcome = match value.trim() {
"PASS" => Outcome::Pass,
"FAIL" => Outcome::HardwareFailure,
"NOT_APPLICABLE" => Outcome::NotApplicable,
"UNAVAILABLE" => Outcome::Unavailable,
"SETUP_PROBLEM" => Outcome::SetupProblem,
_ => Outcome::NotTested,
};
s.record(
key,
"legacy_import",
outcome,
"Imported checklist / Importierte Prüfliste".into(),
);
}
}
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn temp() -> PathBuf {
std::env::temp_dir().join(format!(
"csr-state-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn roundtrip_repair_and_interruption() {
let p = temp();
let mut s = Inspection::new("PC-42", Identity::default(), Settings::default());
s.notes = "Notes before reboot".into();
s.step = 4;
s.record("wifi", "result", Outcome::HardwareFailure, "broken".into());
s.record(
"wifi",
"repair",
Outcome::NotTested,
"replaced adapter".into(),
);
s.record("wifi", "started", Outcome::NotTested, "running".into());
s.save(&p).unwrap();
let r = Inspection::load(&p, "PC-42").unwrap();
assert_eq!(r.step, 4);
assert_eq!(r.notes, s.notes);
assert_eq!(r.checks["wifi"].history.len(), 3);
assert!(r.checks["wifi"].evidence.contains("Interrupted"));
fs::remove_dir_all(p).unwrap();
}
#[test]
fn corrupt_and_traversal_never_overwritten() {
let p = temp();
fs::create_dir_all(p.join("inspections")).unwrap();
fs::write(p.join("inspections/PC-1.json"), "broken").unwrap();
assert!(Inspection::load(&p, "PC-1").is_err());
assert!(Inspection::load(&p, "../x").is_err());
assert_eq!(
fs::read_to_string(p.join("inspections/PC-1.json")).unwrap(),
"broken"
);
fs::remove_dir_all(p).unwrap();
}
#[test]
fn only_completed_checks_can_be_approved_and_changes_revoke() {
let mut s = Inspection::new("PC-1", Identity::default(), Settings::default());
s.technician = "T".into();
s.report = Some(
serde_json::json!({"disks":[{"name":"sda","removable":false,"smart_health":"PASS"}]}),
);
for c in s.checks.values_mut() {
c.outcome = Outcome::Pass;
}
s.approve().unwrap();
s.record("wifi", "repair", Outcome::NotTested, "repaired".into());
assert!(s.approval.is_none());
for outcome in [
Outcome::NotTested,
Outcome::Unavailable,
Outcome::SetupProblem,
Outcome::HardwareFailure,
Outcome::NeedsReview,
] {
s.checks.get_mut("wifi").unwrap().outcome = outcome;
assert!(s.approve().is_err());
}
}
#[test]
fn thresholds_block_approval() {
let mut s = Inspection::new("PC-1", Identity::default(), Settings::default());
s.report = Some(
serde_json::json!({"batteries":[{"health_percent":60}],"disks":[{"removable":false,"smart_health":"UNKNOWN"}]}),
);
assert!(s.blockers().iter().any(|s| s.contains("battery")));
assert!(s.blockers().iter().any(|s| s.contains("SMART")));
}
#[test]
fn placeholder_identity_never_matches() {
let a = Identity::from_report(
&serde_json::json!({"serial_number":"To Be Filled By O.E.M.","system_uuid":"00000000-0000-0000-0000-000000000000"}),
);
assert!(!a.matches(&a));
}
#[test]
fn per_disk_checklist_retains_removed_drive_history() {
let mut s = Inspection::new("PC-1", Identity::default(), Settings::default());
s.set_report(PathBuf::new(),serde_json::json!({"disks":[{"name":"sda","removable":false},{"name":"sdb","removable":false},{"name":"sdc","removable":true}]}));
assert!(s.checks.contains_key("disk:sda"));
assert!(s.checks.contains_key("disk:sdb"));
assert!(!s.checks.contains_key("disk:sdc"));
s.set_report(PathBuf::new(), serde_json::json!({"disks":[]}));
assert_eq!(s.checks["disk:sda"].outcome, Outcome::NeedsReview);
}
}
#[cfg(test)]
mod regression_tests {
use super::*;
#[test]
fn legacy_checklist_migrates_and_duplicates_match_uuid() {
let root = std::env::temp_dir().join(format!(
"csr-migration-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(root.join("reports/PC-1/100-1")).unwrap();
fs::create_dir_all(root.join("checklists")).unwrap();
let report = serde_json::json!({"asset_id":"PC-1","system_uuid":"12345678-1234-1234-1234-123456789012","serial_number":"ABC123","manufacturer":"Maker","disks":[]});
fs::write(
root.join("reports/PC-1/100-1/report.json"),
report.to_string(),
)
.unwrap();
fs::write(
root.join("checklists/PC-1.txt"),
"display=PASS\nwifi=SETUP_PROBLEM\nwebcam=NOT_APPLICABLE\n",
)
.unwrap();
let s = migrate(&root, "PC-1", Settings::default());
assert_eq!(s.checks["display"].outcome, Outcome::Pass);
assert_eq!(s.checks["wifi"].outcome, Outcome::SetupProblem);
s.save(&root).unwrap();
assert_eq!(
duplicates(&root, "PC-NEW", &Identity::from_report(&report)),
vec!["PC-1"]
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn changed_thresholds_recheck_measured_peaks() {
let mut s = Inspection::new("PC-1", Identity::default(), Settings::default());
s.checks.get_mut("thermals").unwrap().evidence = "PEAK_TEMPERATURE_C=88.5".into();
assert!(!s.blockers().iter().any(|b| b.contains("measured peak")));
s.settings.max_temperature_c = 85.0;
assert!(s.blockers().iter().any(|b| b.contains("measured peak")));
}
#[test]
fn invalid_settings_are_rejected() {
let mut s = Settings::default();
s.stress_seconds = 0;
assert!(s.validate().is_err());
s.stress_seconds = 120;
s.max_temperature_c = f64::NAN;
assert!(s.validate().is_err());
s.max_temperature_c = 90.0;
s.test_url = "file:///tmp/private".into();
assert!(s.validate().is_err());
}
}

7
live/autologin.conf Normal file
View File

@@ -0,0 +1,7 @@
[Unit]
Requires=intake-prepare.service
After=intake-prepare.service
[Service]
ExecStart=
ExecStart=-/usr/bin/agetty --autologin intake --noclear %I $TERM

19
live/intake-login.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Sourced on autologin on tty1; no system service owns the compositor.
[[ "$(tty)" == /dev/tty1 ]] || return 0
[[ -z "${WAYLAND_DISPLAY:-}" ]] || return 0
export WLR_RENDERER_ALLOW_SOFTWARE=1
export XDG_SESSION_TYPE=wayland
if [[ ! -w /mnt/intake-data ]]; then
printf '\nCS-R Intake: data partition not writable; no scan can be saved.\n' >&2
printf 'Press Enter for diagnostic console...'
read -r _
return 0
fi
if command -v cage >/dev/null && [[ -x /usr/local/bin/intake-gui ]]; then
cage -- /usr/local/bin/intake-gui --data /mnt/intake-data
printf '\nGUI exited. You are at the diagnostic terminal (tty1).\n' >&2
else
printf '\nCage or GUI missing. Attempting controller fallback.\n' >&2
/usr/local/bin/intake-controller --data /mnt/intake-data || true
fi

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Prepare CS-R Intake data and kiosk user
After=local-fs.target systemd-udev-settle.service
Before=getty@tty1.service
Wants=systemd-udev-settle.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/intake-prepare
RemainAfterExit=yes
TimeoutStartSec=45
[Install]
WantedBy=multi-user.target

51
live/intake-prepare.sh Normal file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Run as root once per boot. NEVER write to internal devices.
DATA=/mnt/intake-data
if ! id intake &>/dev/null; then
useradd --create-home --home-dir /home/intake --shell /bin/bash --user-group intake
fi
install -d -o intake -g intake -m 0700 /home/intake
# Read protected DMI fields once as root; provide a boot-local, read-only snapshot.
install -d -o root -g intake -m 0750 /run/csr-intake
for field in product_uuid product_serial; do
if [[ -r /sys/class/dmi/id/$field ]]; then
install -o root -g intake -m 0440 "/sys/class/dmi/id/$field" "/run/csr-intake/$field"
fi
done
if [[ -r /sys/firmware/dmi/tables/DMI ]]; then
install -o root -g intake -m 0440 /sys/firmware/dmi/tables/DMI /run/csr-intake/DMI
fi
install -d -m 0755 "$DATA"
part=$(blkid -L INTAKE_DATA || true)
if [[ -z "$part" ]]; then
echo 'CS-R ERROR: INTAKE_DATA not found; scans must not run.' >&2
else
if ! mountpoint -q "$DATA"; then
mount -o rw,nosuid,nodev "$part" "$DATA"
fi
for d in config database reports checklists inspections final-reports diagnostics; do
mkdir -p "$DATA/$d"
chown -R intake:intake "$DATA/$d"
done
chown intake:intake "$DATA"
[[ ! -f "$DATA/active-inspection.txt" ]] || chown intake:intake "$DATA/active-inspection.txt"
chmod 0750 "$DATA"
# Only provision when absent; the user can rotate credentials on persistent media.
if [[ -d /etc/csr-intake ]]; then
for f in inventory.json inventory-api-key workshop.json; do
if [[ -f /etc/csr-intake/$f && ! -e "$DATA/config/$f" ]]; then
install -o intake -g intake -m 0600 "/etc/csr-intake/$f" "$DATA/config/$f"
fi
done
fi
[[ ! -f "$DATA/config/inventory-api-key" ]] || chmod 0600 "$DATA/config/inventory-api-key"
# Workshop TLS certificates stay on persistent media, alongside inventory config.
# Preserve certificate and hostname verification for Python and curl.
if [[ -f "$DATA/config/inventory-server.crt" ]]; then
install -D -m 0644 "$DATA/config/inventory-server.crt" /etc/ca-certificates/trust-source/anchors/csr-inventory.crt
update-ca-trust
fi
fi
install -o intake -g intake -m 0644 /usr/local/bin/intake-login /home/intake/.bash_profile
# .bash_profile sourced by the user's real console login.

7
scanner/Cargo.lock generated Normal file
View File

@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "pc-intake"
version = "1.3.0"

11
scanner/Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "pc-intake"
version = "1.3.0"
edition = "2021"
[dependencies]
[profile.release]
lto = true
codegen-units = 1
strip = true

628
scanner/src/main.rs Normal file
View File

@@ -0,0 +1,628 @@
//! Offline Linux intake. No crates, shell commands, network, or write benchmarks.
use std::{env, fs, hint::black_box, path::{Path, PathBuf}, process::{self, Command}, thread, time::{Instant, SystemTime, UNIX_EPOCH}};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const WORK: u64 = 4_000_000;
#[derive(Debug)]
struct Disk { name: String, gib: u64, rotational: Option<bool>, removable: bool, model: String, health: String, smart_details: String }
#[derive(Debug)]
struct Battery { name: String, capacity_pct: Option<u64>, health_pct: Option<u64>, status: String }
#[derive(Debug)]
struct Bench { single_ms: f64, multi_ms: f64, threads: usize, single_runs_ms: Vec<f64>, parallel_runs_ms: Vec<f64> }
#[derive(Debug)]
struct CompileBench { compiler: String, median_ms: f64, runs_ms: Vec<f64>, source_functions: usize, status: String }
#[derive(Debug)]
struct DevBench { median_ms: f64, runs_ms: Vec<f64>, records: usize, checksum: u64 }
#[derive(Debug)]
struct MemoryBench { median_mib_s: f64, runs_mib_s: Vec<f64>, buffer_mib: usize, checksum: u64 }
#[derive(Debug)]
struct VideoBench { status: String, source: String, codec: String, width: u32, height: u32, frames: u64, seconds: f64, fps: f64, realtime_factor: f64, note: String }
#[derive(Debug)]
struct GraphicsBench { status: String, score: Option<u64>, note: String }
#[derive(Debug)]
struct PlaybackBench { status: String, elapsed_seconds: f64, note: String }
#[derive(Debug)]
struct Role { name: &'static str, verdict: &'static str, score: Option<u8>, reason: String }
#[derive(Debug)]
struct Report { playback: Option<PlaybackBench>, video: Option<VideoBench>, graphics: Option<GraphicsBench>, compilation: Option<CompileBench>, memory_bench: Option<MemoryBench>, developer: Option<DevBench>, sensors: Vec<(String, f64)>, network: Vec<(String,String)>, installed_ram_gib: Option<f64>, checklist: Vec<(String,String)>, asset: String, timestamp: u64, distro: String, kernel: String, vendor: String, model: String, cpu: String, threads: usize, mem_gib: f64, gpus: Vec<String>, disks: Vec<Disk>, batteries: Vec<Battery>, bench: Option<Bench>, roles: Vec<Role>, concerns: Vec<String>, health_gate: &'static str }
fn read(path: impl AsRef<Path>) -> String { fs::read_to_string(path).unwrap_or_default().trim().to_string() }
fn dmi_identity(key: &str) -> String { let value = read(format!("/sys/class/dmi/id/{key}")); if value.is_empty() { read(format!("/run/csr-intake/{key}")) } else { value } }
fn number(path: impl AsRef<Path>) -> Option<u64> { read(path).parse().ok() }
fn clean(s: &str) -> String { s.replace(|c: char| matches!(c, '\r' | '\n' | '\t'), " ").trim().to_string() }
fn esc(s: &str) -> String { let mut out = String::new(); for c in s.chars() { match c { '&' => out.push_str("&amp;"), '<' => out.push_str("&lt;"), '>' => out.push_str("&gt;"), '"' => out.push_str("&quot;"), '\'' => out.push_str("&#39;"), _ => out.push(c) } } out }
fn json(s: &str) -> String { let mut out=String::from("\""); for c in s.chars() { match c { '"'=>out.push_str("\\\""), '\\'=>out.push_str("\\\\"), '\n'=>out.push_str("\\n"), '\r'=>out.push_str("\\r"), '\t'=>out.push_str("\\t"), c if c < ' '=>out.push_str(&format!("\\u{:04x}",c as u32)), _=>out.push(c) } } out.push('"'); out }
fn csv(s: &str) -> String { format!("\"{}\"", s.replace('"', "\"\"")) }
fn distro() -> String { for line in read("/etc/os-release").lines() { if let Some(x)=line.strip_prefix("PRETTY_NAME=") { return x.trim_matches('"').to_owned(); } } "Unknown".into() }
fn cpu() -> String { for line in read("/proc/cpuinfo").lines() { if let Some((k,v))=line.split_once(':') { if k.trim()=="model name" || k.trim()=="Processor" { return clean(v); } } } "Unknown".into() }
fn mem_gib() -> f64 { for line in read("/proc/meminfo").lines() { if let Some(x)=line.strip_prefix("MemTotal:") { return x.split_whitespace().next().and_then(|v|v.parse::<f64>().ok()).unwrap_or(0.0)/1048576.0; } } 0.0 }
// DMI type 17 is optional and often root-only; do not mistake MemTotal for installed RAM.
fn installed_ram_gib() -> Option<f64> {
let bytes=fs::read("/sys/firmware/dmi/tables/DMI").or_else(|_| fs::read("/run/csr-intake/DMI")).ok()?;
let mut at=0usize; let mut total_mib=0u64; let mut count=0u64;
while at+4<=bytes.len() {
let kind=bytes[at];let len=bytes[at+1] as usize;
if len<4 || at+len>bytes.len(){break;}
if kind==17 && len>=14 {
let size=u16::from_le_bytes([bytes[at+12],bytes[at+13]]);
let mib=match size {
0 | 0xffff => 0,
0x7fff if len>=32 => u32::from_le_bytes([bytes[at+28],bytes[at+29],bytes[at+30],bytes[at+31]]) as u64,
0x7fff => 0,
n if n & 0x8000 != 0 => ((n & 0x7fff) as u64)/1024,
n => n as u64,
};
if mib>0 {total_mib+=mib;count+=1;}
}
let mut next=at+len;
while next+1<bytes.len() && !(bytes[next]==0 && bytes[next+1]==0){next+=1;}
if next+1>=bytes.len(){break;}
at=next+2;
if kind==127 {break;}
}
if count>0 {Some(total_mib as f64/1024.0)} else {None}
}
const CHECK_NAMES: [&str; 11] = ["display","keyboard","touchpad","wifi","audio","microphone","webcam","usb_ports","charging","thermals","stability"];
fn checklist(path:Option<&Path>) -> Result<Vec<(String,String)>,String> {
let mut result:Vec<(String,String)>=CHECK_NAMES.iter().map(|n|(n.to_string(),"UNKNOWN".into())).collect();
if let Some(path)=path {
for (i,line) in fs::read_to_string(path).map_err(|e|format!("Checklist: {e}"))?.lines().enumerate() {
let line=line.trim(); if line.is_empty() || line.starts_with('#'){continue;}
let (name,value)=line.split_once('=').ok_or_else(||format!("Checklist line {}: expected name=PASS/FAIL/UNKNOWN",i+1))?;
let name=name.trim();let value=value.trim().to_uppercase();
if !["PASS","FAIL","UNKNOWN","NOT_TESTED","NOT_APPLICABLE","UNAVAILABLE","SETUP_PROBLEM"].contains(&value.as_str()){return Err(format!("Checklist line {}: invalid value",i+1));}
let slot=result.iter_mut().find(|(n,_)|n==name).ok_or_else(||format!("Checklist line {}: unknown check {}",i+1,name))?;
slot.1=if ["NOT_TESTED","UNAVAILABLE","SETUP_PROBLEM"].contains(&value.as_str()) {"UNKNOWN".into()} else {value};
}
}
Ok(result)
}
fn gpu() -> Vec<String> {
let mut v=vec![];
if let Ok(entries)=fs::read_dir("/sys/bus/pci/devices") {
for e in entries.flatten() { let p=e.path(); let class=read(p.join("class"));
if class.starts_with("0x03") { let vendor=read(p.join("vendor")); let device=read(p.join("device")); let driver=fs::read_link(p.join("driver")).ok().and_then(|x|x.file_name().map(|s|s.to_string_lossy().into_owned())).unwrap_or_else(||"unbound".into());
v.push(format!("PCI {}:{} driver={}",vendor,device,driver));
}
}
}
v.sort(); v
}
fn disks() -> Vec<Disk> {
let mut found=vec![];
if let Ok(entries)=fs::read_dir("/sys/block") { for e in entries.flatten() {
let name=e.file_name().to_string_lossy().into_owned();
if !(name.starts_with("sd") || name.starts_with("nvme") || name.starts_with("vd") || name.starts_with("mmcblk")) {continue;}
let p=e.path(); let sectors=number(p.join("size")).unwrap_or(0);
if sectors==0 {continue;}
let rot=number(p.join("queue/rotational")).map(|x|x!=0);
let model=read(p.join("device/model"));
found.push(Disk {name, gib:sectors.saturating_mul(512)/1073741824, rotational:rot, removable:number(p.join("removable")).unwrap_or(0)!=0, model, health:"UNKNOWN".into(), smart_details:"Not assessed".into()});
}}
found.sort_by(|a,b|a.name.cmp(&b.name)); found
}
// Linux NVMe Get Log Page, SMART/Health Information (log identifier 0x02).
// No writes to the SSD. ioctl may require root/CAP_SYS_ADMIN; failures stay UNKNOWN.
// SATA, USB, and virtual disks are not falsely marked healthy.
#[cfg(target_os="linux")]
mod native_nvme {
use std::{fs::OpenOptions, os::{fd::AsRawFd, raw::{c_int,c_ulong}}, path::Path};
#[repr(C)]
#[derive(Default)]
struct NvmeAdminCmd {
opcode:u8, flags:u8, rsvd1:u16, nsid:u32, cdw2:u32, cdw3:u32,
metadata:u64, addr:u64, metadata_len:u32, data_len:u32,
cdw10:u32, cdw11:u32, cdw12:u32, cdw13:u32, cdw14:u32, cdw15:u32,
timeout_ms:u32, result:u32,
}
unsafe extern "C" {fn ioctl(fd:c_int, request:c_ulong, ...) -> c_int;}
const NVME_IOCTL_ADMIN_CMD:c_ulong=0xC0484E41;
fn le128(data:&[u8])->u128 {let mut bytes=[0u8;16];bytes.copy_from_slice(data);u128::from_le_bytes(bytes)}
pub fn read_health(name:&str)->Result<(String,String),String> {
let controller=name.get(..name[4..].find('n').map(|i|i+4).unwrap_or(name.len())).unwrap_or(name); // nvme0n1 -> nvme0
if !controller.starts_with("nvme") || !controller[4..].chars().all(|c|c.is_ascii_digit()) {
return Err("Not an NVMe namespace".into());
}
let device=format!("/dev/{controller}");
if !Path::new(&device).exists(){return Err(format!("{device} unavailable"));}
let file=OpenOptions::new().read(true).open(&device).map_err(|e|format!("open {device}: {e}"))?;
let mut buffer=[0u8;512];
let mut command=NvmeAdminCmd {opcode:0x02,nsid:0xffffffff,addr:buffer.as_mut_ptr() as u64,
data_len:512,cdw10:((128-1)<<16)|0x02,timeout_ms:5000,..Default::default()};
let result=unsafe {ioctl(file.as_raw_fd(),NVME_IOCTL_ADMIN_CMD,&mut command)};
if result!=0 {return Err(format!("NVMe admin ioctl returned {result} (may need root/permissions or unsupported device)"));}
let warnings=buffer[0];
let temp_k=u16::from_le_bytes([buffer[1],buffer[2]]);
let spare=buffer[3];let threshold=buffer[4];let used=buffer[5];
let media_errors=le128(&buffer[160..176]);
let status=if warnings!=0 || spare<threshold || media_errors>0 {"WARN"} else if used>=100 {"WARN"} else {"PASS"};
let temp=if temp_k>0 {format!("{} C",i32::from(temp_k)-273)} else {"UNKNOWN".into()};
let details=format!("NVMe SMART: critical_warning=0x{warnings:02x}; temperature={temp}; available_spare={spare}%; spare_threshold={threshold}%; percentage_used={used}%; media_errors={media_errors}. WARN requires review, not automatic failure.");
Ok((status.into(),details))
}
}
#[cfg(not(target_os="linux"))]
mod native_nvme {pub fn read_health(_: &str)->Result<(String,String),String>{Err("Linux only".into())}}
// Optional read-only smartctl transport for SATA/USB/NVMe. Never infer PASS from an exit code
// alone; smartctl exits nonzero for some warning conditions, so parse both stdout and stderr.
fn smartctl_health(name: &str) -> Result<(String,String),String> {
let path=format!("/dev/{name}");
let output=Command::new("smartctl").args(["-H","-A",&path]).output()
.map_err(|e|format!("smartctl unavailable ({e})"))?;
let content=String::from_utf8_lossy(&output.stdout).to_string();
let stderr=String::from_utf8_lossy(&output.stderr);
if content.trim().is_empty() {return Err(format!("smartctl gave no output: {}",stderr.chars().take(160).collect::<String>()));}
let upper=content.to_ascii_uppercase();
// smartctl health strings differ for ATA and SCSI; do not guess on unsupported bridges.
let passed=upper.contains("SMART OVERALL-HEALTH SELF-ASSESSMENT TEST RESULT: PASSED")
|| upper.contains("SMART HEALTH STATUS: OK")
|| upper.contains("SMART OVERALL-HEALTH SELF-ASSESSMENT TEST RESULT: OK");
let failed=upper.contains("SMART OVERALL-HEALTH SELF-ASSESSMENT TEST RESULT: FAILED")
|| upper.contains("SMART HEALTH STATUS: BAD")
|| upper.contains("SMART HEALTH STATUS: FAILED");
let interesting:Vec<String>=content.lines().filter(|l| {
let x=l.to_ascii_lowercase();
["overall-health","smart health status","reallocated_sector_ct","current_pending_sector","offline_uncorrectable","reported_uncorrect","percentage used","media and data integrity errors","critical warning"].iter().any(|w|x.contains(w))
}).take(14).map(|l|clean(l)).collect();
let details=format!("smartctl (-H -A) exit={}; {}",output.status,interesting.join(" | "));
// An overall PASSED flag is not full disk certification. Inspect problem attributes.
let bad_attr=content.lines().any(|line| {
let x=line.to_ascii_lowercase();
if ["reallocated_sector_ct","current_pending_sector","offline_uncorrectable","reported_uncorrect"].iter().any(|t|x.contains(t)) {
line.split_whitespace().last().and_then(|v|v.parse::<u64>().ok()).is_some_and(|n|n>0)
} else {false}
});
if failed || bad_attr {Ok(("WARN".into(),details))}
else if passed {Ok(("PASS".into(),details))}
else {Ok(("UNKNOWN".into(),details))}
}
fn assess_disks(disks:&mut [Disk]) {
for disk in disks {
if disk.name.starts_with("nvme") {
match native_nvme::read_health(&disk.name) {
Ok((state,details))=>{disk.health=state;disk.smart_details=details;},
Err(e)=>{disk.smart_details=format!("NVMe SMART unavailable: {e}");}
}
} else {
match smartctl_health(&disk.name) {
Ok((state,details))=>{disk.health=state;disk.smart_details=details;},
Err(e)=>{disk.smart_details=format!("SATA/USB SMART unavailable: {e}");}
}
}
}
}
// Actual FFmpeg file decode (not a display/playback or hardware acceleration test).
// Video test uses a user-provided real file; requires ffmpeg on the target machine.
fn ffmpeg_video(path:&Path) -> VideoBench {
let source=path.display().to_string();
let unknown=|status:String|VideoBench{status,source:source.clone(),codec:"UNKNOWN".into(),width:0,height:0,frames:0,seconds:0.0,fps:0.0,realtime_factor:0.0,note:"Real input decoded to null output; measures decode throughput, not dropped display frames".into()};
if !path.is_file() {return unknown("UNAVAILABLE (video file does not exist)".into());}
let start=Instant::now();
let output=Command::new("ffmpeg").args(["-hide_banner","-nostdin","-v","info","-i"]).arg(path)
.args(["-map","0:v:0","-an","-sn","-f","null","-"]).output();
let output=match output {Ok(o)=>o,Err(e)=>return unknown(format!("UNAVAILABLE (ffmpeg: {e})"))};
let log=String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
let detail = log.lines().last().unwrap_or("unknown error");
return unknown(format!(
"FAIL (ffmpeg exited {}; {})",
output.status,
detail.chars().take(160).collect::<String>()
));
}
// Extract frame count from the final ffmpeg progress line (informational only).
let progress=log.lines().rev().find(|l|l.contains("frame=")).unwrap_or("");
let frames=progress.split("frame=").nth(1).and_then(|x|x.trim_start().split_whitespace().next()).and_then(|x|x.parse::<u64>().ok()).unwrap_or(0);
let video_line=log.lines().find(|l|l.contains("Video:")).unwrap_or("");
let codec=video_line.split("Video:").nth(1).and_then(|x|x.split(',').next()).unwrap_or("UNKNOWN").trim().to_string();
let dimensions=video_line.split(|c:char| !c.is_ascii_alphanumeric() && c!='x').find(|x| {let mut it=x.split('x');it.next().and_then(|n|n.parse::<u32>().ok()).is_some() && it.next().and_then(|n|n.parse::<u32>().ok()).is_some()}).unwrap_or("");
let mut pair=dimensions.split('x');let width=pair.next().and_then(|x|x.parse().ok()).unwrap_or(0);let height=pair.next().and_then(|x|x.parse().ok()).unwrap_or(0);
let seconds=start.elapsed().as_secs_f64();
let fps=if seconds>0.0 {frames as f64/seconds} else {0.0};
// Estimated real-time factor based on reported input fps if known; not displayed playback.
let nominal=video_line.split(',').find_map(|x|{let x=x.trim();x.strip_suffix(" fps").and_then(|n|n.trim().parse::<f64>().ok())}).unwrap_or(0.0);
VideoBench{status:if frames>0 {"PASS".into()} else {"UNKNOWN (ffmpeg did not report frame count)".into()},source,codec,width,height,frames,seconds,fps,realtime_factor:if nominal>0.0 {fps/nominal} else {0.0},note:"Real media decoded by FFmpeg to null output; excludes display scheduling, sound, hardware acceleration validation and dropped frames".into()}
}
// On-screen playback check via mpv, when a graphical session and mpv are installed.
// A successful process exit means playback completed, not that there were no dropped frames.
fn mpv_playback(path:&Path)->PlaybackBench {
if !path.is_file() {return PlaybackBench{status:"UNAVAILABLE".into(),elapsed_seconds:0.0,note:"File missing".into()};}
let start=Instant::now();
let output=Command::new("mpv").args(["--no-config","--no-audio","--no-osc","--frames=300","--hwdec=auto","--vo=gpu","--really-quiet","--"]).arg(path).output();
match output {
Err(e)=>PlaybackBench{status:"UNAVAILABLE".into(),elapsed_seconds:0.0,note:format!("mpv unavailable: {e}")},
Ok(o)=>PlaybackBench{status:if o.status.success(){"COMPLETED"}else{"FAILED"}.into(),elapsed_seconds:start.elapsed().as_secs_f64(),note:format!("300-frame on-screen mpv run; exit={}. Dropped frames and display smoothness still require observation",o.status)}
}
}
// Optional benchmark, not a gaming FPS measurement. glmark2 requires usable display session.
fn graphics_benchmark()->GraphicsBench {
let output=Command::new("glmark2").arg("--off-screen").output();
match output {
Err(e)=>GraphicsBench{status:"UNAVAILABLE".into(),score:None,note:format!("glmark2 not installed or could not launch: {e}")},
Ok(o)=>{
let log=String::from_utf8_lossy(&o.stdout);
let score=log.lines().find_map(|line|line.trim().strip_prefix("glmark2 Score:").and_then(|v|v.trim().parse::<u64>().ok()));
GraphicsBench{status:if o.status.success()&&score.is_some(){"PASS"}else{"UNKNOWN"}.into(),score,note:format!("Off-screen OpenGL benchmark (not game FPS), process status {}",o.status)}
}
}
}
fn median(values:&mut [f64])->f64 {
values.sort_by(|a,b|a.total_cmp(b));
let m=values.len()/2;
if values.len()%2==1 {values[m]} else {(values[m-1]+values[m])/2.0}
}
// Read thermal sensors from hwmon. Labels are untrusted hardware strings and escaped in HTML.
fn temperatures() -> Vec<(String,f64)> {
let mut result=Vec::new();
if let Ok(chips)=fs::read_dir("/sys/class/hwmon") { for chip in chips.flatten() {
let p=chip.path(); let chipname=read(p.join("name"));
if let Ok(files)=fs::read_dir(&p) {for file in files.flatten() {
let filename=file.file_name().to_string_lossy().into_owned();
if !filename.starts_with("temp") || !filename.ends_with("_input") {continue;}
let stem=filename.trim_end_matches("_input");
if let Some(millidegrees)=number(file.path()) {
if millidegrees>180_000 {continue;}
let label=read(p.join(format!("{stem}_label")));
let name=if label.is_empty(){format!("{chipname}:{stem}")} else {format!("{chipname}:{label}")};
result.push((name,millidegrees as f64/1000.0));
}
}}
}}
result.sort_by(|a,b|a.0.cmp(&b.0));result
}
// Detect network devices and their Linux link state (does not imply internet connectivity).
fn network_devices() -> Vec<(String,String)> {
let mut result=Vec::new();
if let Ok(entries)=fs::read_dir("/sys/class/net") {for e in entries.flatten() {
let name=e.file_name().to_string_lossy().into_owned();
if name=="lo" {continue;}
let p=e.path();let link=read(p.join("operstate"));
let kind=if p.join("wireless").exists() || p.join("phy80211").exists() {"Wi-Fi"} else {"Network"};
result.push((name,format!("{kind}, link={link}; connectivity not tested")));
}}result.sort_by(|a,b|a.0.cmp(&b.0));result
}
fn batteries() -> Vec<Battery> {
let mut found=vec![];
if let Ok(entries)=fs::read_dir("/sys/class/power_supply") { for e in entries.flatten() {
let p=e.path(); if read(p.join("type"))!="Battery" {continue;}
let name=e.file_name().to_string_lossy().into_owned();
let full=number(p.join("energy_full")).or_else(||number(p.join("charge_full")));
let design=number(p.join("energy_full_design")).or_else(||number(p.join("charge_full_design")));
let health=full.zip(design).and_then(|(f,d)|if d>0 {Some((f.saturating_mul(100)/d).min(150))} else {None});
found.push(Battery{name,capacity_pct:number(p.join("capacity")),health_pct:health,status:read(p.join("status"))});
}} found
}
// Fixed-workload integer mixing (throughput proxy, NOT a standardized CPU benchmark).
fn cpu_work(iterations:u64) -> u64 { let mut x=0x243f6a8885a308d3u64; for i in 0..iterations {x=x.wrapping_add(i ^ 0x9e3779b97f4a7c15); x^=x>>27; x=x.wrapping_mul(0x3c79ac492ba7b653); x^=x>>33;} black_box(x) }
fn benchmark() -> Bench {
let threads=thread::available_parallelism().map(|n|n.get()).unwrap_or(1).min(16);
// Warm up the workload once to reduce first-run noise.
black_box(cpu_work(WORK / 4));
let mut singles=Vec::new();let mut parallels=Vec::new();
for _ in 0..5 {
let t=Instant::now();black_box(cpu_work(WORK));singles.push(t.elapsed().as_secs_f64()*1000.0);
let t=Instant::now();let hs:Vec<_>=(0..threads).map(|_|thread::spawn(||cpu_work(WORK))).collect();
for h in hs {let _=black_box(h.join());}
parallels.push(t.elapsed().as_secs_f64()*1000.0);
}
let mut s=singles.clone();let mut p=parallels.clone();
Bench{single_ms:median(&mut s),multi_ms:median(&mut p),threads,single_runs_ms:singles,parallel_runs_ms:parallels}
}
// Deterministic source-text scanning/indexing proxy; this is NOT a compilation benchmark.
// No compiler or external executable is needed on refurbished devices.
fn source_index_work(records: usize) -> u64 {
let mut checksum = 0xcbf29ce484222325u64;
let keywords = ["fn", "let", "impl", "struct", "pub", "match"];
for n in 0..records {
let line = format!("pub fn function_{n}(value: u64) -> u64 {{ let output = value.wrapping_mul({}); output }}", n % 7919 + 1);
for token in line.split(|c: char| !c.is_ascii_alphanumeric() && c != '_') {
if token.is_empty() { continue; }
let is_keyword = keywords.contains(&token);
let mut hash = 0xcbf29ce484222325u64;
for b in token.bytes() { hash = (hash ^ b as u64).wrapping_mul(0x100000001b3); }
checksum = checksum.rotate_left(7) ^ hash ^ u64::from(is_keyword);
}
}
black_box(checksum)
}
fn developer_benchmark() -> DevBench {
const RECORDS: usize = 16_384;
black_box(source_index_work(256));
let mut runs = Vec::new();
let mut checksum = 0;
for _ in 0..3 {
let t = Instant::now();
checksum = source_index_work(RECORDS);
runs.push(t.elapsed().as_secs_f64() * 1000.0);
}
let mut sorted = runs.clone();
DevBench { median_ms: median(&mut sorted), runs_ms: runs, records: RECORDS, checksum }
}
// Allocates 64 MiB; read-only timed passes over filled memory. No disk writes.
// This is a single-thread sequential READ proxy, not a standardized DRAM benchmark.
fn memory_benchmark() -> MemoryBench {
const MIB: usize = 1024 * 1024;
const BYTES: usize = 64 * MIB;
let elements = BYTES / std::mem::size_of::<u64>();
let data: Vec<u64> = (0..elements).map(|i| (i as u64).wrapping_mul(0x9e3779b97f4a7c15)).collect();
let mut speeds = Vec::new();
let mut checksum = 0u64;
for _ in 0..5 {
let start = Instant::now();
let sum = data.iter().fold(0u64, |sum, item| sum.wrapping_add(black_box(*item)));
black_box(sum);
checksum = sum;
let secs = start.elapsed().as_secs_f64().max(0.000_001);
speeds.push(64.0 / secs);
}
let mut sorted = speeds.clone();
MemoryBench { median_mib_s: median(&mut sorted), runs_mib_s: speeds, buffer_mib: 64, checksum }
}
fn cpu_variation_pct(b: &Bench) -> f64 {
if b.single_ms <= 0.0 { return 0.0; }
let lo = b.single_runs_ms.iter().copied().fold(f64::INFINITY, f64::min);
let hi = b.single_runs_ms.iter().copied().fold(0.0_f64, f64::max);
(hi - lo) / b.single_ms * 100.0
}
// Provisional fixed anchor points for the existing v1 synthetic workloads.
// Scores are NOT percentiles, validated application benchmarks, or donation approvals.
fn higher_is_better(value: f64, low: f64, high: f64) -> u8 {
if !value.is_finite() || value <= 0.0 { return 0; }
((100.0 * (value - low) / (high - low)).clamp(0.0, 100.0)).round() as u8
}
fn lower_is_better(value: f64, best: f64, worst: f64) -> u8 {
if !value.is_finite() || value <= 0.0 { return 0; }
((100.0 * (worst - value) / (worst - best)).clamp(0.0, 100.0)).round() as u8
}
// Optional real C compilation benchmark. The tool itself has no external runtime dependencies.
// Uses a compiler only when explicitly requested. All generated files are confined to a fresh
// temporary directory and removed even if the compiler fails. No generated code is executed.
fn compilation_benchmark() -> CompileBench {
const FUNCTIONS: usize = 320;
let compiler = ["cc", "gcc", "clang"].iter().find(|name|
Command::new(name).arg("--version").output().is_ok()
).map(|x|x.to_string());
let Some(compiler) = compiler else { return CompileBench { compiler:"none".into(), median_ms:0.0, runs_ms:vec![], source_functions:FUNCTIONS, status:"UNAVAILABLE (C compiler not installed)".into() }; };
let folder = env::temp_dir().join(format!("pc-intake-compile-{}-{}",process::id(),SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos()));
if let Err(e) = fs::create_dir(&folder) {return CompileBench {compiler,median_ms:0.0,runs_ms:vec![],source_functions:FUNCTIONS,status:format!("UNAVAILABLE (temporary directory: {e})")};}
let result = (|| -> Result<Vec<f64>,String> {
let source=folder.join("workload.c");
let mut contents=String::from("#include <stdint.h>\n");
for n in 0..FUNCTIONS { contents.push_str(&format!("uint64_t fn{n}(uint64_t x) {{ for (uint64_t j=0;j<96;j++) x=(x*6364136223846793005ULL+1442695040888963407ULL)^(x>>13); return x+{n}ULL; }}\n")); }
fs::write(&source,contents).map_err(|e|e.to_string())?;
let mut times=vec![];
for iteration in 0..3 {
let object=folder.join(format!("workload-{iteration}.o"));
let start=Instant::now();
let output=Command::new(&compiler).arg("-std=c11").arg("-O2").arg("-c").arg(&source).arg("-o").arg(&object).output().map_err(|e|e.to_string())?;
if !output.status.success() {return Err(format!("compiler exited {}: {}",output.status, String::from_utf8_lossy(&output.stderr).chars().take(240).collect::<String>()));}
let metadata=fs::metadata(&object).map_err(|e|e.to_string())?;
if metadata.len()==0 {return Err("empty compiler output".into());}
times.push(start.elapsed().as_secs_f64()*1000.0);
}
Ok(times)
})();
let _=fs::remove_dir_all(&folder);
match result {
Ok(mut runs) => {let med=median(&mut runs);CompileBench{compiler,median_ms:med,runs_ms:runs,source_functions:FUNCTIONS,status:"PASS".into()}},
Err(e) => CompileBench{compiler,median_ms:0.0,runs_ms:vec![],source_functions:FUNCTIONS,status:format!("UNAVAILABLE ({e})")},
}
}
fn grade_with_evidence(cpu: Option<&Bench>, dev: Option<&DevBench>, ram: Option<&MemoryBench>) -> Vec<Role> {
let Some(b) = cpu else {return ["Office","Student","Developer","Multimedia playback","Video creation","Gaming"].iter().map(|name|Role{name:*name,verdict:"NOT_ASSESSED",score:None,reason:"Benchmarks skipped (--quick) or unavailable".into()}).collect()};
let s=lower_is_better(b.single_ms, 12.0, 85.0);
let m=lower_is_better(b.multi_ms, 18.0, 450.0);
let office=(s as u16 * 7 + m as u16 * 3)/10;
let mut r=vec![Role{name:"Office",verdict:if office>=50 {"PROVISIONAL_CANDIDATE"} else if office>=25 {"LIMITED"} else {"NOT_RECOMMENDED"},score:Some(office as u8),reason:"Fixed-anchor CPU proxy score; verify real browser/office responsiveness".into()}];
r.push(Role{name:"Student",verdict:"NEEDS_VIDEO_CALL_TEST",score:None,reason:"CPU proxy available, but video calls/playback and peripherals untested".into()});
if let (Some(d),Some(mem))=(dev,ram) {
let ds=lower_is_better(d.median_ms, 15.0, 250.0);
let rs=higher_is_better(mem.median_mib_s, 1500.0, 20000.0);
let composite=(s as u32*30 + m as u32*30 + ds as u32*30 + rs as u32*10)/100;
r.push(Role{name:"Developer",verdict:if composite>=65 {"PROVISIONAL_STRONG_CANDIDATE"} else if composite>=40 {"PROVISIONAL_BASIC_CANDIDATE"} else {"LIMITED"},score:Some(composite as u8),reason:format!("CPU single {s}/100, parallel {m}/100, source-index proxy {ds}/100, memory-read proxy {rs}/100. Not real compilation; thresholds need fleet calibration")});
} else {
r.push(Role{name:"Developer",verdict:"NOT_ASSESSED",score:None,reason:"Source-index and memory measurements are required".into()});
}
for (name,reason) in [
("Multimedia playback","No decoded frames, dropped-frame or codec playback measurements"),
("Video creation","No verified software/hardware video encoding workload"),
("Gaming","No actual GPU frame-rate or game benchmark")
] {r.push(Role{name,verdict:"NOT_ASSESSED",score:None,reason:reason.into()});}
r
}
// Keep legacy gate-style grading API testable while replacing ungrounded decisions.
fn grade(single_ms:f64,multi_ms:f64,threads:usize)->Vec<Role>{
let b=Bench{single_ms,multi_ms,threads,single_runs_ms:vec![single_ms],parallel_runs_ms:vec![multi_ms]};
grade_with_evidence(Some(&b),None,None)
}
fn collect(asset:String, quick:bool, do_compile:bool, video_path:Option<&Path>, do_graphics:bool, do_playback:bool, checklist: Vec<(String,String)>) -> Report {
let threads=thread::available_parallelism().map(|n|n.get()).unwrap_or(1);
let mut disk=disks(); assess_disks(&mut disk); let bat=batteries(); let sensors=temperatures();let network=network_devices(); let mem=mem_gib(); let installed=installed_ram_gib();
let mut concerns=vec![];
if let Some(physical)=installed { if physical < 14.0 { concerns.push(format!("DMI reports {:.1} GiB installed; verify standardized 16 GB RAM", physical)); } }
if disk.is_empty() {concerns.push("No local block devices detected".into());}
if disk.iter().any(|d|d.rotational==Some(true)) {concerns.push("Rotating HDD detected; expected SSD".into());}
for (name,temp) in &sensors {if *temp>=90.0 {concerns.push(format!("High sensor reading {name}: {temp:.1} C; investigate cooling"));}}
for b in &bat {if let Some(h)=b.health_pct {if h<70 {concerns.push(format!("Battery {} reported {}% of design capacity",b.name,h));}}}
for d in &disk {if d.health!="PASS" {concerns.push(format!("Storage {} health {}: {}",d.name,d.health,d.smart_details));}}
for (name,state) in &checklist {if state!="PASS" && state!="NOT_APPLICABLE" {concerns.push(format!("Technician check {name}: {state}"));}}
if disk.is_empty() {concerns.push("No SSD available to certify".into());}
let bench=if quick {None} else {Some(benchmark())};
let developer=if quick {None} else {Some(developer_benchmark())};
let memory_bench=if quick {None} else {Some(memory_benchmark())};
if let Some(b)=&bench { if cpu_variation_pct(b)>35.0 {concerns.push(format!("CPU benchmark variation {:.1}% across runs; repeat after checking thermal/power conditions",cpu_variation_pct(b)));} }
let compilation=if !quick && do_compile {Some(compilation_benchmark())} else {None};
if let Some(c)=&compilation {if c.status!="PASS" {concerns.push(format!("Optional compilation benchmark: {}",c.status));}}
let video=video_path.map(ffmpeg_video);
let playback=if do_playback {video_path.map(mpv_playback)} else {None};
let graphics=if do_graphics {Some(graphics_benchmark())} else {None};
if let Some(v)=&video {if v.status!="PASS" {concerns.push(format!("Video decode test: {}",v.status));}}
let mut roles=grade_with_evidence(bench.as_ref(),developer.as_ref(),memory_bench.as_ref());
if let Some(c)=&compilation {if c.status=="PASS" {if let Some(dev)=roles.iter_mut().find(|r|r.name=="Developer") {dev.reason.push_str(&format!("; real C compile: {:.1} ms ({} functions, 3 runs, compiler {})",c.median_ms,c.source_functions,c.compiler));}}}
if let Some(v)=&video {if let Some(role)=roles.iter_mut().find(|r|r.name=="Multimedia playback") {role.verdict=if v.status=="PASS" && v.realtime_factor>=1.2 {"DECODE_CANDIDATE"} else {"DISPLAY_PLAYBACK_NOT_ASSESSED"};role.reason=format!("FFmpeg real-file decode: {} frames, {:.1} fps, {:.2}x realtime. Actual display playback and dropped frames not measured",v.frames,v.fps,v.realtime_factor);}}
if let Some(g)=&graphics {if let Some(role)=roles.iter_mut().find(|r|r.name=="Gaming") {role.reason=format!("OpenGL glmark2: {} score={:?}; real game FPS untested",g.status,g.score);}}
// Unknown SMART or missing functional checks must never mean 'pass'.
let gate=if checklist.iter().any(|(_,s)|s=="FAIL") || disk.iter().any(|d|d.health=="WARN") || disk.iter().any(|d|d.rotational==Some(true)) || bat.iter().any(|b|b.health_pct.is_some_and(|h|h<70)) || sensors.iter().any(|(_,t)|*t>=90.0) {
"REPAIR_OR_MANUAL_REVIEW_REQUIRED"
} else if disk.is_empty() || disk.iter().any(|d|d.health!="PASS") || checklist.iter().any(|(_,s)|s!="PASS" && s!="NOT_APPLICABLE") || installed.is_some_and(|g|g<14.0) {
"TECHNICIAN_AND_SMART_REVIEW_REQUIRED"
} else {"READY_FOR_FINAL_APPROVAL"};
Report{playback,video,graphics,compilation,memory_bench,developer,sensors,network,installed_ram_gib:installed,checklist,asset,timestamp:SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),distro:distro(),kernel:read("/proc/sys/kernel/osrelease"),vendor:read("/sys/class/dmi/id/sys_vendor"),model:read("/sys/class/dmi/id/product_name"),cpu:cpu(),threads,mem_gib:mem,gpus:gpu(),disks:disk,batteries:bat,bench,roles,concerns,health_gate:gate}
}
fn json_report(r:&Report) -> String {
let mut s=format!("{{\n \"schema_version\": 1,\n \"tool_version\": {},\n \"asset_id\": {},\n \"timestamp_unix\": {},\n \"distro\": {},\n \"kernel\": {},\n \"manufacturer\": {},\n \"model\": {},\n \"cpu\": {},\n \"logical_threads\": {},\n \"ram_gib\": {:.3},\n \"health_gate\": {},\n",json(VERSION),json(&r.asset),r.timestamp,json(&r.distro),json(&r.kernel),json(&r.vendor),json(&r.model),json(&r.cpu),r.threads,r.mem_gib,json(r.health_gate));
s.push_str(&format!(" \"serial_number\": {},\n \"system_uuid\": {},\n",json(&dmi_identity("product_serial")),json(&dmi_identity("product_uuid"))));
s.push_str(&format!(" \"chassis_type\": {},\n", json(&read("/sys/class/dmi/id/chassis_type"))));
let touchpad=fs::read_to_string("/proc/bus/input/devices").ok().map(|v|v.to_lowercase().contains("touchpad"));
let webcam=fs::read_dir("/sys/class/video4linux").ok().map(|mut e|e.next().is_some());
s.push_str(&format!(" \"capabilities\": {{\"touchpad\":{},\"webcam\":{}}},\n",touchpad.map(|v|v.to_string()).unwrap_or("null".into()),webcam.map(|v|v.to_string()).unwrap_or("null".into())));
s.push_str(" \"gpus\": [");for (i,g) in r.gpus.iter().enumerate(){if i>0{s.push(',');}s.push_str(&json(g));}s.push_str("],\n \"disks\": [");
for (i,d) in r.disks.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{{\"name\":{},\"size_gib\":{},\"rotational\":{},\"removable\":{},\"model\":{},\"smart_health\":{},\"smart_details\":{}}}",json(&d.name),d.gib,d.rotational.map(|x|x.to_string()).unwrap_or("null".into()),d.removable,json(&d.model),json(&d.health),json(&d.smart_details)));}
s.push_str("],\n \"temperatures_c\": [");
for (i,(name,value)) in r.sensors.iter().enumerate() {if i>0{s.push(',');}s.push_str(&format!("{{\"sensor\":{},\"celsius\":{:.2}}}",json(name),value));}
s.push_str("],\n \"network_devices\": [");
for (i,(name,status)) in r.network.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{{\"interface\":{},\"status\":{}}}",json(name),json(status)));}
s.push_str("],\n \"batteries\": [");for (i,b) in r.batteries.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{{\"name\":{},\"charge_percent\":{},\"health_percent\":{},\"status\":{}}}",json(&b.name),b.capacity_pct.map(|x|x.to_string()).unwrap_or("null".into()),b.health_pct.map(|x|x.to_string()).unwrap_or("null".into()),json(&b.status)));}
s.push_str("],\n \"benchmarks\": "); if let Some(b)=&r.bench {s.push_str(&format!("{{\"type\":\"rust_integer_mix_v1\",\"work_per_thread\":{},\"single_ms\":{:.4},\"parallel_ms\":{:.4},\"parallel_threads\":{}}}",WORK,b.single_ms,b.multi_ms,b.threads));
s.pop();s.push_str(",\"single_runs_ms\":[");for(i,v)in b.single_runs_ms.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{v:.4}"));}
s.push_str("],\"parallel_runs_ms\":[");for(i,v)in b.parallel_runs_ms.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{v:.4}"));}s.push_str("]}");
}else{s.push_str("null");}
s.push_str(",\n \"memory_benchmark\": ");
if let Some(b)=&r.memory_bench {
s.push_str(&format!("{{\"type\":\"sequential_read_v1\",\"buffer_mib\":{},\"median_mib_s\":{:.2},\"checksum\":{},\"runs_mib_s\":[", b.buffer_mib,b.median_mib_s,b.checksum));
for (i,v) in b.runs_mib_s.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{v:.2}"));}
s.push_str("]}");
} else {s.push_str("null");}
s.push_str(",\n \"developer_benchmark\": ");
if let Some(d)=&r.developer {
s.push_str(&format!("{{\"type\":\"source_index_v1\",\"records\":{},\"median_ms\":{:.4},\"checksum\":{},\"runs_ms\":[",d.records,d.median_ms,d.checksum));
for (i,v) in d.runs_ms.iter().enumerate() {if i>0{s.push(',');}s.push_str(&format!("{v:.4}"));}
s.push_str("]}");
} else {s.push_str("null");}
s.push_str(",\n \"compilation_benchmark\": ");
if let Some(c)=&r.compilation {s.push_str(&format!("{{\"status\":{},\"compiler\":{},\"median_ms\":{:.3},\"source_functions\":{},\"runs_ms\":[",json(&c.status),json(&c.compiler),c.median_ms,c.source_functions));for(i,v)in c.runs_ms.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{v:.3}"));}s.push_str("]}");} else {s.push_str("null");}
s.push_str(",\n \"playback_check\": ");
if let Some(v)=&r.playback {s.push_str(&format!("{{\"status\":{},\"elapsed_seconds\":{:.3},\"note\":{}}}",json(&v.status),v.elapsed_seconds,json(&v.note)));} else {s.push_str("null");}
s.push_str(",\n \"video_benchmark\": ");
if let Some(v)=&r.video {s.push_str(&format!("{{\"status\":{},\"source\":{},\"codec\":{},\"width\":{},\"height\":{},\"frames\":{},\"elapsed_seconds\":{:.3},\"decode_fps\":{:.2},\"realtime_factor\":{:.3},\"note\":{}}}",json(&v.status),json(&v.source),json(&v.codec),v.width,v.height,v.frames,v.seconds,v.fps,v.realtime_factor,json(&v.note)));} else {s.push_str("null");}
s.push_str(",\n \"graphics_benchmark\": ");
if let Some(g)=&r.graphics {s.push_str(&format!("{{\"status\":{},\"score\":{},\"note\":{}}}",json(&g.status),g.score.map(|v|v.to_string()).unwrap_or("null".into()),json(&g.note)));}else{s.push_str("null");}
s.push_str(",\n \"roles\": [");for (i,role) in r.roles.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{{\"name\":{},\"verdict\":{},\"score\":{},\"reason\":{}}}",json(role.name),json(role.verdict),role.score.map(|v|v.to_string()).unwrap_or("null".into()),json(&role.reason)));}
s.push_str("],\n \"concerns\": [");for (i,c) in r.concerns.iter().enumerate(){if i>0{s.push(',');}s.push_str(&json(c));}s.push_str("],\n \"installed_ram_gib\": ");
s.push_str(&r.installed_ram_gib.map(|x|format!("{:.3}",x)).unwrap_or("null".into()));
s.push_str(",\n \"technician_checks\": {");
for (i,(name,value)) in r.checklist.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{}:{}",json(name),json(value)));}
s.push_str("}\n}\n");s
}
fn html(r:&Report) -> String {
let mut s=format!("<!doctype html><html lang=\"en\"><meta charset=\"utf-8\"><title>PC Intake {}</title><style>body{{font:16px system-ui;max-width:850px;margin:3rem auto;padding:0 1rem;line-height:1.5}}table{{border-collapse:collapse;width:100%}}th,td{{text-align:left;border-bottom:1px solid #ddd;padding:.5rem}}.warning{{padding:1rem;background:#fff2dd}}</style><h1>PC Intake: {}</h1><p><strong>Scanner version:</strong> {}</p><p><strong>Release gate:</strong> {}</p><p>{} · {} · {:.1} GiB RAM</p><p><strong>CPU:</strong> {}</p><p><strong>OS:</strong> {} (kernel {})</p>",esc(&r.asset),esc(&r.asset),esc(VERSION),esc(r.health_gate),esc(&r.vendor),esc(&r.model),r.mem_gib,esc(&r.cpu),esc(&r.distro),esc(&r.kernel));
s.push_str("<h2>Disk health (native NVMe SMART where available)</h2><table><tr><th>Device</th><th>Capacity</th><th>Type</th><th>Health</th></tr>");for d in &r.disks{s.push_str(&format!("<tr><td>{}</td><td>{} GiB</td><td>{}</td><td>{}: {}</td></tr>",esc(&d.name),d.gib,if d.rotational==Some(true){"HDD"}else if d.rotational==Some(false){"SSD / non-rotating"}else{"Unknown"},esc(&d.health),esc(&d.smart_details)));}s.push_str("</table><h2>Graphics devices</h2><ul>");for g in &r.gpus{s.push_str(&format!("<li>{}</li>",esc(g)));}s.push_str("</ul><h2>Batteries</h2><ul>");for b in &r.batteries{s.push_str(&format!("<li>{}: charge {:?}%, capacity vs design {:?}%, status {}</li>",esc(&b.name),b.capacity_pct,b.health_pct,esc(&b.status)));}s.push_str("</ul><h2>CPU proxy benchmark</h2>");if let Some(b)=&r.bench{s.push_str(&format!("<p>Integer mix v1 median of five runs: single {:.2} ms, parallel {:.2} ms ({} threads). Lower is better; this is not an application benchmark.</p>",b.single_ms,b.multi_ms,b.threads));}else{s.push_str("<p>Not run (--quick)</p>");}
s.push_str("<h2>Memory bandwidth (single-thread read proxy)</h2>");
if let Some(b)=&r.memory_bench {s.push_str(&format!("<p>{:.0} MiB/s median of 5 runs reading {} MiB of RAM. Higher is better; not a full memory bandwidth test.</p>",b.median_mib_s,b.buffer_mib));}else{s.push_str("<p>Not run (--quick).</p>");}
s.push_str("<h2>Developer workload (source-indexing proxy)</h2>");
if let Some(d)=&r.developer {s.push_str(&format!("<p>{} generated source records scanned in {:.2} ms (median of 3). Not a compilation or IDE benchmark.</p>",d.records,d.median_ms));}else{s.push_str("<p>Not run (--quick).</p>");}
s.push_str("<h2>On-screen playback check (optional)</h2>");
if let Some(v)=&r.playback {s.push_str(&format!("<p>{} in {:.1}s. {}</p>",esc(&v.status),v.elapsed_seconds,esc(&v.note)));}else{s.push_str("<p>Not requested (--playback with --video-file PATH).</p>");}
s.push_str("<h2>Real media decode (optional)</h2>");
if let Some(v)=&r.video {s.push_str(&format!("<p>Status: {}. File: {}. Codec: {}. Resolution: {}x{}. {} frames in {:.2}s = {:.1} decode FPS ({:.2}x nominal real time). {}</p>",esc(&v.status),esc(&v.source),esc(&v.codec),v.width,v.height,v.frames,v.seconds,v.fps,v.realtime_factor,esc(&v.note)));}else{s.push_str("<p>Not requested (--video-file PATH).</p>");}
s.push_str("<h2>OpenGL benchmark (optional)</h2>");
if let Some(g)=&r.graphics {s.push_str(&format!("<p>{}, score: {:?}. {}</p>",esc(&g.status),g.score,esc(&g.note)));}else{s.push_str("<p>Not requested (--graphics).</p>");}
s.push_str("<h2>Optional real C compilation benchmark</h2>");
if let Some(c)=&r.compilation {s.push_str(&format!("<p>Status: {}. Compiler: {}. {} functions, median {:.2} ms over {} runs. Lower is better. Compiler version and system load affect comparability.</p>",esc(&c.status),esc(&c.compiler),c.source_functions,c.median_ms,c.runs_ms.len()));} else {s.push_str("<p>Not requested (add --compile). An installed C compiler is required only for this optional test.</p>");}
s.push_str(&format!("<h2>Memory</h2><p>Usable Linux RAM: {:.2} GiB. Installed DMI RAM: {}. Usable RAM is not the same as physically installed RAM.</p>",r.mem_gib,r.installed_ram_gib.map(|x|format!("{x:.2} GiB")).unwrap_or("UNKNOWN (DMI unavailable)".into())));
s.push_str("<h2>Temperature sensors (snapshot)</h2><table><tr><th>Sensor</th><th>Temperature</th></tr>");
for (name,temp) in &r.sensors {s.push_str(&format!("<tr><td>{}</td><td>{:.1} &deg;C</td></tr>",esc(name),temp));}s.push_str("</table><p>A single idle snapshot is not a load/thermal-throttling test.</p>");
s.push_str("<h2>Network interfaces</h2><table><tr><th>Interface</th><th>Link state</th></tr>");
for (name,status) in &r.network {s.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>",esc(name),esc(status)));}s.push_str("</table>");
s.push_str("<h2>Technician checklist</h2><table><tr><th>Check</th><th>Status</th></tr>");for (name,value) in &r.checklist{s.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>",esc(name),esc(value)));}s.push_str("</table>");
s.push_str("<h2>Provisional role guidance</h2><p>Scores are uncalibrated workload proxies, not a decision to donate. Empty scores mean not assessed.</p><table><tr><th>Use</th><th>Assessment</th><th>Proxy score</th><th>Limitations</th></tr>");for role in &r.roles{s.push_str(&format!("<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",esc(role.name),esc(role.verdict),role.score.map(|v|format!("{v}/100")).unwrap_or("—".into()),esc(&role.reason)));}s.push_str("</table><h2>Concerns</h2><div class=\"warning\"><ul>");for c in &r.concerns{s.push_str(&format!("<li>{}</li>",esc(c)));}s.push_str("</ul></div><p>READY_FOR_FINAL_APPROVAL still requires an authorized human decision.</p></html>");s
}
fn csv_report(r:&Report) -> String {
let single=r.bench.as_ref().map(|b|format!("{:.4}",b.single_ms)).unwrap_or_default();
let multi=r.bench.as_ref().map(|b|format!("{:.4}",b.multi_ms)).unwrap_or_default();
let disks=r.disks.iter().map(|d|format!("{}:{}GiB",d.name,d.gib)).collect::<Vec<_>>().join(";");
let gpus=r.gpus.join(";");
let concerns=r.concerns.join(";");
format!("tool_version,asset_id,timestamp_unix,manufacturer,model,cpu,ram_gib,disks,gpus,cpu_single_ms,cpu_parallel_ms,health_gate,concerns,installed_ram_gib,technician_failed,technician_unknown,developer_index_ms,cpu_single_variation_pct,memory_read_mib_s,office_proxy_score,developer_proxy_score,compile_median_ms,compile_status\n{}\n",[csv(VERSION),csv(&r.asset),r.timestamp.to_string(),csv(&r.vendor),csv(&r.model),csv(&r.cpu),format!("{:.3}",r.mem_gib),csv(&disks),csv(&gpus),single,multi,csv(r.health_gate),csv(&concerns),r.installed_ram_gib.map(|v|format!("{v:.3}")).unwrap_or_default(),r.checklist.iter().filter(|(_,s)|s=="FAIL").count().to_string(),r.checklist.iter().filter(|(_,s)|s=="UNKNOWN").count().to_string(),r.developer.as_ref().map(|d|format!("{:.4}",d.median_ms)).unwrap_or_default(),r.bench.as_ref().map(|b|format!("{:.1}",cpu_variation_pct(b))).unwrap_or_default(),r.memory_bench.as_ref().map(|b|format!("{:.2}",b.median_mib_s)).unwrap_or_default(),r.roles.iter().find(|x|x.name=="Office").and_then(|x|x.score).map(|v|v.to_string()).unwrap_or_default(),r.roles.iter().find(|x|x.name=="Developer").and_then(|x|x.score).map(|v|v.to_string()).unwrap_or_default(),r.compilation.as_ref().filter(|c|c.status=="PASS").map(|c|format!("{:.3}",c.median_ms)).unwrap_or_default(),csv(r.compilation.as_ref().map(|c|c.status.as_str()).unwrap_or("NOT_REQUESTED"))].join(","))
}
fn tool_status(name: &str) -> String {
let found = env::var_os("PATH").and_then(|paths| {
env::split_paths(&paths).find_map(|dir| {
let candidate=dir.join(name);
if candidate.is_file() { Some(candidate) } else { None }
})
});
match found {Some(p)=>format!("FOUND ({})",p.display()),None=>"NOT FOUND".into()}
}
fn self_check() {
println!("PC Intake v{VERSION} — environment diagnostics");
println!("Platform: {} / {}", env::consts::OS, env::consts::ARCH);
println!("DMI access: {}", if Path::new("/sys/class/dmi/id").exists(){"available"}else{"unavailable"});
println!("Block devices: {}", if Path::new("/sys/block").exists(){"available"}else{"unavailable"});
for (tool, purpose) in [("smartctl","SATA/USB SMART"),("ffmpeg","video decoding"),("mpv","on-screen playback"),("glmark2","OpenGL benchmark"),("cc","C compilation"),("gcc","C compilation"),("clang","C compilation")] {
println!("{tool}: {} — {purpose}", tool_status(tool));
}
println!("NOTE: FOUND does not prove a utility works or has permissions to access the device.");
println!("Run the binary from this release, not an older ./pc-intake elsewhere.");
}
fn usage() {println!("pc-intake v{VERSION}\nUsage: pc-intake --asset-id ID [--quick] [--output DIR] [--checklist FILE] [--compile] [--video-file PATH] [--playback] [--graphics]\n pc-intake --version | --self-check | --help\nOffline Linux intake; no third-party runtime dependencies.\n--quick: skip CPU work; disk SMART collection still attempted. No GPU/game or video playback benchmark is performed.\n--compile: opt-in C compilation test; requires cc/gcc/clang installed.");}
fn run() -> Result<(),String> {
let mut args=env::args().skip(1);let mut id=None;let mut out=PathBuf::from("reports");let mut quick=false;let mut do_compile=false;let mut do_graphics=false;let mut do_playback=false;let mut video_path:Option<PathBuf>=None;let mut checks_path:Option<PathBuf>=None;
while let Some(a)=args.next(){match a.as_str(){"--asset-id"=>id=Some(args.next().ok_or("--asset-id needs a value")?),"--output"=>out=PathBuf::from(args.next().ok_or("--output needs a directory")?),"--quick"=>quick=true,"--compile"=>do_compile=true,"--graphics"=>do_graphics=true,"--playback"=>do_playback=true,"--video-file"=>video_path=Some(PathBuf::from(args.next().ok_or("--video-file needs a file")?)),"--checklist"=>checks_path=Some(PathBuf::from(args.next().ok_or("--checklist needs a file")?)),"--version"|"-V"=>{println!("pc-intake v{VERSION}");return Ok(());},"--self-check"=>{self_check();return Ok(());},"--help"|"-h"=>{usage();return Ok(());},_=>return Err(format!("Unknown argument: {a}"))}}
if do_playback && video_path.is_none() {return Err("--playback requires --video-file PATH".into());}
let id=id.ok_or("Provide --asset-id (for example NPC-0042)")?;
if id.is_empty()||id.len()>64||!id.chars().all(|c|c.is_ascii_alphanumeric()||c=='-'||c=='_') {return Err("Asset ID must be 1-64 ASCII letters, digits, '-' or '_'".into());}
let checks=checklist(checks_path.as_deref())?;let report=collect(id.clone(),quick,do_compile,video_path.as_deref(),do_graphics,do_playback,checks);let folder=out.join(&id).join(format!("{}-{}",report.timestamp,process::id()));
fs::create_dir_all(&folder).map_err(|e|e.to_string())?;
fs::write(folder.join("report.json"),json_report(&report)).map_err(|e|e.to_string())?;
fs::write(folder.join("report.html"),html(&report)).map_err(|e|e.to_string())?;
fs::write(folder.join("summary.csv"),csv_report(&report)).map_err(|e|e.to_string())?;
println!("PC Intake v{VERSION}");
println!("Asset: {} | CPU: {} | RAM {:.1} GiB",id,report.cpu,report.mem_gib);
for d in &report.disks { println!("Disk {} ({} GiB): SMART {} — {}",d.name,d.gib,d.health,d.smart_details); }
println!("Release: {}",report.health_gate);
println!("Installed RAM (DMI): {}",report.installed_ram_gib.map(|x|format!("{x:.2} GiB")).unwrap_or("UNKNOWN".into()));
println!("Reports: {}",folder.display());Ok(())
}
fn main(){if !cfg!(target_os="linux"){eprintln!("Warning: Linux-only hardware inventory");}if let Err(e)=run(){eprintln!("Error: {e}");usage();process::exit(2)}}
#[cfg(test)] mod tests {use super::*;#[test]fn escapes(){assert_eq!(json("x\"\\\n"),"\"x\\\"\\\\\\n\"");assert_eq!(esc("<x&"),"&lt;x&amp;");}#[test]fn tests_role_gates(){let roles=grade(10.0,40.0,8);assert_eq!(roles[2].verdict,"NOT_ASSESSED");assert!(roles[0].score.is_some());assert_eq!(roles[5].verdict,"NOT_ASSESSED");}#[test]fn checklist_defaults_unknown(){let c=checklist(None).unwrap();assert_eq!(c.len(),11);assert!(c.iter().all(|(_,v)|v=="UNKNOWN"));}#[test]fn cpu_deterministic(){assert_eq!(cpu_work(100),cpu_work(100));}
#[test]fn source_index_deterministic(){assert_eq!(source_index_work(200),source_index_work(200));}
#[test]fn source_index_nonempty(){assert_ne!(source_index_work(200),0);}}
#[cfg(test)] mod new_tests {
use super::*;
#[test] fn score_anchors_are_bounded(){assert_eq!(lower_is_better(12.0,12.0,85.0),100);assert_eq!(lower_is_better(85.0,12.0,85.0),0);assert_eq!(higher_is_better(1500.0,1500.0,20000.0),0);assert_eq!(higher_is_better(20000.0,1500.0,20000.0),100);}
#[test] fn quick_mode_never_scores(){assert!(grade_with_evidence(None,None,None).iter().all(|r|r.score.is_none()));}
#[test] fn unmeasured_gpu_roles_have_no_score(){let b=Bench{single_ms:20.0,multi_ms:40.0,threads:8,single_runs_ms:vec![],parallel_runs_ms:vec![]};let r=grade_with_evidence(Some(&b),None,None);assert!(r.iter().filter(|x|matches!(x.name,"Gaming"|"Multimedia playback"|"Video creation")).all(|x|x.score.is_none()));}
#[test] fn median_works(){let mut x=[7.0,1.0,4.0,3.0,5.0];assert_eq!(median(&mut x),4.0);}
#[test] fn no_false_sata_pass(){let d=vec![Disk{name:"sda".into(),gib:128,rotational:Some(false),removable:false,model:"sample".into(),health:"UNKNOWN".into(),smart_details:String::new()}];assert_eq!(d[0].health,"UNKNOWN");}
}
#[cfg(test)] mod feature_tests {
use super::*;
#[test] fn temperatures_are_reasonable(){assert!(temperatures().iter().all(|(_,t)|*t>=0.0&&*t<=180.0));}
#[test] fn network_excludes_loopback(){assert!(!network_devices().iter().any(|(name,_)|name=="lo"));}
}
#[cfg(test)] mod v07_tests {
use super::*;
#[test] fn memory_work_has_data() { let m=memory_benchmark(); assert_eq!(m.runs_mib_s.len(),5); assert!(m.median_mib_s>0.0); assert_eq!(m.buffer_mib,64); }
#[test] fn physical_memory_is_not_usable_memory() { let r=mem_gib(); assert!(r>=0.0); }
}
#[cfg(test)] mod workflow_compatibility_tests {
use super::*;
#[test] fn accepts_gui_statuses_without_treating_unavailable_as_failure() {
let path=std::env::temp_dir().join(format!("csr-checklist-{}-{}",process::id(),SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()));
fs::write(&path,"display=PASS\nkeyboard=FAIL\ntouchpad=NOT_APPLICABLE\nwifi=SETUP_PROBLEM\nmicrophone=UNAVAILABLE\nwebcam=NOT_TESTED\n").unwrap();
let checks=checklist(Some(&path)).unwrap();fs::remove_file(path).unwrap();
let get=|key:&str|checks.iter().find(|(k,_)|k==key).unwrap().1.as_str();
assert_eq!(get("display"),"PASS");assert_eq!(get("keyboard"),"FAIL");assert_eq!(get("touchpad"),"NOT_APPLICABLE");assert_eq!(get("wifi"),"UNKNOWN");assert_eq!(get("microphone"),"UNKNOWN");assert_eq!(get("webcam"),"UNKNOWN");
}
}

22
scripts/build-iso.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Build in a new directory so old profiles and work directories are never reused.
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
DEST=${1:?Usage: bash scripts/build-iso.sh /absolute/path/to/new-build-directory}
[[ ! -e "$DEST" ]] || { echo 'Destination must not already exist.' >&2; exit 2; }
mkdir -p "$DEST"
DEST=$(cd "$DEST" && pwd)
bash "$ROOT/scripts/create-profile.sh" "$DEST/profile"
python3 "$ROOT/scripts/release-manifest.py" --verify-profile "$DEST/profile"
sudo mkarchiso -v -w "$DEST/work" -o "$DEST/out" "$DEST/profile"
python3 "$ROOT/scripts/release-manifest.py" --verify-profile "$DEST/profile"
shopt -s nullglob
isos=("$DEST"/out/*.iso)
[[ ${#isos[@]} == 1 ]] || { echo 'Expected exactly one ISO.' >&2; exit 1; }
bsdtar -tf "${isos[0]}" > "$DEST/iso-files.txt"
grep -qx 'arch/boot/x86_64/vmlinuz-linux' "$DEST/iso-files.txt"
grep -qx 'arch/boot/x86_64/initramfs-linux.img' "$DEST/iso-files.txt"
[[ $(stat -c %s "${isos[0]}") -lt 4294967295 ]] || { echo 'ISO exceeds FAT32 file size limit.' >&2; exit 1; }
sha256sum "${isos[0]}" > "$DEST/out/SHA256SUMS"
cp "$DEST/profile/airootfs/etc/csr-intake/release.json" "$DEST/out/release.json"
echo "ISO validated in $DEST/out. Physical UEFI/BIOS boot and hardware tests still required."

13
scripts/build-release.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
export CSR_BUILD_ID
CSR_BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id)
for component in scanner controller gui; do
cargo test --locked --manifest-path "$ROOT/$component/Cargo.toml"
cargo build --locked --release --manifest-path "$ROOT/$component/Cargo.toml"
done
python3 -m unittest discover -s "$ROOT/tests" -p 'test_*.py'
bash "$ROOT/tests/smoke.sh"
python3 "$ROOT/scripts/release-manifest.py" --create "$ROOT/build/release.json"
echo "Validated release: $CSR_BUILD_ID"

62
scripts/create-profile.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
DEST=${1:-$ROOT/build/profile}
[[ -d /usr/share/archiso/configs/releng ]] || { echo 'Install archiso first.' >&2; exit 1; }
[[ ! -e $DEST ]] || { echo "Profile already exists: $DEST" >&2; exit 1; }
bash "$ROOT/scripts/build-release.sh"
mkdir -p "$(dirname "$DEST")"
cp -a /usr/share/archiso/configs/releng "$DEST"
for pkg in cage seatd xorg-xwayland mesa vulkan-radeon vulkan-intel libglvnd fontconfig ttf-dejavu networkmanager curl ca-certificates sqlite smartmontools ffmpeg gcc glmark2 mpv pciutils util-linux e2fsprogs stress-ng memtester alsa-utils v4l-utils dmidecode python usbutils xdg-utils firefox; do
grep -qxF "$pkg" "$DEST/packages.x86_64" || echo "$pkg" >> "$DEST/packages.x86_64"
done
mkdir -p "$DEST/airootfs/usr/local/bin" "$DEST/airootfs/usr/local/lib/pc-intake" "$DEST/airootfs/etc/systemd/system/multi-user.target.wants"
install -m755 "$ROOT/scanner/target/release/pc-intake" "$DEST/airootfs/usr/local/bin/pc-intake"
install -m755 "$ROOT/controller/target/release/intake-controller" "$DEST/airootfs/usr/local/bin/intake-controller"
install -m755 "$ROOT/gui/target/release/intake-gui" "$DEST/airootfs/usr/local/bin/intake-gui"
install -m755 "$ROOT/scripts/intake-session.sh" "$DEST/airootfs/usr/local/bin/intake-session"
# The GUI must run in a real logind session, not a root system service attached to a TTY.
install -m755 "$ROOT/live/intake-prepare.sh" "$DEST/airootfs/usr/local/bin/intake-prepare"
install -m755 "$ROOT/live/intake-login.sh" "$DEST/airootfs/usr/local/bin/intake-login"
install -m644 "$ROOT/live/intake-prepare.service" "$DEST/airootfs/etc/systemd/system/intake-prepare.service"
mkdir -p "$DEST/airootfs/etc/systemd/system/getty@tty1.service.d"
install -m644 "$ROOT/live/autologin.conf" "$DEST/airootfs/etc/systemd/system/getty@tty1.service.d/autologin.conf"
ln -s ../intake-prepare.service "$DEST/airootfs/etc/systemd/system/multi-user.target.wants/intake-prepare.service"
rm -f "$DEST/airootfs/etc/systemd/system/getty@tty1.service"
rm -f "$DEST/airootfs/etc/systemd/system/multi-user.target.wants/pc-intake.service"
rm -f "$DEST/airootfs/etc/systemd/system/pc-intake.service"
# Archiso can override copied file modes. Record explicit permissions too.
cat >> "$DEST/profiledef.sh" <<'EOF'
file_permissions+=(
["/usr/local/bin/intake-session"]="0:0:755"
["/usr/local/bin/pc-intake"]="0:0:755"
["/usr/local/bin/intake-controller"]="0:0:755"
["/usr/local/bin/intake-gui"]="0:0:755"
["/usr/local/bin/intake-prepare"]="0:0:755"
["/usr/local/bin/intake-login"]="0:0:755"
)
EOF
for path in usr/local/bin/intake-session usr/local/bin/pc-intake usr/local/bin/intake-controller usr/local/bin/intake-gui usr/local/bin/intake-prepare usr/local/bin/intake-login; do
test -x "$DEST/airootfs/$path" || { echo "FATAL: missing executable $path" >&2; exit 1; }
done
# Optional operator-approved provisioning at build time; ISO secrets are extractable.
if [[ ${CSR_EMBED_INVENTORY_CONFIG:-0} == 1 ]]; then
: "${CSR_PRIVATE_CONFIG_DIR:?Set CSR_PRIVATE_CONFIG_DIR to local private folder}"
test -f "$CSR_PRIVATE_CONFIG_DIR/inventory.json"
test -f "$CSR_PRIVATE_CONFIG_DIR/inventory-api-key"
mkdir -p "$DEST/airootfs/etc/csr-intake"
install -m600 "$CSR_PRIVATE_CONFIG_DIR/inventory.json" "$DEST/airootfs/etc/csr-intake/inventory.json"
install -m600 "$CSR_PRIVATE_CONFIG_DIR/inventory-api-key" "$DEST/airootfs/etc/csr-intake/inventory-api-key"
cat >> "$DEST/profiledef.sh" <<'PERMS'
file_permissions+=( ["/etc/csr-intake/inventory.json"]="0:0:600" ["/etc/csr-intake/inventory-api-key"]="0:0:600" )
PERMS
fi
echo "Profile created at $DEST"
# Keep NetworkManager running in the live image for LAN/WLAN diagnostics.
mkdir -p "$DEST/airootfs/etc/systemd/system/multi-user.target.wants"
ln -sfn /usr/lib/systemd/system/NetworkManager.service "$DEST/airootfs/etc/systemd/system/multi-user.target.wants/NetworkManager.service"
mkdir -p "$DEST/airootfs/etc/csr-intake"
install -m644 "$ROOT/build/release.json" "$DEST/airootfs/etc/csr-intake/release.json"
python3 "$ROOT/scripts/release-manifest.py" --verify-profile "$DEST"

17
scripts/db-import.py Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import argparse, datetime, json, pathlib, sqlite3
parser=argparse.ArgumentParser()
parser.add_argument('database', type=pathlib.Path)
parser.add_argument('report', type=pathlib.Path)
args=parser.parse_args()
report=json.loads(args.report.read_text(encoding='utf-8'))
asset=report['asset_id']
if not isinstance(asset,str) or not asset or len(asset)>64: raise ValueError('Invalid asset ID')
args.database.parent.mkdir(parents=True,exist_ok=True)
with sqlite3.connect(args.database,timeout=30) as db:
db.execute('PRAGMA journal_mode=DELETE')
db.execute('PRAGMA synchronous=FULL')
db.execute('CREATE TABLE IF NOT EXISTS scans (scan_id INTEGER PRIMARY KEY, asset_id TEXT NOT NULL, scanned_at_unix INTEGER, imported_at_utc TEXT NOT NULL, tool_version TEXT, model TEXT, cpu TEXT, health_gate TEXT, json TEXT NOT NULL)')
db.execute('CREATE INDEX IF NOT EXISTS scans_asset ON scans(asset_id,scan_id DESC)')
db.execute('INSERT INTO scans(asset_id,scanned_at_unix,imported_at_utc,tool_version,model,cpu,health_gate,json) VALUES(?,?,?,?,?,?,?,?)', (asset,report.get('timestamp_unix'),datetime.datetime.now(datetime.timezone.utc).isoformat(),report.get('tool_version'),report.get('model'),report.get('cpu'),report.get('health_gate'),json.dumps(report,separators=(',',':'))))
print('Recorded',asset,'in',args.database)

10
scripts/db-view.py Normal file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python3
import pathlib, sqlite3, sys
p=pathlib.Path(sys.argv[1])
if not p.is_file():
print('No database scans yet.'); sys.exit(0)
with sqlite3.connect(f'file:{p}?mode=ro', uri=True) as db:
print('Total scans:', db.execute('SELECT count(*) FROM scans').fetchone()[0])
print('Unique assets:', db.execute('SELECT count(DISTINCT asset_id) FROM scans').fetchone()[0])
for asset, model, gate, timestamp in db.execute('SELECT asset_id, model, health_gate, scanned_at_unix FROM scans ORDER BY scan_id DESC LIMIT 15'):
print(f'{asset:14} | {(model or "unknown")[:28]:28} | {gate or "UNKNOWN"} | {timestamp}')

80
scripts/install-usb.sh Executable file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# DESTRUCTIVE: explicitly invoked, strongly confirmed, never automatic.
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
DEVICE=${1:-}
ISO=${2:-}
if [[ ! "$DEVICE" =~ ^/dev/[a-zA-Z0-9/_-]+$ || ! -f "$ISO" ]]; then
echo "Usage: sudo bash scripts/install-usb.sh /dev/sdb /absolute/path/to/pc-intake.iso" >&2; exit 2
fi
[[ $EUID -eq 0 ]] || { echo 'Run as root.' >&2; exit 2; }
for cmd in lsblk blkid sgdisk mkfs.vfat mkfs.ext4 grub-install mount umount findmnt wipefs dd sync awk bsdtar blockdev partprobe udevadm; do
command -v "$cmd" >/dev/null || { echo "Missing: $cmd" >&2; exit 2; }
done
DEVICE=$(readlink -f "$DEVICE")
[[ -b $DEVICE && $(lsblk -dn -o TYPE "$DEVICE") == disk ]] || { echo 'Target must be an entire block disk.' >&2; exit 2; }
[[ $(lsblk -dn -o TRAN "$DEVICE" | tr -d ' ') == usb ]] || { echo 'Refusing a device that is not identified as USB transport.' >&2; exit 2; }
root_source=$(findmnt -n -o SOURCE / || true)
root_disk=$(lsblk -no PKNAME "$root_source" 2>/dev/null | head -1 || true)
[[ -z "$root_disk" || "/dev/$root_disk" != "$DEVICE" ]] || { echo 'Refusing root system disk.' >&2; exit 2; }
if lsblk -nr -o MOUNTPOINT "$DEVICE" | grep -q '[^[:space:]]'; then
echo 'USB contains mounted partitions; unmount them before continuing.' >&2; lsblk "$DEVICE"; exit 2
fi
size_bytes=$(blockdev --getsize64 "$DEVICE")
((size_bytes >= 16*1024*1024*1024)) || { echo 'USB must be at least 16 GiB.' >&2; exit 2; }
iso_bytes=$(stat -c %s "$ISO")
((iso_bytes < 4294967295)) || { echo 'ISO exceeds the FAT32 single-file limit.' >&2; exit 2; }
model=$(lsblk -dn -o MODEL "$DEVICE" | xargs)
serial=$(lsblk -dn -o SERIAL "$DEVICE" | xargs)
echo '===== DESTRUCTIVE USB INSTALL ====='
echo "Device: $DEVICE Model: $model Serial: $serial"
echo "Capacity: $(lsblk -dn -o SIZE "$DEVICE") ISO: $ISO"
echo 'ALL PARTITIONS AND DATA ON THIS DEVICE WILL BE ERASED.'
read -rp "Type exactly ERASE $DEVICE to continue: " answer
[[ "$answer" == "ERASE $DEVICE" ]] || { echo 'Cancelled. Nothing erased.'; exit 1; }
# GPT: p1 BIOS boot (2MiB), p2 ESP FAT32 (7GiB), p3 ext4 remainder.
sgdisk --zap-all "$DEVICE"
wipefs -a "$DEVICE" >/dev/null
sgdisk -n 1:2048:+2M -t 1:ef02 -c 1:BIOS_GRUB "$DEVICE"
sgdisk -n 2:0:+7G -t 2:ef00 -c 2:INTAKE_BOOT "$DEVICE"
sgdisk -n 3:0:0 -t 3:8300 -c 3:INTAKE_DATA "$DEVICE"
partprobe "$DEVICE" || true
udevadm settle
if [[ "$DEVICE" =~ [0-9]$ ]]; then p2="${DEVICE}p2"; p3="${DEVICE}p3"; else p2="${DEVICE}2"; p3="${DEVICE}3"; fi
for i in {1..10}; do [[ -b "$p3" ]] && break; sleep 1; done
[[ -b "$p3" ]] || { echo 'Partitions not visible after repartition.' >&2; exit 1; }
mkfs.vfat -F32 -n INTAKE_BOOT "$p2"
mkfs.ext4 -F -L INTAKE_DATA "$p3"
TMP=$(mktemp -d)
cleanup(){ umount "$TMP/boot" 2>/dev/null || true; umount "$TMP/data" 2>/dev/null || true; rm -rf "$TMP"; }
trap cleanup EXIT
mkdir -p "$TMP/boot" "$TMP/data"
mount "$p2" "$TMP/boot"; mount "$p3" "$TMP/data"
mkdir -p "$TMP/boot/boot/grub" "$TMP/data/reports" "$TMP/data/database" "$TMP/data/checklists" "$TMP/data/reference"
cp "$ISO" "$TMP/boot/pc-intake.iso"
# Copy video reference files separately under INTAKE_DATA/reference (optional).
cat > "$TMP/boot/boot/grub/grub.cfg" <<'GRUBCFG'
set timeout=5
set default=0
menuentry 'PC Intake Live USB (offline technician mode)' {
search --no-floppy --label INTAKE_BOOT --set=root
set isofile='/pc-intake.iso'
loopback loop $isofile
linux (loop)/arch/boot/x86_64/vmlinuz-linux img_dev=/dev/disk/by-label/INTAKE_BOOT img_loop=$isofile archisobasedir=arch loglevel=3
initrd (loop)/arch/boot/x86_64/initramfs-linux.img
}
menuentry 'Reboot' { reboot }
menuentry 'Power off' { halt }
GRUBCFG
# Ensure expected kernel layout exists *in the built ISO* before proceeding.
if ! bsdtar -tf "$ISO" 2>/dev/null | grep -qx 'arch/boot/x86_64/vmlinuz-linux'; then
echo 'ISO layout does not match GRUB config (kernel missing). Aborting bootloader setup.' >&2; exit 1
fi
if ! bsdtar -tf "$ISO" 2>/dev/null | grep -qx 'arch/boot/x86_64/initramfs-linux.img'; then
echo 'ISO layout does not match GRUB config (initramfs missing).' >&2; exit 1
fi
grub-install --target=x86_64-efi --efi-directory="$TMP/boot" --boot-directory="$TMP/boot/boot" --removable --no-nvram --recheck
grub-install --target=i386-pc --boot-directory="$TMP/boot/boot" --recheck "$DEVICE"
sync
echo "USB is prepared. Results will be saved to $p3 (INTAKE_DATA)."
echo 'Unmount complete; boot a test PC with UEFI or legacy BIOS support.'

28
scripts/intake-session.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -Eeuo pipefail
DATA=/mnt/intake-data
mkdir -p "$DATA"
if ! mountpoint -q "$DATA"; then
disk=$(blkid -L INTAKE_DATA || true)
if [[ -z "$disk" ]]; then echo 'FATAL: INTAKE_DATA partition missing. No scan will run.'; exec bash; fi
mount -o rw,nosuid,nodev "$disk" "$DATA" || { echo 'FATAL: cannot mount persistent storage'; exec bash; }
fi
# First boot: provision Inventory config to the persistent data partition.
mkdir -p "$DATA/config"
if [[ -d /etc/csr-intake ]]; then
for f in inventory.json inventory-api-key; do
if [[ -f "/etc/csr-intake/$f" && ! -e "$DATA/config/$f" ]]; then
install -m600 "/etc/csr-intake/$f" "$DATA/config/$f"
fi
done
fi
export XDG_RUNTIME_DIR=/run/pc-intake-runtime
mkdir -p "$XDG_RUNTIME_DIR"
chmod 0700 "$XDG_RUNTIME_DIR"
export WLR_RENDERER_ALLOW_SOFTWARE=1
if command -v cage >/dev/null && [[ -x /usr/local/bin/intake-gui ]]; then
echo "Launching graphical intake station..."
cage -s -- /usr/local/bin/intake-gui --data "$DATA"
echo "Graphical interface exited. Starting terminal fallback." >&2
fi
exec /usr/local/bin/intake-controller --data "$DATA"

424
scripts/inventory-sync.py Normal file
View File

@@ -0,0 +1,424 @@
#!/usr/bin/env python3
"""Offline-first bridge with atomic server-side hardware registration.
Stdlib only; server assigns numbers and immutable reports stay unchanged.
"""
import argparse
import datetime as dt
import hashlib
import time
import fcntl
import json
import os
from pathlib import Path
import re
import sqlite3
import ssl
import socket
import uuid
import sys
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_DATA = Path('/mnt/intake-data')
def identity_value(value):
value = str(value or '').strip().casefold()
if value in ('', 'unknown', 'none', 'default string', 'to be filled by o.e.m.',
'not specified', 'system serial number', 'not applicable', 'n/a'):
return ''
if not value.strip('0f- '):
return ''
return value
def hardware_id(report):
raw_uuid = identity_value(report.get('system_uuid'))
try:
token = uuid.UUID(raw_uuid)
if token.int not in (0, (1 << 128) - 1):
return 'dmi-uuid:' + str(token)
except (ValueError, AttributeError):
pass
serial = identity_value(report.get('serial_number'))
vendor = identity_value(report.get('manufacturer'))
if serial and vendor:
payload = json.dumps([vendor, serial], ensure_ascii=True, separators=(',', ':'))
return 'dmi-serial-sha256:' + hashlib.sha256(payload.encode()).hexdigest()
raise ValueError('Keine zuverlässige Hardware-Kennung im Bericht. Mit der aktuellen USB-Version Hardware erneut erfassen; alte Berichte ohne Kennung werden nicht automatisch zugeordnet.')
def hardware_aliases(report):
primary = hardware_id(report)
aliases = [primary]
try:
serial = hardware_id({**report, 'system_uuid': ''})
if serial not in aliases:
aliases.append(serial)
except ValueError:
pass
return aliases
def device_kind(report):
chassis = str(report.get('chassis_type', ''))
if chassis == '13':
return 'AiO'
if chassis in ('8', '9', '10', '11', '14', '30', '31', '32') or report.get('batteries'):
return 'Laptop'
return 'Desktop'
def map_fields(report):
values = {}
for name in ('cpu', 'model'):
value = report.get(name)
if isinstance(value, str) and value.strip() and value.upper() != 'UNKNOWN':
values[name] = value.strip()[:200]
# Physically installed RAM, not Linux's usable memory (affected by iGPU reservations).
ram = report.get('installed_ram_gib')
if isinstance(ram, (int, float)) and not isinstance(ram, bool) and 1 <= ram <= 2048:
values['ram_gb'] = round(ram)
disks = report.get('disks') or []
ssd, hdd = [], []
for disk in disks:
if disk.get('removable') is not False:
continue
gib = disk.get('size_gib')
if not isinstance(gib, (int, float)) or isinstance(gib, bool) or not 1 <= gib <= 100000:
continue
# The kernel rotating flag is not reliable on USB drives; USB disks are excluded above.
(hdd if disk.get('rotational') is True else ssd if disk.get('rotational') is False else []).append(gib)
if ssd:
values['ssd_gb'] = round(sum(ssd) * (1024 ** 3) / 1e9)
if hdd:
values['hdd_gb'] = round(sum(hdd) * (1024 ** 3) / 1e9)
return values
def init_db(db):
db.execute('PRAGMA busy_timeout=10000')
db.execute('CREATE TABLE IF NOT EXISTS device_links (hwid TEXT PRIMARY KEY, inventory_number INTEGER NOT NULL)')
db.execute('CREATE TABLE IF NOT EXISTS inventory_queue (report_sha256 TEXT PRIMARY KEY, report_path TEXT NOT NULL, asset_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT \'PENDING\', attempts INTEGER NOT NULL DEFAULT 0, last_attempt INTEGER, next_attempt INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT \'\')')
db.execute('CREATE TABLE IF NOT EXISTS inventory_sync (report_sha256 TEXT PRIMARY KEY, asset_id TEXT NOT NULL, inventory_number INTEGER NOT NULL, status TEXT NOT NULL, last_attempt_utc TEXT, response TEXT)')
def api_request(base, key, method, suffix, payload=None, timeout=12, ca_file=None):
body = None if payload is None else json.dumps(payload).encode('utf-8')
url = base.rstrip('/') + '/api/v1/' + suffix.lstrip('/')
request = urllib.request.Request(url, data=body, method=method, headers={
'Authorization': 'Api-Key ' + key,
'Accept': 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'pc-intake-usb/1.4',
})
context = ssl.create_default_context()
if ca_file:
context.load_verify_locations(cafile=str(ca_file))
with urllib.request.urlopen(request, timeout=timeout, context=context) as res:
return json.loads(res.read(1024 * 1024).decode('utf-8'))
def credentials(config):
base = config.get('url', '')
if not base: raise ValueError('Inventory URL is not configured; reports remain offline')
parsed = urllib.parse.urlparse(base)
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ValueError('Inventory URL must be an HTTPS origin (e.g. https://inventory.example.org)')
key = os.environ.get('CSR_API_KEY', '')
if not key:
secret = Path(config.get('key_file', '/mnt/intake-data/config/inventory-api-key'))
key = secret.read_text(encoding='utf-8').strip()
if not key:
raise ValueError('No API key configured')
return base, key
def connection_error(ex):
# Never include server responses, request headers, or raw URLs in errors.
if isinstance(ex, urllib.error.HTTPError):
hints = {401: 'API key missing, expired or invalid', 403: 'API key lacks permission',
404: 'Inventory API endpoint not found. Server update required for hardware registration', 409: 'Hardware identity or revision conflict. Check existing or archived records on the server'}
return f'HTTP {ex.code}: {hints.get(ex.code, "Inventory server rejected the request")}'
reason = ex.reason if isinstance(ex, urllib.error.URLError) else ex
if isinstance(reason, ssl.SSLCertVerificationError):
return 'TLS certificate verification failed. Check config/inventory-server.crt, server address and system clock.'
if isinstance(reason, ssl.SSLError):
return 'TLS connection failed. Check the server HTTPS configuration.'
if isinstance(reason, socket.gaierror):
return 'DNS lookup failed. Check Wi-Fi, DNS and inventory server address.'
if isinstance(reason, (TimeoutError, socket.timeout)):
return 'Connection timed out. Connect Wi-Fi and check the inventory server is reachable.'
if isinstance(reason, ConnectionRefusedError):
return 'Connection refused. Check the inventory server address, port and service.'
if isinstance(ex, urllib.error.URLError):
return 'Inventory server unreachable. Check Wi-Fi, network routing and server address.'
if isinstance(ex, FileNotFoundError):
return 'Inventory API key or certificate file is missing. Check the files in config/.'
if isinstance(ex, ValueError):
return str(ex)[:200]
return type(ex).__name__
def check_connection(config):
base, key = credentials(config)
tls = {'ca_file': config['ca_file']} if config.get('ca_file') else {}
api_request(base, key, 'GET', 'choices/', **tls)
return 'Inventory connection OK: HTTPS certificate and API key verified. No records uploaded.'
def sync_one(report_path, config, db, dry_run=False):
raw = report_path.read_bytes()
sha = hashlib.sha256(raw).hexdigest()
report = json.loads(raw)
asset = report['asset_id']
hwid = hardware_id(report)
values = map_fields(report)
done = db.execute('SELECT status FROM inventory_sync WHERE report_sha256=?', (sha,)).fetchone()
if done and done[0] == 'SYNCED':
row = db.execute('SELECT inventory_number FROM inventory_sync WHERE report_sha256=?', (sha,)).fetchone()
return 'ALREADY_SYNCED', row[0]
if not values:
return 'NO_VERIFIED_FIELDS', None
if dry_run:
print(json.dumps({'hwid': hwid, 'number': 'assigned by server', 'changes': values}, indent=2, ensure_ascii=False))
return 'DRY_RUN', None
base, key = credentials(config)
tls = {'ca_file': config['ca_file']} if config.get('ca_file') else {}
# Resolve atomically on the server; local asset labels never select a server record.
current = api_request(base, key, 'POST', 'devices/resolve/',
{'hwid': hwid, 'aliases': hardware_aliases(report), 'device_kind': device_kind(report), 'model': values.get('model', '')}, **tls)
number = current.get('number')
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
raise ValueError('Server did not return a valid inventory number')
with db:
db.execute('INSERT INTO device_links(hwid,inventory_number) VALUES(?,?) ON CONFLICT(hwid) DO UPDATE SET inventory_number=excluded.inventory_number', (hwid, number))
revision = current.get('revision')
if not isinstance(revision, int) or revision < 1:
raise ValueError('Server did not return a valid revision')
# Reject suspicious identity collisions instead of overwriting another model.
existing_model = (current.get('model') or '').strip()
if existing_model and values.get('model') and existing_model.casefold() != values['model'].casefold():
raise ValueError(f'MODEL_MISMATCH: inventory #{number} is {existing_model!r}; scan is {values["model"]!r}')
# Avoid unnecessary revisions when all mapped values already match.
actual = {k: v for k, v in values.items() if current.get(k) != v}
if current.get('stage') not in (None, 'INTAKE'): actual = {}
if not actual:
status = 'SYNCED'
else:
updated = api_request(base, key, 'PATCH', f'devices/{number}/', {**actual, 'expected_revision': revision}, **tls)
if not isinstance(updated.get('revision'), int) or updated['revision'] <= revision:
raise ValueError('Server did not acknowledge the new revision; retry will recheck current values')
status = 'SYNCED'
now = dt.datetime.now(dt.timezone.utc).isoformat()
with db:
db.execute('INSERT INTO inventory_sync(report_sha256,asset_id,inventory_number,status,last_attempt_utc,response) VALUES(?,?,?,?,?,?) ON CONFLICT(report_sha256) DO UPDATE SET status=excluded.status,last_attempt_utc=excluded.last_attempt_utc,response=excluded.response', (sha, asset, number, status, now, 'OK'))
return status, number
def discover_queue(root, db):
"""Discover immutable reports even when no inventory connection is configured."""
for path in sorted((root / 'reports').glob('*/*/report.json')):
if not path.resolve().is_relative_to((root / 'reports').resolve()):
continue
try:
raw = path.read_bytes()
report = json.loads(raw)
asset = report['asset_id']
sha = hashlib.sha256(raw).hexdigest()
done = db.execute('SELECT status FROM inventory_sync WHERE report_sha256=?', (sha,)).fetchone()
with db:
db.execute('INSERT OR IGNORE INTO inventory_queue(report_sha256,report_path,asset_id,status) VALUES(?,?,?,?)', (sha, str(path.resolve()), asset, 'SYNCED' if done and done[0] == 'SYNCED' else 'PENDING'))
except (OSError, ValueError, KeyError):
continue
def process_queue(root, db, config, retry=False, selected=None, retry_seconds=60):
discover_queue(root, db)
failures = 0
rows = db.execute("SELECT report_sha256, report_path, attempts, next_attempt FROM inventory_queue WHERE status != 'SYNCED' ORDER BY report_path").fetchall()
for sha, raw_path, attempts, next_attempt in rows:
path = Path(raw_path)
if selected and path.resolve() != selected.resolve():
continue
if not retry and next_attempt > int(time.time()):
continue
stamp = int(time.time())
# Persist before the request; interrupted uploads can be retried with the same report hash.
with db:
db.execute("UPDATE inventory_queue SET status='UPLOADING', attempts=attempts+1, last_attempt=? WHERE report_sha256=?", (stamp, sha))
try:
if not path.resolve().is_relative_to((root / 'reports').resolve()):
raise ValueError('Report path outside reports directory')
if hashlib.sha256(path.read_bytes()).hexdigest() != sha:
raise ValueError('Queued report changed; original upload cancelled')
result, number = sync_one(path, config, db)
if result not in ('SYNCED', 'ALREADY_SYNCED'):
raise ValueError(result)
with db:
db.execute("UPDATE inventory_queue SET status='SYNCED', error='', next_attempt=0 WHERE report_sha256=?", (sha,))
if db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='scans'").fetchone():
db.execute("UPDATE scans SET sync_status='SYNCED', synced_inventory_id=? WHERE report_path=?", (str(number), str(path)))
print(f'{path.parent.parent.name}: SYNCED -> #{number}')
except (OSError, ValueError, KeyError, urllib.error.URLError) as ex:
failures += 1
# Error strings deliberately exclude URLs, API keys and raw server responses.
error = connection_error(ex)
delay = min(3600, retry_seconds * (2 ** min(attempts, 6)))
with db:
db.execute("UPDATE inventory_queue SET status='ERROR', error=?, next_attempt=? WHERE report_sha256=?", (error, stamp + delay, sha))
print(f'{path.parent.parent.name}: {error}', file=sys.stderr)
return failures
def station_identity():
def read(name):
for folder in ['/sys/class/dmi/id', '/run/csr-intake']:
try:
value = (Path(folder)/name).read_text().strip()
if value: return value
except OSError: pass
return ''
return {'system_uuid':read('product_uuid'),'serial_number':read('product_serial'),
'manufacturer':read('sys_vendor'),'model':read('product_name'),'chassis_type':read('chassis_type')}
def station_config(root):
config=json.loads((root/'config/inventory.json').read_text())
config.setdefault('key_file',str(root/'config/inventory-api-key'))
if (root/'config/inventory-server.crt').exists():config.setdefault('ca_file',str(root/'config/inventory-server.crt'))
return config
def station_api(config,method,suffix,payload=None):
base,key=credentials(config)
return api_request(base,key,method,suffix,payload,**({'ca_file':config['ca_file']} if config.get('ca_file') else {}))
def station_resolve(config,report):
return station_api(config,'POST','devices/resolve/',{'hwid':hardware_id(report),'aliases':hardware_aliases(report),
'device_kind':device_kind(report),'model':report.get('model','')})
def station_save(path,value):
path.parent.mkdir(parents=True,exist_ok=True)
tmp=path.with_suffix('.new')
with tmp.open('w') as f:
json.dump(value,f,ensure_ascii=False);f.flush();os.fsync(f.fileno())
os.replace(tmp,path)
def queue_inspection(root,path,phase):
inspection=json.loads(path.read_text())
ident=inspection['identity']
identity={'system_uuid':ident.get('uuid'),'serial_number':ident.get('serial'),
'manufacturer':ident.get('manufacturer'),'model':ident.get('model')}
# An immutable report provides chassis information, not the USB host's hardware.
if inspection.get('report'): identity.update({k:inspection['report'].get(k,identity.get(k)) for k in ['system_uuid','serial_number','manufacturer','model','chassis_type','batteries']})
results={k:{'outcome':v['outcome'],'notes':v.get('notes',''),'history':v.get('history',[]),'evidence':v.get('evidence','')} for k,v in inspection['checks'].items()}
body={'hwid':hardware_id(identity),'phase':phase,'technician':inspection.get('technician',''),
'notes':inspection.get('notes',''),'results':results}
digest=hashlib.sha256(json.dumps(body,sort_keys=True).encode()).hexdigest()
body['request_id']=str(uuid.uuid5(uuid.NAMESPACE_URL,'csr-inspection:'+inspection['asset']+':'+str(inspection['revision'])+':'+str(inspection['updated'])+':'+digest))
file=root/'outbox/inspections'/f"{inspection['updated']:020}-{inspection['revision']:020}-{body['request_id']}.json"
receipt=root/'outbox/sent'/file.name
if not file.exists() and not receipt.exists():station_save(file,{'identity':identity,'body':body})
return file
def flush_inspections(root,config):
latest=None
for path in sorted((root/'outbox/inspections').glob('*.json')):
event=json.loads(path.read_text());device=station_resolve(config,event['identity'])
body={**event['body'],'expected_revision':device['revision']}
latest=station_api(config,'POST',f"devices/{device['number']}/inspection/",body)
done=root/'outbox/sent'/path.name;done.parent.mkdir(parents=True,exist_ok=True)
os.replace(path,done)
return latest
def run_station(root,action,report_path):
# Recover the last saved snapshot even if power was lost before a worker queued it.
# These files may belong to a different PC previously visited by this USB.
if action in ['boot','flush']:
for saved in sorted((root/'inspections').glob('*.json')):
inspection=json.loads(saved.read_text())
if inspection.get('sync_phase'):
queue_inspection(root,saved,inspection['sync_phase'])
# Enqueue before reading config/network so interruption and offline use preserve results.
if action in ['start','progress','complete','queue']:
if not report_path:raise ValueError('Inspection file is required')
phase={'start':'STARTED','progress':'IN_PROGRESS','complete':'COMPLETE'}.get(action)
if phase is None: phase=json.loads(report_path.read_text()).get('sync_phase') or 'IN_PROGRESS'
queue_inspection(root,report_path,phase)
if action=='queue':
print(json.dumps({'queued':True}))
return 0
(root/'database').mkdir(parents=True,exist_ok=True)
config=station_config(root)
if action in ['start','progress','complete','flush']:
flush_inspections(root,config)
device=station_resolve(config,station_identity())
else:
report=json.loads(report_path.read_text()) if report_path else station_identity()
device=station_resolve(config,report)
if action=='intake' and device['stage']=='INTAKE':
device=station_api(config,'POST',f"devices/{device['number']}/intake/",{
'hwid':hardware_id(report),'expected_revision':device['revision'],'specifications':map_fields(report)})
if action == 'intake' and report_path:
raw=report_path.read_bytes(); sha=hashlib.sha256(raw).hexdigest()
with sqlite3.connect(root/'database/intake.sqlite3') as db:
init_db(db);discover_queue(root,db)
db.execute("INSERT OR REPLACE INTO inventory_sync(report_sha256,asset_id,inventory_number,status,last_attempt_utc,response) VALUES(?,?,?,'SYNCED',?,'OK')",(sha,report.get('asset_id',''),device['number'],dt.datetime.now(dt.timezone.utc).isoformat()))
db.execute("UPDATE inventory_queue SET status='SYNCED',error='',next_attempt=0 WHERE report_sha256=?",(sha,))
if db.execute("SELECT name FROM sqlite_master WHERE name='scans'").fetchone():
db.execute("UPDATE scans SET sync_status='SYNCED',synced_inventory_id=? WHERE report_path=?",(str(device['number']),str(report_path)))
result={'device':device,'pending':len(list((root/'outbox/inspections').glob('*.json')))}
station_save(root/'database/station-device.json',result)
print(json.dumps(result))
return 0
def main():
parser = argparse.ArgumentParser(description='Offline inventory queue with server-assigned device numbers')
parser.add_argument('--data', type=Path, default=DEFAULT_DATA)
parser.add_argument('--station', choices=['boot','intake','start','progress','complete','flush','queue'])
parser.add_argument('--identity-json', help=argparse.SUPPRESS)
parser.add_argument('--report', type=Path)
parser.add_argument('--dry-run', action='store_true')
parser.add_argument('--list', action='store_true', help='Discover reports and print queue as JSON; no network requests')
parser.add_argument('--check', action='store_true', help='Verify TLS and API authentication without uploading')
parser.add_argument('--retry', action='store_true', help='Retry errors immediately, ignoring backoff')
args = parser.parse_args()
if args.identity_json:
try:
hwid = hardware_id(json.loads(args.identity_json))
print(json.dumps({'hwid': hwid, 'local_id': 'HW-' + hashlib.sha256(hwid.encode()).hexdigest()[:24]}))
return 0
except ValueError as ex:
print(str(ex), file=sys.stderr)
return 2
root = args.data.resolve()
(root / 'database').mkdir(parents=True, exist_ok=True)
# Cross-process lock prevents GUI/CLI concurrent PATCH requests.
with (root / 'database' / 'inventory-sync.lock').open('a') as lock:
try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print('Another upload worker is running', file=sys.stderr)
return 2
if args.station:
try: return run_station(root,args.station,args.report)
except (OSError,ValueError,KeyError) as ex:
print(connection_error(ex),file=sys.stderr)
return 2
with sqlite3.connect(root / 'database' / 'intake.sqlite3', timeout=20) as db:
init_db(db)
discover_queue(root, db)
if args.list:
db.row_factory = sqlite3.Row
print(json.dumps([dict(row) for row in db.execute('SELECT q.*, s.inventory_number FROM inventory_queue q LEFT JOIN inventory_sync s USING(report_sha256) ORDER BY q.last_attempt DESC, q.report_path')]))
return 0
if args.dry_run:
paths = [args.report] if args.report else sorted((root / 'reports').glob('*/*/report.json'))
for path in paths: sync_one(path, {}, db, True)
return 0
config_path = root / 'config' / 'inventory.json'
try:
config = json.loads(config_path.read_text()) if config_path.exists() else {}
config.setdefault('key_file', str(root / 'config' / 'inventory-api-key'))
cert = root / 'config' / 'inventory-server.crt'
if cert.exists():
config.setdefault('ca_file', str(cert))
if args.check:
print(check_connection(config))
return 0
settings_path = root / 'config' / 'workshop.json'
settings = json.loads(settings_path.read_text()) if settings_path.exists() else {}
delay = max(10, min(3600, int(settings.get('retry_seconds', 60))))
return 1 if process_queue(root, db, config, args.retry, args.report, delay) else 0
except (OSError, ValueError, KeyError) as ex:
print(connection_error(ex), file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Source fingerprint and artifact verification; never touches USB devices."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
import tomllib
ROOT = Path(__file__).resolve().parents[1]
COMPONENTS = {'gui': 'intake-gui', 'scanner': 'pc-intake', 'controller': 'intake-controller'}
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def source_id():
paths = []
for component in COMPONENTS:
base = ROOT / component
paths.extend((base / 'src').rglob('*.rs'))
paths.extend(p for p in [base / 'Cargo.toml', base / 'Cargo.lock', base / 'build.rs'] if p.exists())
for folder in ['scripts', 'live']:
paths.extend(p for p in (ROOT / folder).iterdir() if p.is_file())
h = hashlib.sha256()
for path in sorted(paths):
h.update(str(path.relative_to(ROOT)).encode() + b'\0')
h.update(path.read_bytes())
return 'csr-' + h.hexdigest()[:20]
def create(path):
binaries = {}
for component, name in COMPONENTS.items():
package = tomllib.loads((ROOT / component / 'Cargo.toml').read_text())['package']
binary = ROOT / component / 'target/release' / name
binaries[name] = {'version': package['version'], 'sha256': digest(binary)}
build_id = source_id()
version = subprocess.check_output([ROOT / 'gui/target/release/intake-gui', '--version'], text=True).strip()
if build_id not in version:
raise ValueError('GUI source fingerprint does not match current sources; rebuild release')
manifest = {'schema_version': 1, 'build_id': build_id, 'rustc': subprocess.check_output(['rustc', '--version'], text=True).strip(), 'binaries': binaries}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(manifest, indent=2) + '\n')
def verify(profile):
root = profile / 'airootfs'
manifest = json.loads((root / 'etc/csr-intake/release.json').read_text())
for name, spec in manifest['binaries'].items():
binary = root / 'usr/local/bin' / name
if digest(binary) != spec['sha256']:
raise ValueError(f'Binary hash mismatch: {name}')
if not binary.stat().st_mode & 0o111:
raise ValueError(f'Binary is not executable: {name}')
version = subprocess.check_output([root / 'usr/local/bin/intake-gui', '--version'], text=True)
if manifest['build_id'] not in version:
raise ValueError('GUI build ID differs from release manifest')
required = ['networkmanager', 'curl', 'ca-certificates', 'python', 'usbutils', 'smartmontools', 'alsa-utils', 'ffmpeg', 'stress-ng', 'xdg-utils', 'firefox']
packages = (profile / 'packages.x86_64').read_text().splitlines()
for package in required:
if package not in packages:
raise ValueError(f'Missing live package: {package}')
for file in ['usr/local/bin/intake-login', 'usr/local/bin/intake-prepare', 'etc/systemd/system/getty@tty1.service.d/autologin.conf', 'etc/systemd/system/multi-user.target.wants/NetworkManager.service']:
p = root / file
if not p.exists() and not p.is_symlink():
raise ValueError(f'Missing live startup file: {file}')
print(f"Profile verified: {manifest['build_id']}")
def main():
parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--source-id', action='store_true')
group.add_argument('--create', type=Path)
group.add_argument('--verify-profile', type=Path)
args = parser.parse_args()
if args.source_id: print(source_id())
elif args.create: create(args.create)
else: verify(args.verify_profile)
if __name__ == '__main__': main()

9
scripts/report-view.py Normal file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env python3
import json,sys
r=json.load(open(sys.argv[1],encoding='utf8'))
for k in ('asset_id','tool_version','model','cpu','ram_gib','health_gate'):
print(f'{k}: {r.get(k,"UNKNOWN")}')
print('Disks:')
for d in r.get('disks',[]):print(' ',d.get('name'),d.get('model'),d.get('smart_health'))
print('Warnings:')
for c in r.get('concerns',[]):print(' -',c)

15
tests/smoke.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd "$(dirname "$0")/.."
for sh in scripts/*.sh; do bash -n "$sh"; done
python -m py_compile scripts/*.py
python - <<'PY'
import json, pathlib, sqlite3, subprocess, tempfile
with tempfile.TemporaryDirectory() as td:
p=pathlib.Path(td); report=p/'report.json'; db=p/'database'/'intake.sqlite3'
report.write_text(json.dumps({'asset_id':'PC-0001','tool_version':'1.1.1','model':'Test PC','cpu':'CPU','health_gate':'REVIEW','timestamp_unix':123}))
subprocess.run(['python','scripts/db-import.py',str(db),str(report)],check=True)
subprocess.run(['python','scripts/db-view.py',str(db)],check=True)
with sqlite3.connect(db) as c: assert c.execute('SELECT count(*) FROM scans').fetchone()[0]==1
print('smoke checks OK')
PY

View File

@@ -0,0 +1,174 @@
import importlib.util
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
HERE = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('inventory_sync', HERE/'scripts/inventory-sync.py')
bridge = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bridge)
class Tests(unittest.TestCase):
def setUp(self):
self.report = {
'system_uuid':'12345678-1234-1234-1234-123456789012','asset_id':'PC-0001','cpu':'AMD Ryzen 5 PRO 3500U','model':'HP EliteBook 745 G6',
'ram_gib':13.54,'installed_ram_gib':16.0,
'disks':[{'name':'sda','removable':False,'rotational':False,'size_gib':119},
{'name':'sdb','removable':True,'rotational':True,'size_gib':28}]
}
def test_maps_physical_ram_and_excludes_usb(self):
self.assertEqual(bridge.map_fields(self.report), {'cpu':'AMD Ryzen 5 PRO 3500U','model':'HP EliteBook 745 G6','ram_gb':16,'ssd_gb':128})
def test_hardware_identity_stability_and_fallback(self):
a = {**self.report, 'asset_id': '45'}
b = {**a, 'asset_id': '69', 'installed_ram_gib': 64, 'disks': []}
self.assertEqual(bridge.hardware_id(a), bridge.hardware_id(b))
self.assertEqual(bridge.hardware_id(a), 'dmi-uuid:12345678-1234-1234-1234-123456789012')
serial = {'manufacturer': ' HP ', 'serial_number': ' abc123 '}
self.assertEqual(bridge.hardware_id(serial), bridge.hardware_id({'manufacturer':'hp','serial_number':'ABC123'}))
for data in [{}, {'system_uuid':'00000000-0000-0000-0000-000000000000'}, {'serial_number':'To Be Filled By O.E.M.','manufacturer':'HP'}]:
with self.assertRaises(ValueError): bridge.hardware_id(data)
def test_missing_hwid_never_uses_manual_number(self):
with tempfile.TemporaryDirectory() as tmp, sqlite3.connect(':memory:') as db:
bridge.init_db(db)
path=Path(tmp)/'report.json'
path.write_text(json.dumps({'asset_id':'45','model':'Laptop'}))
with patch.object(bridge,'api_request') as api:
with self.assertRaisesRegex(ValueError,'Hardware-Kennung'): bridge.sync_one(path,{},db)
api.assert_not_called()
def test_sync_revision_and_idempotence(self):
with tempfile.TemporaryDirectory() as tmp:
folder=Path(tmp); (folder/'reports').mkdir(); path=folder/'reports'/'report.json'
path.write_text(json.dumps(self.report))
calls=[]
def fake(base,key,method,suffix,payload=None):
calls.append((method,suffix,payload))
if method=='POST':
return {'number':501,'revision':3,'model':'HP EliteBook 745 G6','cpu':'old','ram_gb':8,'ssd_gb':128}
return {'revision':4}
with sqlite3.connect(':memory:') as db:
bridge.init_db(db)
with patch.object(bridge,'api_request',side_effect=fake), patch.dict('os.environ',{'CSR_API_KEY':'fake'}):
res=bridge.sync_one(path,{'url':'https://example.org'},db)
self.assertEqual(res,('SYNCED',501))
self.assertEqual(calls[0][1], 'devices/resolve/')
self.assertEqual(calls[1][1], 'devices/501/')
self.assertEqual(calls[1][0],'PATCH')
self.assertEqual(calls[1][2]['expected_revision'],3)
self.assertNotIn('ssd_gb',calls[1][2])
self.assertEqual(bridge.sync_one(path,{'url':'https://example.org'},db),('ALREADY_SYNCED',501))
self.assertEqual(len(calls),2)
def test_mismatch_not_patched(self):
with tempfile.TemporaryDirectory() as tmp:
path=Path(tmp)/'report.json';path.write_text(json.dumps(self.report))
with sqlite3.connect(':memory:') as db:
bridge.init_db(db)
with patch.object(bridge,'api_request',return_value={'number':501,'revision':1,'model':'Different machine'}),patch.dict('os.environ',{'CSR_API_KEY':'fake'}):
with self.assertRaisesRegex(ValueError,'MODEL_MISMATCH'):
bridge.sync_one(path,{'url':'https://example.org'},db)
class QueueTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.path = self.root / 'reports/PC-0042/100-1/report.json'
self.path.parent.mkdir(parents=True)
self.report = {'system_uuid':'12345678-1234-1234-1234-123456789012', 'asset_id': 'PC-0042', 'model': 'Test Laptop', 'cpu': 'CPU', 'timestamp_unix': 100}
self.path.write_text(json.dumps(self.report))
self.db = sqlite3.connect(self.root / 'queue.sqlite3')
self.addCleanup(self.db.close)
bridge.init_db(self.db)
def test_discovery_is_persistent_without_credentials(self):
bridge.discover_queue(self.root, self.db)
bridge.discover_queue(self.root, self.db)
self.assertEqual(self.db.execute('SELECT count(*) FROM inventory_queue').fetchone()[0], 1)
with patch.object(bridge, 'api_request') as api:
self.assertEqual(bridge.process_queue(self.root, self.db, {}), 1)
api.assert_not_called()
state = self.db.execute('SELECT status,attempts,error,next_attempt FROM inventory_queue').fetchone()
self.assertEqual(state[:2], ('ERROR', 1))
self.assertIn('not configured', state[2])
self.assertGreater(state[3], 0)
# Backoff skips repeated attempts until due; manual retry bypasses it.
self.assertEqual(bridge.process_queue(self.root, self.db, {}), 0)
self.assertEqual(self.db.execute('SELECT attempts FROM inventory_queue').fetchone()[0], 1)
bridge.process_queue(self.root, self.db, {}, retry=True)
self.assertEqual(self.db.execute('SELECT attempts FROM inventory_queue').fetchone()[0], 2)
def test_retry_after_ambiguous_response_does_not_patch_twice(self):
remote = {'number': 501, 'revision': 1, 'model': 'Test Laptop', 'cpu': 'old'}
patches = []
def api(base, key, method, suffix, payload=None):
if method == 'POST': return dict(remote)
patches.append(payload)
remote.update({k:v for k,v in payload.items() if k != 'expected_revision'})
remote['revision'] += 1
raise OSError('connection lost after server committed')
with patch.object(bridge, 'api_request', side_effect=api), patch.dict('os.environ', {'CSR_API_KEY':'secret'}):
self.assertEqual(bridge.process_queue(self.root, self.db, {'url':'https://example.org'}), 1)
self.assertEqual(bridge.process_queue(self.root, self.db, {'url':'https://example.org'}, retry=True), 0)
self.assertEqual(len(patches), 1)
self.assertEqual(self.db.execute('SELECT status FROM inventory_queue').fetchone()[0], 'SYNCED')
bridge.process_queue(self.root, self.db, {'url':'https://example.org'}, retry=True)
self.assertEqual(len(patches), 1)
def test_changed_report_and_http_error_remain_unsynced(self):
bridge.discover_queue(self.root, self.db)
self.path.write_text(json.dumps({**self.report, 'cpu':'changed'}))
with patch.object(bridge, 'api_request', side_effect=bridge.urllib.error.HTTPError('https://example.org', 409, 'conflict', {}, None)), patch.dict('os.environ', {'CSR_API_KEY':'TOPSECRET'}):
bridge.process_queue(self.root, self.db, {'url':'https://example.org'}, retry=True)
rows = self.db.execute('SELECT status,error FROM inventory_queue').fetchall()
self.assertTrue(all(s == 'ERROR' for s, _ in rows))
self.assertTrue(any('changed' in e for _, e in rows))
self.assertTrue(any('409' in e for _, e in rows))
self.assertFalse(any('TOPSECRET' in e for _, e in rows))
def test_interrupted_upload_rechecks_server(self):
bridge.discover_queue(self.root, self.db)
with self.db: self.db.execute("UPDATE inventory_queue SET status='UPLOADING'")
with patch.object(bridge, 'api_request', return_value={'number':501,'revision':2,'model':'Test Laptop','cpu':'CPU'}) as api, patch.dict('os.environ', {'CSR_API_KEY':'secret'}):
self.assertEqual(bridge.process_queue(self.root, self.db, {'url':'https://example.org'}), 0)
self.assertEqual(api.call_count, 1)
self.assertEqual(api.call_args.args[2], 'POST')
if __name__=='__main__': unittest.main()
class ConnectionTests(unittest.TestCase):
def test_api_check_uses_certificate_and_never_writes(self):
with patch.object(bridge, 'api_request', return_value={}) as api, patch.dict('os.environ', {'CSR_API_KEY': 'test-key'}):
message = bridge.check_connection({'url': 'https://inventory.test', 'ca_file': '/usb/config/inventory-server.crt'})
api.assert_called_once_with('https://inventory.test', 'test-key', 'GET', 'choices/', ca_file='/usb/config/inventory-server.crt')
self.assertIn('No records uploaded', message)
def test_ca_file_keeps_certificate_verification_enabled(self):
context = bridge.ssl.create_default_context()
with patch.object(bridge.ssl, 'create_default_context', return_value=context), patch.object(context, 'load_verify_locations') as load, patch.object(bridge.urllib.request, 'urlopen') as request:
request.return_value.__enter__.return_value.read.return_value = b'{}'
bridge.api_request('https://inventory.test', 'test-key', 'GET', 'choices/', ca_file='/usb/cert.pem')
load.assert_called_once_with(cafile='/usb/cert.pem')
self.assertEqual(context.verify_mode, bridge.ssl.CERT_REQUIRED)
self.assertTrue(context.check_hostname)
self.assertIs(request.call_args.kwargs['context'], context)
def test_network_errors_are_actionable_without_raw_secrets(self):
cases = [
(bridge.ssl.SSLCertVerificationError(1, 'secret'), 'certificate'),
(bridge.socket.gaierror(-2, 'secret'), 'DNS'),
(TimeoutError('secret'), 'timed out'),
(ConnectionRefusedError('secret'), 'refused'),
('https://secret@example.test', 'unreachable'),
]
for reason, expected in cases:
message = bridge.connection_error(bridge.urllib.error.URLError(reason))
self.assertIn(expected, message)
self.assertNotIn('secret', message)
error = bridge.urllib.error.HTTPError('https://secret@example.test', 401, 'secret', {}, None)
self.assertIn('API key', bridge.connection_error(error))
self.assertNotIn('secret', bridge.connection_error(error))
error.close()

View File

@@ -0,0 +1,44 @@
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('release_manifest', ROOT / 'scripts/release-manifest.py')
release = importlib.util.module_from_spec(spec)
spec.loader.exec_module(release)
class ReleaseTests(unittest.TestCase):
def test_source_id_is_stable(self):
self.assertEqual(release.source_id(), release.source_id())
self.assertTrue(release.source_id().startswith('csr-'))
def test_profile_rejects_stale_binary(self):
with tempfile.TemporaryDirectory() as tmp:
profile = Path(tmp)
base = profile / 'airootfs'
(base / 'etc/csr-intake').mkdir(parents=True)
(base / 'usr/local/bin').mkdir(parents=True)
binary = base / 'usr/local/bin/intake-gui'
binary.write_bytes(b'original binary'); binary.chmod(0o755)
manifest = {'build_id':'csr-test','binaries':{'intake-gui':{'sha256':release.digest(binary)}}}
(base / 'etc/csr-intake/release.json').write_text(json.dumps(manifest))
binary.write_bytes(b'stale binary')
with self.assertRaisesRegex(ValueError, 'hash mismatch'):
release.verify(profile)
def test_complete_profile_is_verified(self):
with tempfile.TemporaryDirectory() as tmp:
profile=Path(tmp); base=profile/'airootfs'
for file in ['etc/csr-intake/release.json', 'usr/local/bin/intake-gui', 'usr/local/bin/intake-login', 'usr/local/bin/intake-prepare', 'etc/systemd/system/getty@tty1.service.d/autologin.conf', 'etc/systemd/system/multi-user.target.wants/NetworkManager.service']:
path=base/file;path.parent.mkdir(parents=True,exist_ok=True);path.write_text('fixture')
binary=base/'usr/local/bin/intake-gui';binary.chmod(0o755)
(base/'etc/csr-intake/release.json').write_text(json.dumps({'build_id':'csr-test','binaries':{'intake-gui':{'sha256':release.digest(binary)}}}))
packages=['networkmanager','curl','ca-certificates','python','usbutils','smartmontools','alsa-utils','ffmpeg','stress-ng','xdg-utils','firefox']
(profile/'packages.x86_64').write_text('\n'.join(packages))
with patch.object(release.subprocess,'check_output',return_value='GUI csr-test'):
release.verify(profile)
(profile/'packages.x86_64').write_text('networkmanager')
with self.assertRaisesRegex(ValueError,'Missing live package'):
release.verify(profile)

41
tests/test_station.py Normal file
View File

@@ -0,0 +1,41 @@
from test_inventory_sync import bridge
import unittest,tempfile,json
from pathlib import Path
from unittest.mock import patch
class StationTests(unittest.TestCase):
def test_offline_inspection_is_durable_and_retry_is_idempotent(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);path=root/'inspection.json'
path.write_text(json.dumps({'asset':'HW-test','revision':2,'updated':20,'identity':{'uuid':'12345678-1234-1234-1234-123456789012','model':'Test'},'technician':'Tech','checks':{'wifi':{'outcome':'UNAVAILABLE','notes':'Tool unavailable'}},'notes':''}))
with self.assertRaises(FileNotFoundError):bridge.run_station(root,'progress',path)
files=list((root/'outbox/inspections').glob('*.json'));self.assertEqual(len(files),1)
first=json.loads(files[0].read_text());self.assertEqual(first['body']['results']['wifi']['outcome'],'UNAVAILABLE')
bridge.queue_inspection(root,path,'IN_PROGRESS');self.assertEqual(len(list((root/'outbox/inspections').glob('*.json'))),1)
with patch.object(bridge,'station_resolve',return_value={'number':99,'revision':2}),patch.object(bridge,'station_api',side_effect=OSError('offline')):
with self.assertRaises(OSError):bridge.flush_inspections(root,{})
self.assertTrue(files[0].exists())
with patch.object(bridge,'station_resolve',return_value={'number':99,'revision':3}),patch.object(bridge,'station_api',return_value={'number':99}) as api:
bridge.flush_inspections(root,{})
self.assertEqual(api.call_args.args[3]['request_id'],first['body']['request_id'])
self.assertFalse(files[0].exists());self.assertTrue((root/'outbox/sent'/files[0].name).exists())
def test_intake_does_not_overwrite_later_stage(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);p=root/'report.json';p.write_text(json.dumps({'system_uuid':'12345678-1234-1234-1234-123456789012','cpu':'CPU'}))
with patch.object(bridge,'station_config',return_value={}),patch.object(bridge,'station_resolve',return_value={'number':1,'stage':'STORAGE'}),patch.object(bridge,'station_api') as api:
bridge.run_station(root,'intake',p)
api.assert_not_called()
def test_boot_recovers_saved_inspection_before_network_access(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);(root/'inspections').mkdir()
saved={'asset':'HW-previous','revision':3,'updated':21,
'identity':{'uuid':'12345678-1234-1234-1234-123456789012'},
'sync_phase':'COMPLETE','technician':'Tech','checks':{'wifi':{'outcome':'PASS'}}}
(root/'inspections/previous.json').write_text(json.dumps(saved))
with self.assertRaises(FileNotFoundError):bridge.run_station(root,'boot',None)
queued=list((root/'outbox/inspections').glob('*.json'))
self.assertEqual(len(queued),1)
self.assertEqual(json.loads(queued[0].read_text())['body']['phase'],'COMPLETE')
with self.assertRaises(FileNotFoundError):bridge.run_station(root,'boot',None)
self.assertEqual(len(list((root/'outbox/inspections').glob('*.json'))),1)

10
workshop.example.json Normal file
View File

@@ -0,0 +1,10 @@
{
"language": "de",
"test_url": "https://example.com",
"stress_seconds": 120,
"recording_seconds": 5,
"camera_seconds": 25,
"max_temperature_c": 90.0,
"min_battery_health_pct": 70,
"retry_seconds": 60
}