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

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

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

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

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

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

1936
gui/src/main.rs Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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