This commit is contained in:
26
.gitea/workflows/tests.yml
Normal file
26
.gitea/workflows/tests.yml
Normal 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
20
.gitignore
vendored
Normal 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
104
README.md
Normal 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
92
WERKSTATT-ABLAUF.md
Normal 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
|
||||
5
config.example/inventory.json
Normal file
5
config.example/inventory.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"url": "https://inventory.example.org",
|
||||
"device_type_id": 1,
|
||||
"status_id": 1
|
||||
}
|
||||
7
controller/Cargo.lock
generated
Normal file
7
controller/Cargo.lock
generated
Normal 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
5
controller/Cargo.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[package]
|
||||
name = "intake-controller"
|
||||
version = "1.7.0"
|
||||
edition = "2021"
|
||||
[dependencies]
|
||||
194
controller/src/extended.rs
Normal file
194
controller/src/extended.rs
Normal 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
28
controller/src/main.rs
Normal 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
3267
gui/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
gui/Cargo.toml
Normal file
9
gui/Cargo.toml
Normal 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
39
gui/build.rs
Normal 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
483
gui/src/diagnostics.rs
Normal 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
72
gui/src/inventory.rs
Normal 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
1936
gui/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
187
gui/src/report.rs
Normal file
187
gui/src/report.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
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("<script>"));
|
||||
assert!(h.contains("NOT APPROVED"));
|
||||
assert!(h.contains("@media print"));
|
||||
}
|
||||
}
|
||||
825
gui/src/wifi.rs
Normal file
825
gui/src/wifi.rs
Normal 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
893
gui/src/workflow.rs
Normal 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
7
live/autologin.conf
Normal 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
19
live/intake-login.sh
Normal 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
|
||||
14
live/intake-prepare.service
Normal file
14
live/intake-prepare.service
Normal 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
51
live/intake-prepare.sh
Normal 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
7
scanner/Cargo.lock
generated
Normal 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
11
scanner/Cargo.toml
Normal 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
628
scanner/src/main.rs
Normal 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("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => 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} °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&"),"<x&");}#[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
22
scripts/build-iso.sh
Normal 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
13
scripts/build-release.sh
Normal 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
62
scripts/create-profile.sh
Executable 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
17
scripts/db-import.py
Executable 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
10
scripts/db-view.py
Normal 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
80
scripts/install-usb.sh
Executable 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
28
scripts/intake-session.sh
Executable 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
424
scripts/inventory-sync.py
Normal 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())
|
||||
78
scripts/release-manifest.py
Normal file
78
scripts/release-manifest.py
Normal 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
9
scripts/report-view.py
Normal 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
15
tests/smoke.sh
Executable 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
|
||||
174
tests/test_inventory_sync.py
Normal file
174
tests/test_inventory_sync.py
Normal 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()
|
||||
44
tests/test_release_manifest.py
Normal file
44
tests/test_release_manifest.py
Normal 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
41
tests/test_station.py
Normal 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
10
workshop.example.json
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user